Skip to content
Data Display

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.

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>

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.

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>

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.

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>

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.

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>