Data Display
Kanban
A board of columns you drag cards between.
import { Kanban } from 'omaris' Examples
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> 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> 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> 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> When to use it
Use it for
- Work that moves through stages by hand: tickets, leads, applications. One column per stage,
bind:columns, andonmoveto save where a card went. - Stages with rules.
limitturns a column's count amber past its cap and refuses cards from elsewhere while over it.lockedrefuses every drop and shows a padlock, for a "Done" that is closed. - Cards built from the library's own pieces: a
Badgeper label, anAvatarfor the assignee, a due-dateChip, atoneper column, and aMenuat the end of each header throughcolumnActions. - A board used from a phone and a keyboard. A finger holds, then drags.
Spacepicks a card up, arrows move it,Escapeputs it back, and a live region reads every step out.
Do
- Give
keywhen items have noid. Identity keeps a card the same element while it slides and is what the keyboard drag holds on to. - Save in
onmove, not in an effect oncolumns. The array is rewritten once, on release, so a cancelled drag never reaches the server. - Pass
labelso a keyboard drag announces "Fix login bug" rather than "card". - Set
heightfrom the prop. It is a property, soheight="auto"plus anh-*onclasses.rootreplaces it. Usedensity="compact"when the columns are long. - Handle
onaddwhenaddableis on. The row only asks; it does not create.
Don't
- Use native drag and drop or a
draggableattribute inside a card. The board tracks the pointer itself, and a native drag fights it. - Mutate
columnsfrom a card's own button while a drag is in flight. Wait foronmove. - Put more than about six columns on a phone. The board scrolls sideways, but dragging toward an off-screen column is dragging toward an edge.
Quick reference
density comfortable(default)compact
API
Kanban
A board of columns you drag cards between.
Three decisions shape this one.
Pointer events, not HTML drag-and-drop. The native API cannot be styled, does not fire on touch at all, and hands you a drag image the browser drew. This tracks the pointer directly, so the same code works with a mouse, a finger and a pen. The gesture's listeners live on the window, never on the card — a card the render can remove is no place to keep a pointer — so a release anywhere, on or off the board, always ends the drag.
The list is never mutated mid-drag. What you see while dragging is a preview — the card dimmed in place, or a gap put where it would land — and columns only changes when you let go. So a drag that is cancelled costs nothing, and the data never passes through a state that was not asked for.
It works from the keyboard. Space picks a card up, the arrows move it between positions and columns, Space drops it and Escape puts it back. A board that can only be used with a mouse is not finished.
Cards slide rather than jump: the preview is keyed by the item's identity and animated with FLIP, so every card that has to move to make room is seen moving, and the ghost flies into the gap on release.
import { Kanban } from 'omaris' <Kanban bind:columns key={(task) => task.id} onmove={save}> {#snippet card(task)}<Text>{task.title}</Text>{/snippet}</Kanban> Parts
Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.
root- The horizontally scrolling board.
column- One column.
header- No description in the source yet.
dot- The tone dot before the title.
title- No description in the source yet.
count4or4 / 5when the column has a limit.over- The count, when the column is over its limit.
lock- The padlock on a locked column.
list- The scrolling stack of cards.
cell- The keyed wrapper FLIP measures — one per card, or the gap.
card- One card.
placeholder- The gap showing where the card would land.
ghost- The card under the pointer. Fixed to the viewport, never in the flow.
empty- Shown in a column with nothing in it.
add- "Add a card", under the stack.
status- Live region announcing what a keyboard drag is doing.
Props
columns required bindableKanbanColumn<T>[] The board. Bindable — a drop rewrites it.
card required Snippet<[T, KanbanCardContext<T>]> How to draw one card.
header Snippet<[KanbanColumn<T>]> Replaces a column's whole header row — dot, title, count and columnActions.
columnActions Snippet<[KanbanColumn<T>]> Rendered at the end of the default header — a menu, usually.
key (item: T, index: number) => string | number Identity, which is what keeps a card the same element while it slides. Defaults to the item's id, then its index.
label (item: T) => string Names a card for assistive tech during a keyboard drag.
density Defaults to 'comfortable'
KanbanDensity comfortablecompact
columnWidth Defaults to 272
number | string Column width. A number is px, a string any CSS length.
height Defaults to '28rem'
number | string Board height. A number is px, a string any CSS length — a property rather than a class, so classes.root can replace it.
emptyText Defaults to 'Nothing here'
string Shown in an empty column.
addable Defaults to false
boolean Show the "add a card" row under each column.
addLabel Defaults to 'Add a card'
string disabled Defaults to false
boolean onmove (move: KanbanMove<T>) => void onadd (columnId: string) => void class string classes KanbanClasses