Kanban
Drag-and-drop board with columns, card reordering, and keyboard move support.
Live demo
01Basic board Β· drag or keyboard
Drag a card between columns, or focus one and press Space, steer with β β β β, then Space to drop. Esc cancels.
To Do
3- Draft release notes
- Audit colour tokens
- Reply to support thread
In Progress
1- Kanban keyboard moves
Done
1- Ship dark mode
WIP limits Β· capped columns
Active is capped at 2 and Shipped at 3. Try to overfill one β the move is refused and announced.
Backlog
3- Spec the API
- Wireframe board
- Pick a font
Active
1Β /Β 2- Build component
Shipped
0Β /Β 3Custom cards Β· your markup via snippet
The card snippet receives the item and its column, so you decide the layout β here a coloured tag chip.
Inbox
2- Customer feedback research
- Perf regression bug
Triaged
1- New onboarding flow feature
Implementation
02<script lang="ts">
import Kanban, { type KanbanColumn } from '$lib/components/Kanban.svelte';
β
type Task = { id: string; title: string };
let columns = $state<KanbanColumn<Task>[]>([
{ id: 'todo', title: 'To do', cards: [{ id: '1', title: 'Spec' }, { id: '2', title: 'Design' }] },
{ id: 'doing', title: 'In progress', cards: [{ id: '3', title: 'Build' }] },
{ id: 'done', title: 'Done', cards: [] }
]);
</script>
β
<Kanban bind:columns getId={(t) => t.id}>
{#snippet card(task)}
<strong>{task.title}</strong>
{/snippet}
</Kanban>Kanban owns the board: native HTML5 drag-and-drop reorders cards within a column and moves them between columns, while a parallel keyboard model (Space to pick up, arrows to steer, Space to drop, Escape to cancel) makes every move possible without a pointer. An aria-live region narrates each step. Optional per-column wipLimit refuses cross-column drops that would overflow, and a card snippet hands full markup control to the consumer.
Logic explainer
03What Does It Do? (Plain English)
Kanban is a Trello-style board. You get a row of columns (To Do, In Progress, Doneβ¦) and each column holds a stack of cards. You can drag a card to a new spot in its own column, or fling it across to another column, and the board updates instantly. Crucially, it also works with just the keyboard: focus a card, press Space to "pick it up", steer with the arrow keys, then press Space again to drop it β no mouse required.
Think of it like: a corkboard of sticky notes. Grab a note, slide it under another note or onto a different section of the board, and let go.
You bring the data and decide how a card looks (via a snippet); the component owns the dragging, reordering, keyboard moves, WIP-limit enforcement, and screen-reader announcements.
How It Works (Pseudo-Code)
STATE:
columns β ordered list, each { id, title, cards[], wipLimit? }
dragSource β { col, idx } of the pointer-dragged card (or null)
dropTarget β { col, idx } where it would land (or null)
keyboardLift β { col, idx } of the keyboard-lifted card (or null)
ON pointer dragstart(col, idx):
dragSource = { col, idx }
ON pointer dragover a card(col, idx):
preventDefault # mark as a valid drop zone
dropTarget = before-or-after idx, based on pointer Y vs card midpoint
ON pointer drop(col):
moveCard(dragSource β dropTarget)
clear dragSource, dropTarget
ON card keydown:
Space/Enter:
IF nothing lifted β keyboardLift = this card; ANNOUNCE "picked up"
ELSE β moveCard to keyboardLift position; ANNOUNCE "dropped"; clear
Escape (while lifted): clear lift; ANNOUNCE "cancelled"
Arrow Up/Down (while lifted): nudge target index within column
Arrow Left/Right (while lifted): hop to adjacent column, clamp index
moveCard(fromCol, fromIdx, toCol, toIdx):
IF crossing columns AND toCol is at wipLimit β REFUSE + announce
splice card out of source; adjust index if same-column downward move; splice in
reassign columns (new structure); call onChangeThe Two Move Models: Pointer and Keyboard
The component deliberately runs two parallel move models that both funnel into one moveCard() function, so behaviour stays identical regardless of input device.
Pointer (HTML5 DnD). dragstart records the source. dragover on each card calls preventDefault() (without which the browser refuses the drop) and computes whether the pointer is over the top or bottom half of the hovered card β that decides whether the dragged card lands before or after it. A thin accent bar (.kb-drop) renders at the computed slot so the user sees exactly where the card will go.
Keyboard. There is no native keyboard DnD, so we simulate it. Space "lifts" a card into keyboardLift; arrow keys mutate that target ({col, idx}) rather than moving anything yet; a second Space commits the move. This "lift β steer β commit" loop is the accessible-DnD pattern recommended for sortable lists, and Escape always restores the pre-lift state.
WIP Limits
A column may declare a wipLimit. The count badge shows n / limit and turns red once full. moveCard() refuses any incoming cross-column card that would push a full column over its limit, announcing the refusal via the live region. Reordering within an already-full column is still allowed (the count doesn't change), which matches how real boards behave.
State Flow Diagram
ββββββββββββ idle ββββββββββββ
β β
dragstart / Space (no card held)
β β
βΌ β
βββββββββββ arrows/dragover ββββ΄ββββββββ
β holding β ββββββββββββββββββΊ β retarget β
ββββββ¬βββββ ββββββ¬ββββββ
β drop / Space (commit) β Escape
βΌ βΌ
moveCard() βββΊ onChange() idle (unchanged)
β
βΌ
idleProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
columns |
KanbanColumn<T>[] (bindable) |
[] |
The board state. Each column is { id, title, cards, wipLimit? }. |
card |
Snippet<[T, KanbanColumn<T>]> |
β (required) | Renders one card; receives the card item and its owning column. |
onChange |
(columns: KanbanColumn<T>[]) => void |
undefined |
Called with the new board after every successful move. |
getId |
(item: T) => string |
item.id |
Derives the stable {#each} key / drag payload for a card. |
KanbanColumn<T> = { id: string; title: string; cards: T[]; wipLimit?: number }. Card type T must extend { id: string }.
Edge Cases
| Case | Behaviour |
|---|---|
| Drop into an empty column | dragover on column body targets index 0; a "Drop cards here" placeholder shows when empty. |
| Same-column downward reorder | Index is decremented by one after removal so the card lands exactly where the indicator showed. |
| Cross-column drop into a full column | Refused; live region announces "Cannot move β {column} is at its limit of {n}". |
| Reorder within a full column | Allowed β the count is unchanged, so no limit is breached. |
| Escape mid-keyboard-move | Lift is discarded; board returns to its pre-lift state, announced as "Move cancelled". |
| Arrow past the first/last column | Clamped β no wrap-around; target index is clamped to the new column's length. |
| Two-way binding | bind:columns and onChange both reflect the same new array reference after a move. |
Dependencies
Zero. Pure Svelte 5 runes ($state, $bindable, $props), native HTML5 drag-and-drop, and a Svelte snippet for card rendering. No drag library, no icon library.
File Structure
src/lib/components/
βββ Kanban.svelte # Component: columns, DnD + keyboard move logic, scoped CSS
βββ Kanban.md # This explainer
βββ Kanban.test.ts # Render, keyboard pick-up/move, WIP limit, ARIA assertions
src/routes/kanban/
βββ +page.svelte # Live demo (ComponentPageShell): basic board, WIP limits, custom cardsAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
columns | KanbanColumn<T>[] (bindable) | [] | The board: ordered columns, each { id, title, cards, wipLimit? }. |
card | Snippet<[T, KanbanColumn<T>]> | β | Renders one card; receives the card item and its owning column. |
onChange | (columns) => void | undefined | Called with the new board after each successful move. |
getId | (item: T) => string | item.id | Derives the stable key / drag payload for a card. |