Kanban
.kanban is a horizontally scrolling row of columns, each a sunken panel
with its own scrolling body. It is twelve rules — the board is mostly two nested
scroll containers, and getting those to behave is the whole component.
When to use it
Use it when items move between a small number of named states and seeing the whole
pipeline at once is the point: a task board, a hiring pipeline, an order queue. If the
states are not meaningful, a .grid or a
.list is easier to scan.
To do
2Add a webhook retry
In progress
1Freeze the public API
Done
0Nothing here yet
<div class="kanban" style="--kanban-height:22rem">
<section class="kanban-col" aria-labelledby="dx-k1">
<div class="kanban-head"><h3 id="dx-k1">To do</h3><span class="kanban-count">2</span></div>
<div class="kanban-body">
<article class="kanban-card">
<h4 class="kanban-card-title">Rewrite the export job</h4>
<div class="kanban-card-meta">Due Thursday · Ada</div>
</article>
<article class="kanban-card">
<h4 class="kanban-card-title">Add a webhook retry</h4>
<div class="kanban-card-meta">Unassigned</div>
</article>
</div>
</section>
<section class="kanban-col" aria-labelledby="dx-k2">
<div class="kanban-head"><h3 id="dx-k2">In progress</h3><span class="kanban-count">1</span></div>
<div class="kanban-body">
<article class="kanban-card">
<h4 class="kanban-card-title">Freeze the public API</h4>
<div class="kanban-card-meta">Due today · Marco</div>
</article>
</div>
</section>
<section class="kanban-col" aria-labelledby="dx-k3">
<div class="kanban-head"><h3 id="dx-k3">Done</h3><span class="kanban-count">0</span></div>
<div class="kanban-body">
<p class="kanban-empty">Nothing here yet</p>
</div>
</section>
</div>
<div class="kanban" style="--kanban-height:22rem">
<section class="kanban-col" aria-labelledby="dx-k1">
<div class="kanban-head"><h3 id="dx-k1">To do</h3><span class="kanban-count">2</span></div>
<div class="kanban-body">
<article class="kanban-card">
<h4 class="kanban-card-title">Rewrite the export job</h4>
<div class="kanban-card-meta">Due Thursday · Ada</div>
</article>
<article class="kanban-card">
<h4 class="kanban-card-title">Add a webhook retry</h4>
<div class="kanban-card-meta">Unassigned</div>
</article>
</div>
</section>
<section class="kanban-col" aria-labelledby="dx-k2">
<div class="kanban-head"><h3 id="dx-k2">In progress</h3><span class="kanban-count">1</span></div>
<div class="kanban-body">
<article class="kanban-card">
<h4 class="kanban-card-title">Freeze the public API</h4>
<div class="kanban-card-meta">Due today · Marco</div>
</article>
</div>
</section>
<section class="kanban-col" aria-labelledby="dx-k3">
<div class="kanban-head"><h3 id="dx-k3">Done</h3><span class="kanban-count">0</span></div>
<div class="kanban-body">
<p class="kanban-empty">Nothing here yet</p>
</div>
</section>
</div>
Two scroll containers
The board scrolls on the inline axis and each column scrolls on the block axis. Both
set overscroll-behavior: contain, which is what stops a scroll that
reaches the end of a column from chaining outward — first to the board, then to the
page.
Columns are flex: 0 0 min(19rem, 82vw): a fixed width so they do not
squash, with a viewport cap so a phone still shows a sliver of the next one. The board
uses scroll-snap-type: x proximity, so it settles on a column when the
reader stops near one and leaves them alone otherwise — the same reasoning as
.scroller.
align-items: flex-start on the board means columns are as tall as their
content up to --kanban-height, rather than all stretching to match the
longest. A board where an empty column is as tall as a full one wastes most of the
screen.
Head, body, card
.kanban-head is pinned outside the scrolling area, so a column's name and
count stay visible while its cards scroll. .kanban-count is the pill
beside the title. .kanban-body is the scrolling region, with a
5rem minimum so an empty column is still a target you can drop onto.
.kanban-empty shows in an empty column and is hidden by
.kanban-col:has(.kanban-card) .kanban-empty as soon as one arrives — a
parent selector rather than a class that something has to remember to toggle.
Dragging
src/26-perf.css styles the drag states this board shares with
lists and the
data grid: a .drag-handle that appears on
hover or focus-within, and the lifted appearance of a card in flight.
deck-adapters.js drives them with SortableJS when it is present, or the
native drag-and-drop API when it is not.
There is no keyboard path. A board that can only be reordered by
dragging cannot be reordered at all by a keyboard-only user, and a kanban board's
entire purpose is moving cards. This is the most serious accessibility gap in Deck —
more so here than in a list, because in a list the order is usually cosmetic and here
it is the data. Recorded in FINDINGS.md.
The mitigation is a move control on each card — a menu with "Move to In progress" — which is also faster than dragging for anyone using a large board.
Classes
Generated from src/26-perf.css by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.kanban |
deck.components |
The obvious thing to build once you have drag and drop. An issue queue by status is the same shape as a board. | 26-perf.css:125 |
.kanban-body |
deck.components |
The scrolling card region, with a 5rem floor so an empty column is still something you can drop onto. | 26-perf.css:176 |
.kanban-card |
deck.components |
One card. .kanban-card-title is styling, not a heading — if the card opens something, make the title a link. | 26-perf.css:188 |
.kanban-card-meta |
deck.components |
Sets display, align-items, gap, font-size, and 1 more. | 26-perf.css:202 |
.kanban-card-title |
deck.components |
Sets font-size, font-weight. | 26-perf.css:201 |
.kanban-col |
deck.components |
One column: a fixed width with a viewport cap so a phone still shows a sliver of the next, capped in height by --kanban-height. | 26-perf.css:136 |
.kanban-count |
deck.components |
Sets margin-inline-start, font-size, font-weight, color, and 5 more. | 26-perf.css:163 |
.kanban-empty |
deck.components |
Shown in an empty column and hidden by :has(.kanban-card) as soon as one arrives, so nothing has to remember to toggle it. | 26-perf.css:211 |
.kanban-head |
deck.components |
The column title and count, outside the scrolling area so they stay visible while cards scroll. | 26-perf.css:148 |
Tokens
--kanban-height caps a column and defaults to 70dvh. It is
local to the component rather than a global token.
| Token | Live | Declared value |
|---|---|---|
--bg-sunken |
light-dark(var(--ink-100), oklch(8.6% .012 var(--hue-neutral))) |
|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--text-sm |
.8125rem |
|
--text-xs |
.75rem |
|
--text-faint |
light-dark(var(--ink-500), var(--ink-500)) |
|
--r-md |
12px |
|
--r-sm |
7px |
|
--shadow-2 |
0 2px 4px oklch(20% .03 var(--shadow-color) / .05), 0 4px 12px oklch(20% .03 var(--shadow-color) / .07) |
|
--space-1 |
.25rem |
|
--space-3 |
.75rem |
|
--space-4 |
1rem |
Accessibility
- Drag-and-drop with no keyboard equivalent is the headline problem. See above. Ship a move control as well; a board without one is unusable without a pointer.
-
Columns should be labelled regions.
<section aria-labelledby>pointing at the column's heading, as the example does — so a screen-reader user can navigate between columns rather than falling through every card. -
The count should be in the heading's accessible name. "To do" and
a separate "2" are announced as two things. Consider
<h3 id>To do <span class="sr-only">— 2 items</span></h3>. -
Both scroll containers need keyboard reach. The board and each
column are
overflow: autowith notabindex. Cards are usually focusable, which drags the container along — but an empty or non-interactive column cannot be scrolled from the keyboard. Same defect as.table-wrap. -
A card needs a real heading or a link.
.kanban-card-titleis styling only. If a card opens something, make the title a link and let the card be the target with the stretched-link pattern. -
Moving a card should be announced. Nothing about the drag reaches a
screen reader. An
aria-liveregion saying "Moved to In progress" is the minimum, whether the move came from a drag or a menu. -
.drag-handleappears on hover or focus-within, which is the right pairing — a handle that only appears on hover is invisible to a keyboard user even before the missing key handling.
Right to left
The board is a flex row that reverses under dir="rtl", columns use
scroll-snap-align: start — the starting edge, not the left one — and
overscroll-behavior-inline is already logical. Nothing needs a rule.
قيد الانتظار
١إعادة كتابة مهمة التصدير
قيد التنفيذ
٠لا شيء هنا
<div dir="rtl" class="kanban" style="--kanban-height:16rem">
<section class="kanban-col" aria-labelledby="dx-kr1">
<div class="kanban-head"><h3 id="dx-kr1">قيد الانتظار</h3><span class="kanban-count">١</span></div>
<div class="kanban-body">
<article class="kanban-card"><h4 class="kanban-card-title">إعادة كتابة مهمة التصدير</h4></article>
</div>
</section>
<section class="kanban-col" aria-labelledby="dx-kr2">
<div class="kanban-head"><h3 id="dx-kr2">قيد التنفيذ</h3><span class="kanban-count">٠</span></div>
<div class="kanban-body"><p class="kanban-empty">لا شيء هنا</p></div>
</section>
</div>
<div dir="rtl" class="kanban" style="--kanban-height:16rem">
<section class="kanban-col" aria-labelledby="dx-kr1">
<div class="kanban-head"><h3 id="dx-kr1">قيد الانتظار</h3><span class="kanban-count">١</span></div>
<div class="kanban-body">
<article class="kanban-card"><h4 class="kanban-card-title">إعادة كتابة مهمة التصدير</h4></article>
</div>
</section>
<section class="kanban-col" aria-labelledby="dx-kr2">
<div class="kanban-head"><h3 id="dx-kr2">قيد التنفيذ</h3><span class="kanban-count">٠</span></div>
<div class="kanban-body"><p class="kanban-empty">لا شيء هنا</p></div>
</section>
</div>
Reduced motion
The only transition is a card's shadow on hover, collapsed to .01ms by
the global reset. Scroll snapping is positional rather than animated and is
unaffected — a reader who wants less motion still wants the columns to line up.
A drag follows the pointer, so it is a response to the reader's own gesture rather than motion they did not ask for.
Printing
No print rule. A printed board shows the columns that fit on the page and clips the
rest, and each column shows only the cards that were scrolled into view — the same
gap as .scroller and
.carousel, and worse here because there
are two nested scrollers.
A board is a working surface rather than a document, so this matters less than the accordion's print gap — but a printed board that silently omits half the work is still misleading.
Overriding it
/* One board, no layer needed */
<div class="kanban" style="--kanban-height:60vh">
@layer app.components {
/* Wider columns */
.kanban-col { flex-basis: min(24rem, 86vw); }
/* Columns that fill the height rather than sizing to content */
.kanban { align-items: stretch; }
/* A per-column accent */
.kanban-col[data-state="blocked"] { border-color: var(--bad-500); }
}
When not to use it
- Not without a non-drag way to move cards. The board's purpose is moving things, and dragging is the one interaction Deck does not make accessible.
- Not for many columns. Six states means six columns and a lot of horizontal scrolling; past four the board stops showing the pipeline at a glance, which was the reason to use one.
- Not for many cards. A column with two hundred cards is a scroll container inside a scroll container. Filter first, or use a data grid with a status column.
-
Not on a phone as the primary view. One column at a time with the
rest off-screen is worse than a filtered list. Consider swapping to a
.listgrouped by status below a breakpoint. - Not when the states are not real. If items do not actually move between the columns, the columns are categories and a grid with headings says so more plainly.