Skip to content
omaris

Data Display

Kanban

A board of columns you drag cards between.

import { Kanban } from 'omaris'
Learn

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.

To do

2

Rewrite the onboarding email

Dara

Audit contrast in dark mode

Hana

In progress

1 / 2

Keyboard drag for the board

Mira

Done

1

Carousel caption fade

Ali

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.

Triage

3

Select clears on blur

2

Tooltip flips at the edge

1

Sheet snap points jump

3

In progress

2 / 1

Kanban overlap packing

5

Gauge tick alignment

2

Shipped

Locked 1

Docs demos

8

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.

Backlog

2
bug

Select clears its value on blur

Fri Dara Aziz
buga11y

Tooltip flips at the viewport edge

Mon Hana Karim

In progress

1 / 2
feature

Kanban overlap packing

Thu Mira Salih

In review

1
polish

Gauge tick alignment

Today Ali Hadi

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.

New

$4,200

Karwan Foods

$4,200

In conversation

$11,400

Zagros Cafe

$1,800

Tigris Grill

$9,600

Won

$3,100

Sulay Bakery

$3,100

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, and onmove to save where a card went.
  • Stages with rules. limit turns a column's count amber past its cap and refuses cards from elsewhere while over it. locked refuses every drop and shows a padlock, for a "Done" that is closed.
  • Cards built from the library's own pieces: a Badge per label, an Avatar for the assignee, a due-date Chip, a tone per column, and a Menu at the end of each header through columnActions.
  • A board used from a phone and a keyboard. A finger holds, then drags. Space picks a card up, arrows move it, Escape puts it back, and a live region reads every step out.

Not for

  • Rows people sort, filter and page → Table. A board moves a few dozen things; it does not read a thousand.
  • Items with a status word and no stages to move between → List with a Badge.
  • A grid where the columns mean nothing → a Card grid or Masonry.

Do

  • Give key when items have no id. 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 on columns. The array is rewritten once, on release, so a cancelled drag never reaches the server.
  • Pass label so a keyboard drag announces "Fix login bug" rather than "card".
  • Set height from the prop. It is a property, so height="auto" plus an h-* on classes.root replaces it. Use density="compact" when the columns are long.
  • Handle onadd when addable is on. The row only asks; it does not create.

Don't

  • Use native drag and drop or a draggable attribute inside a card. The board tracks the pointer itself, and a native drag fights it.
  • Mutate columns from a card's own button while a drag is in flight. Wait for onmove.
  • 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.
count
4 or 4 / 5 when 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 bindable
KanbanColumn<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
comfortable
compact
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