Kanban
A board of columns you drag cards between.
Walkthrough
1. Basic
Drag a card with a mouse, a finger or the keyboard: Space picks it up, the arrows move it, Space drops it and Escape puts it back. The card you lift stays dimmed in place, a gap opens where it would land, and the ghost flies into it on release — columns is only rewritten once it has landed, so a cancelled drag costs nothing.
Press Space to pick the card up, the arrow keys to move it, Space to drop it and Escape to put it back.
Move a card to see the onmove payload.<script lang="ts"> import { Kanban, Text, type KanbanColumn, type KanbanMove } from 'omaris'; type Task = { id: string; title: string; owner: string }; let columns = $state<KanbanColumn<Task>[]>([ { id: 'todo', title: 'To do', items: [ { id: 't1', title: 'Rewrite the onboarding email', owner: 'Dara' }, { id: 't2', title: 'Audit contrast in dark mode', owner: 'Hana' } ] }, { id: 'doing', title: 'In progress', tone: 'info', limit: 2, items: [{ id: 't3', title: 'Keyboard drag for the board', owner: 'Mira' }] }, { id: 'done', title: 'Done', tone: 'success', items: [{ id: 't4', title: 'Carousel caption fade', owner: 'Ali' }] } ]); let last = $state<string | null>(null);</script><div class="flex w-full flex-col gap-2"> <Kanban bind:columns label={(task) => task.title} height="18rem" onmove={(move: KanbanMove<Task>) => (last = `${move.item.title}: ${move.from} → ${move.to} @ ${move.toIndex + 1}`)} > {#snippet card(task: Task)} <div class="flex flex-col gap-1"> <Text variant="body-md">{task.title}</Text> <Text variant="label-sm" tone="muted">{task.owner}</Text> </div> {/snippet} </Kanban> <Text variant="label-sm" tone="muted" data-kanban-readout> {last ?? 'Move a card to see the onmove payload.'} </Text></div> 2. Limits and adding
A column's limit turns its count amber once it is over and refuses cards from other columns while it is at it; locked refuses every drop and says so in its header. addable puts an "add a card" row under each stack — onadd decides what that means.
Press Space to pick the card up, the arrow keys to move it, Space to drop it and Escape to put it back.
<script lang="ts"> import { Kanban, Text, type KanbanColumn } from 'omaris'; type Ticket = { id: string; title: string; points: number }; let next = 100; let columns = $state<KanbanColumn<Ticket>[]>([ { id: 'triage', title: 'Triage', items: [ { id: 'k1', title: 'Select clears on blur', points: 2 }, { id: 'k2', title: 'Tooltip flips at the edge', points: 1 }, { id: 'k3', title: 'Sheet snap points jump', points: 3 } ] }, { id: 'wip', title: 'In progress', limit: 1, items: [ { id: 'k4', title: 'Kanban overlap packing', points: 5 }, { id: 'k5', title: 'Gauge tick alignment', points: 2 } ] }, { id: 'shipped', title: 'Shipped', locked: true, items: [{ id: 'k6', title: 'Docs demos', points: 8 }] } ]); function add(columnId: string) { columns = columns.map((column) => column.id === columnId ? { ...column, items: [...column.items, { id: `k${next++}`, title: 'New ticket', points: 1 }] } : column ); }</script><Kanban bind:columns density="compact" columnWidth={220} height="18rem" addable addLabel="Add a ticket" onadd={add} emptyText="Empty — drop something here" class="w-full"> {#snippet card(ticket: Ticket)} <div class="flex items-start justify-between gap-2"> <Text variant="body-sm">{ticket.title}</Text> <Text variant="label-sm" tone="muted">{ticket.points}</Text> </div> {/snippet}</Kanban> 3. Rich cards
A card is whatever the card snippet draws — here a Badge per label, an Avatar for the assignee, a due-date Chip and a priority dot. Each column gets a tone for its header dot, and columnActions puts a Menu at the end of the header.
Press Space to pick the card up, the arrow keys to move it, Space to drop it and Escape to put it back.
<script lang="ts"> import { Avatar, Badge, Chip, IconButton, Kanban, Menu, MenuItem, Text, type KanbanColumn } from 'omaris'; type Priority = 'low' | 'medium' | 'high'; type Issue = { id: string; title: string; assignee: string; labels: string[]; due: string; priority: Priority; }; const PRIORITY: Record<Priority, string> = { low: 'bg-success', medium: 'bg-warning', high: 'bg-destructive' }; let columns = $state<KanbanColumn<Issue>[]>([ { id: 'backlog', title: 'Backlog', tone: 'secondary', items: [ { id: 'i1', title: 'Select clears its value on blur', assignee: 'Dara Aziz', labels: ['bug'], due: 'Fri', priority: 'high' }, { id: 'i2', title: 'Tooltip flips at the viewport edge', assignee: 'Hana Karim', labels: ['bug', 'a11y'], due: 'Mon', priority: 'medium' } ] }, { id: 'doing', title: 'In progress', tone: 'info', limit: 2, items: [ { id: 'i3', title: 'Kanban overlap packing', assignee: 'Mira Salih', labels: ['feature'], due: 'Thu', priority: 'low' } ] }, { id: 'review', title: 'In review', tone: 'tertiary', items: [ { id: 'i4', title: 'Gauge tick alignment', assignee: 'Ali Hadi', labels: ['polish'], due: 'Today', priority: 'medium' } ] } ]); function clear(columnId: string) { columns = columns.map((column) => (column.id === columnId ? { ...column, items: [] } : column)); }</script><Kanban bind:columns height="20rem" columnWidth={256} class="w-full"> {#snippet columnActions(column: KanbanColumn<Issue>)} <Menu label="{column.title} actions" align="end"> {#snippet trigger(props)} <IconButton size="xs" aria-label="{column.title} actions" {...props}> <svg viewBox="0 0 24 24" fill="currentColor"> <circle cx="12" cy="5" r="1.6" /> <circle cx="12" cy="12" r="1.6" /> <circle cx="12" cy="19" r="1.6" /> </svg> </IconButton> {/snippet} <MenuItem>Rename</MenuItem> <MenuItem>Set a limit</MenuItem> <MenuItem tone="destructive" onclick={() => clear(column.id)}>Clear column</MenuItem> </Menu> {/snippet} {#snippet card(issue: Issue)} <div class="flex flex-col gap-2"> <div class="flex flex-wrap gap-1"> {#each issue.labels as label (label)} <Badge size="sm" variant="tonal" tone={label === 'bug' ? 'destructive' : label === 'a11y' ? 'info' : 'secondary'} > {label} </Badge> {/each} </div> <Text variant="body-md">{issue.title}</Text> <div class="flex items-center gap-2"> <span class="size-2 shrink-0 rounded-full {PRIORITY[issue.priority]}" role="img" aria-label="{issue.priority} priority" ></span> <Chip size="sm" class="pointer-events-none">{issue.due}</Chip> <Avatar size="xs" name={issue.assignee} colorize class="ms-auto" /> </div> </div> {/snippet}</Kanban> 4. Overridden
The overridden case. header replaces a column's whole header row, key names the identity that keeps a card the same element while it slides, the card snippet is told whether it is the ghost being dragged, and height is a property rather than a class — so height="auto" plus an h-* on classes.root really replaces it.
Press Space to pick the card up, the arrow keys to move it, Space to drop it and Escape to put it back.
<script lang="ts"> import { Kanban, Text, latn, type KanbanCardContext, type KanbanColumn } from 'omaris'; type Lead = { id: string; company: string; value: number }; const TINT: Record<string, string> = { new: 'bg-primary', talking: 'bg-warning', won: 'bg-success' }; let columns = $state<KanbanColumn<Lead>[]>([ { id: 'new', title: 'New', items: [{ id: 'l1', company: 'Karwan Foods', value: 4200 }] }, { id: 'talking', title: 'In conversation', items: [ { id: 'l2', company: 'Zagros Cafe', value: 1800 }, { id: 'l3', company: 'Tigris Grill', value: 9600 } ] }, { id: 'won', title: 'Won', items: [{ id: 'l4', company: 'Sulay Bakery', value: 3100 }] } ]);</script><Kanban bind:columns key={(lead) => lead.id} height="auto" columnWidth="14rem" classes={{ root: 'h-64 rounded-shape-lg bg-surface-container-low p-2', column: 'rounded-shape-md bg-surface-container-lowest', card: 'rounded-shape-md shadow-1' }} class="w-full"> {#snippet header(column: KanbanColumn<Lead>)} <div class="flex items-center gap-2 px-3 py-2"> <span class="size-2 rounded-full {TINT[column.id]}" aria-hidden="true"></span> <Text variant="title-sm">{column.title}</Text> <Text variant="label-sm" tone="muted" class="ms-auto"> ${column.items.reduce((sum, lead) => sum + lead.value, 0).toLocaleString(undefined, latn())} </Text> </div> {/snippet} {#snippet card(lead: Lead, ctx: KanbanCardContext<Lead>)} <div class="flex flex-col gap-1"> <Text variant="body-md">{lead.company}</Text> <Text variant="label-sm" tone={ctx.dragging ? 'primary' : 'muted'}> ${lead.value.toLocaleString(undefined, latn())} </Text> </div> {/snippet}</Kanban>