Kanban Board
Published on 29 May 2025
Ever wondered how tools like Jira or Linear manage their boards? Here's a fun UI challenge, making a Kanban board. I'll try walking you through how to build a simple Kanban board from scratch — explained as easy as possible.
What will you learn?
This article introduces the Kanban Board UI component. Popular in task tracking tools like Jira, Linear and Trello.
This article will demonstrate the UI component being made using vanilla HTML and JS and styled using TailwindCSS.
What is a Kanban Board?
A Kanban board in a user interface (UI) is a visual project management tool used to organize tasks and workflows. It typically consists of columns representing different stages of a process (such as "To Do," "In Progress," and "Done") and movable cards representing individual tasks or items. Users can drag and drop cards between columns to reflect progress, making it easy to track the status of work at a glance. Kanban boards help teams visualize work, limit work-in-progress, and optimize task flow in a clear and intuitive manner.
How do we make one?
Making a kanban board is surprisingly simpler than you might think. Even though it might seem like a big component, we can break it down into smaller requirements. Let's take a look:
Requirements
- The Kanban Board should have 3 columns representing the different states
- A board could have ≥ 0 card(s) in it.
- A card should be draggable.
- A card can be dragged into another column. It could also be rearranged in it's current column by dragging it above/below another card.
- Once a card has been dropped into another column, the card should no longer be visible in the original column but instead should appear in the new column.
Now that we have a set of requirements, we can start to implement it. We're going to focus on the 2 main aspects, the HTML and JS:
The HTML
- Create a wrapper for the board.
<div id="kanban-board" class="w-full p-8 rounded-sm flex flex-row gap-8"></div>
- Let's assume our kanban board has 3 columns.
Todo
,In Progress
andDone
(pretty standard). This can be represented by 3 sibling divs:
<div id="kanban-board" class="w-full p-8 rounded-sm flex flex-row gap-8">
<div class="kanban-column flex-1 px-8 rounded-sm min-h-[300px] bg-secondary">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">To Do</h3>
<div class="kanban-column-body min-h-[250px]"></div>
</div>
<div class="kanban-column flex-1 px-8 rounded-sm min-h-[300px] bg-secondary">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
In Progress
</h3>
<div class="kanban-column-body min-h-[250px]"></div>
</div>
<div class="kanban-column flex-1 px-8 rounded-sm min-h-[300px] bg-secondary">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">Done</h3>
<div class="kanban-column-body min-h-[250px]"></div>
</div>
</div>
- Now that we have the columns defined, we'll need to a child div to each column since each column will potentially have cards in it. I've included 2 cards in the
Todo
column to use as an example later on. Note that for each card, a draggable attribute is applied. This declares the card as a draggble block.
<div id="kanban-board" class="w-full p-8 rounded-sm flex flex-row gap-8">
<div class="kanban-column flex-1 px-8 rounded-sm min-h-[300px] bg-secondary">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">To Do</h3>
<div class="kanban-column-body min-h-[250px]">
<div
class="kanban-card font-semibold border-l-4 border-l-blue-500 shadow-sm bg-white dark:bg-slate-600 transition-all ease-in-out duration-100 hover:bg-slate-200 dark:hover:bg-slate-600 p-4 mb-4 cursor-move"
draggable
>
Task 1
</div>
<div
class="kanban-card font-semibold border-l-4 border-l-blue-500 shadow-sm bg-white dark:bg-slate-600 transition-all ease-in-out duration-100 hover:bg-slate-200 dark:hover:bg-slate-600 p-4 mb-4 cursor-move"
draggable
>
Task 2
</div>
</div>
</div>
<div class="kanban-column flex-1 px-8 rounded-sm min-h-[300px] bg-secondary">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
In Progress
</h3>
<div class="kanban-column-body min-h-[250px]"></div>
</div>
<div class="kanban-column flex-1 px-8 rounded-sm min-h-[300px] bg-secondary">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">Done</h3>
<div class="kanban-column-body min-h-[250px]"></div>
</div>
</div>
The JavaScript
This is where the magic happens. We'll be utilising the HTML Drag and Drop API to make this work. But before I get into the main logic, let's take a step back and understand what API's are going to be involved. In this API, there are a few events that we'll have to get familiar with:
Event | Fires when... |
---|---|
dragend | ...a drag operation ends (such as releasing a mouse button or hitting the Esc key; see Finishing a Drag.) |
dragover | ...a dragged item is being dragged over a valid drop target, every few hundred milliseconds. |
dragstart | ...the user starts dragging an item. (See Starting a Drag Operation.) |
drop | ...an item is dropped on a valid drop target. (See Performing a Drop.) |
Those are basically all the event listeners that we need to account for here. Simple right? Now let's write the JavaScript:
- Let's first deal with the event listeners for the columns.
// Get all column drop areas
const columns = document.querySelectorAll('.kanban-column-body')
- On each of these column bodies, we need to handle what happens when a card is dropped (ie. when the mouse is released while a card is hovering inside a column body)
columns.forEach((column) => {
column.addEventListener('drop', (e) => {
if (draggedCard) {
column.appendChild(draggedCard)
}
})
})
- There is one more thing to account for. Since the default behaviour of the dragover event is to prevent dropping of elements onto the target element, we'll need to disable this behaviour.
columns.forEach((column) => {
column.addEventListener('drop', (e) => {
if (draggedCard) {
column.appendChild(draggedCard)
}
})
column.addEventListener('dragover', (e) => {
// Allows dropping the cards
e.preventDefault()
})
})
- Now we can move on to dealing with the cards' event listeners. Let's start by grabbing all the available cards:
// Keeps track of which card is being dragged at the moment
let draggedCard = null
// Get all draggable cards
const cards = document.querySelectorAll('.kanban-card')
- For each card, we need to define what will happen when we start dragging it and what happens when the dragging ends. For the start of the drag:
let draggedCard = null
// Get all draggable cards
const cards = document.querySelectorAll('.kanban-card')
cards.forEach((card) => {
// Handle the dragstart event
card.addEventListener('dragstart', (e) => {
draggedCard = card
setTimeout(() => {
// Hide the original card while dragging
card.style.display = 'none'
}, 0)
})
})
Now you might be wondering why is there a setTimout
callback present. Well, without this, if you immediately call card.style.display = 'none'
synchronously inside dragstart, the browser will not render the drag image (the ghost image that follows your cursor). That's because:
- The browser starts computing and rendering the drag feedback image at the same time as dragstart fires.
- If you hide the element before the browser captures that image, it ends up with nothing to render.
Think of the setTimeout
callback acting as the equivalent of a nextTick()
(if you're familiar with VueJS)
- Lastly, we need to handle what happens when the dragging ends:
let draggedCard = null
// Get all draggable cards
const cards = document.querySelectorAll('.kanban-card')
cards.forEach((card) => {
// Handle the dragstart event
card.addEventListener('dragstart', (e) => {
draggedCard = card
setTimeout(() => {
// Hide the original card while dragging
card.style.display = 'none'
}, 0)
})
// Handle the dragend event
card.addEventListener('dragend', (e) => {
setTimeout(() => {
// Show after drop
card.style.display = 'block'
draggedCard = null
}, 0)
})
})
- Now let's put all of the code together and see what happens:
<div id="kanban-board" class="w-full p-8 rounded-sm flex flex-row gap-8">
<div class="kanban-column flex-1 px-8 rounded-sm min-h-[300px] bg-secondary">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">To Do</h3>
<div class="kanban-column-body min-h-[250px]">
<div
class="kanban-card font-semibold border-l-4 border-l-blue-500 shadow-sm bg-white dark:bg-slate-600 transition-all ease-in-out duration-100 hover:bg-slate-200 dark:hover:bg-slate-600 p-4 mb-4 cursor-move"
draggable
>
Task 1
</div>
<div
class="kanban-card font-semibold border-l-4 border-l-blue-500 shadow-sm bg-white dark:bg-slate-600 transition-all ease-in-out duration-100 hover:bg-slate-200 dark:hover:bg-slate-600 p-4 mb-4 cursor-move"
draggable
>
Task 2
</div>
</div>
</div>
<div class="kanban-column flex-1 px-8 rounded-sm min-h-[300px] bg-secondary">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
In Progress
</h3>
<div class="kanban-column-body min-h-[250px]"></div>
</div>
<div class="kanban-column flex-1 px-8 rounded-sm min-h-[300px] bg-secondary">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">Done</h3>
<div class="kanban-column-body min-h-[250px]"></div>
</div>
</div>
<script>
let draggedCard = null
// Get all draggable cards
const cards = document.querySelectorAll('.kanban-card')
cards.forEach((card) => {
// Handle the dragstart event
card.addEventListener('dragstart', (e) => {
draggedCard = card
setTimeout(() => {
// Hide the original card while dragging
card.style.display = 'none'
}, 0)
})
// Handle the dragend event
card.addEventListener('dragend', (e) => {
setTimeout(() => {
// Show after drop
card.style.display = 'block'
draggedCard = null
}, 0)
})
})
// Get all column drop areas
const columns = document.querySelectorAll('.kanban-column-body')
columns.forEach((column) => {
column.addEventListener('dragover', (e) => {
// Allows dropping the cards
e.preventDefault()
})
column.addEventListener('drop', (e) => {
if (draggedCard) {
column.appendChild(draggedCard)
}
})
})
</script>
Demo Time
Feel free to have a play around with the resulting Kanban Board below. Please note the following interactive includes code that supports mobile drag and drop. If you did not follow the steps outlined in the mobile drag and drop section above, then your interactive will not work on mobile screens.
