Skip to main content

KanbanBoard

KanbanBoard

Cards in columns, moved between columns by drag handle or keyboard, with announcements

Tags: component board kanban drag columns assign

View Demo

KanbanBoard lays cards out in columns - designers and their homes, a status pipeline, a triage queue - and lets people move a card to another column by dragging its handle or with the keyboard. Each card renders its own drag handle by spreading handle on a button, so the rest of the card stays clickable. The board never moves cards itself: it calls onMove once per drop and renders whatever items and getColumn say next, so a parent that refuses a move simply leaves the card where it was.

Svelte only for now.

Import​

<script>
  import { KanbanBoard } from '@classic-homes/theme-svelte';
</script>

Basic Usage​

<script>
  import { KanbanBoard, Icon } from '@classic-homes/theme-svelte';

  const columns = [
    { id: 'todo', title: 'To do' },
    { id: 'doing', title: 'In progress' },
    { id: 'done', title: 'Done' },
  ];
  let tasks = $state([
    { id: 'a', title: 'Frame inspection', status: 'todo' },
    { id: 'b', title: 'Drywall walk', status: 'doing' },
  ]);

  function move({ item, to }) {
    tasks = tasks.map((t) => (t.id === item.id ? { ...t, status: to } : t));
  }
</script>

<KanbanBoard
  {columns}
  items={tasks}
  getKey={(t) => t.id}
  getColumn={(t) => t.status}
  getItemLabel={(t) => t.title}
  onMove={move}
  label="Punch list"
>
  {#snippet card({ item, handle, dragging })}
    <div
      class="flex items-center gap-2 rounded-md border bg-background p-2 text-sm"
      class:shadow-lg={dragging}
    >
      <button {...handle} class="cursor-grab touch-none"><Icon name="grip" size={14} /></button>
      {item.title}
    </div>
  {/snippet}
</KanbanBoard>

Examples​

Saving a move​

onMove is the one place a move happens. Update your data (optimistically, then save) and the card stays where it was dropped; leave the data alone and it goes back.

<KanbanBoard
  {columns}
  items={homes}
  getKey={(h) => h.id}
  getColumn={(h) => h.designerId ?? 'unassigned'}
  onMove={async ({ item, to }) => {
    const previous = item.designerId;
    item.designerId = to === 'unassigned' ? null : to;
    try {
      await save(item.id, { designerId: item.designerId });
    } catch {
      item.designerId = previous;
      toastStore.error('Could not assign the designer');
    }
  }}
  label="Designer assignments"
  {card}
/>

Refusing columns​

canDrop keeps a card out of columns it may not go to. Refused columns dim while a card is dragged, the pointer can't drop there, and the arrow keys skip them.

<KanbanBoard
  {columns}
  items={tasks}
  getKey={(t) => t.id}
  getColumn={(t) => t.status}
  canDrop={(task, to) => to !== 'done' || task.checked}
  onMove={move}
  label="Punch list"
  {card}
/>

Ordering within a column​

With sortable on, a card can also change its place in a column: drag it between cards, or use the up and down arrows while it is picked up. onMove reports the new position as index, counted among the other cards in that column.

<KanbanBoard
  sortable
  {columns}
  items={tasks}
  getKey={(t) => t.id}
  getColumn={(t) => t.status}
  onMove={({ item, to, index }) => moveTo(item, to, index)}
  label="Sprint"
  {card}
/>

Custom column headers​

Replace the title and count, and say what an empty column is for.

<KanbanBoard
  {columns}
  items={homes}
  getKey={(h) => h.id}
  getColumn={(h) => h.designer}
  onMove={assign}
  label="Designers"
  {card}
>
  {#snippet columnHeader({ column, items })}
    <header class="flex items-baseline gap-2 border-b px-3 py-2">
      <h3 class="flex-1 truncate text-sm font-semibold">{column.title}</h3>
      <span class="text-sm tabular-nums text-muted-foreground">{items.length}</span>
    </header>
  {/snippet}
  {#snippet columnEmpty()}
    <p class="p-2 text-xs text-muted-foreground">Drop a home here.</p>
  {/snippet}
</KanbanBoard>

Props​

Prop Type Default Description
columns KanbanColumn[] required Columns in order: { id, title, description? } (required)
items T[] required Cards; each shows in the column getColumn names (required)
getKey (item) => string | number required Stable key (required)
getColumn (item) => string required Id of the card's column (required)
onMove (move: { item, from, to, index }) => void required A card was dropped somewhere new - once per drop (required)
card Snippet<[{ item, column, handle, dragging }]> required Renders one card; spread handle on its drag button (required)
label string required Accessible name of the board (required)
columnHeader Snippet<[{ column, items }]> - Replaces the default header (title, count, description)
columnEmpty Snippet<[{ column }]> - Shown in a column with no cards
canDrop (item, to) => boolean - Whether a card may go to a column; refused columns dim and are skipped
sortable boolean false Cards can be reordered within a column; otherwise a move lands at the end
getItemLabel (item) => string "Card" Name used in announcements and handle labels
disabled boolean false Turns moving off
class string - Classes for the board
columnClass string - Classes for each column (e.g. a width other than w-72)
cardClass string - Classes for each card's li

Keyboard Navigation​

  • Space / Enter on a handle - Pick up / drop
  • ArrowLeft / ArrowRight - Move the picked-up card to the previous / next column it may go to
  • ArrowUp / ArrowDown - Change its place in the column (sortable only)
  • Home / End - Move it to the top / bottom of the column (sortable only)
  • Escape - Cancel and put it back (also cancels a pointer drag)

Accessibility​

  • Handles are buttons named "Move {card}" with aria-pressed while picked up and instructions linked by aria-describedby.
  • Every pick up, move, drop and cancel is announced in a live region; a keyboard move keeps focus on the card's handle.
  • Columns are labelled groups with their card count ("Leigh, 3 cards").
  • A pointer drag starts only after the pointer moves a few pixels, so clicking a handle does nothing, and the click that ends a drag is swallowed. Add touch-none to the handle so touch drags don't scroll the page.
  • Keyboard moves are previews: onMove runs once, on drop. Escape or moving focus away cancels.
On this page