Cards in columns, moved between columns by drag handle or keyboard, with announcements
Tags: component board kanban drag columns assign
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/Enteron a handle - Pick up / dropArrowLeft/ArrowRight- Move the picked-up card to the previous / next column it may go toArrowUp/ArrowDown- Change its place in the column (sortableonly)Home/End- Move it to the top / bottom of the column (sortableonly)Escape- Cancel and put it back (also cancels a pointer drag)
Accessibility
- Handles are buttons named "Move {card}" with
aria-pressedwhile picked up and instructions linked byaria-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-noneto the handle so touch drags don't scroll the page. - Keyboard moves are previews:
onMoveruns once, on drop. Escape or moving focus away cancels.
Related Components
- SortableList - Reorder a single list