Kanban

Drag-and-drop board with columns, card reordering, and keyboard move support.

Live demo

01

Basic 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Β /Β 3

Custom 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
Kanban.svelte
<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

03

What 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 onChange

The 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)
        β”‚
        β–Ό
       idle

Props 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 cards

API

04
PropTypeDefaultDescription
columnsKanbanColumn<T>[] (bindable)[]The board: ordered columns, each { id, title, cards, wipLimit? }.
cardSnippet<[T, KanbanColumn<T>]>β€”Renders one card; receives the card item and its owning column.
onChange(columns) => voidundefinedCalled with the new board after each successful move.
getId(item: T) => stringitem.idDerives the stable key / drag payload for a card.