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.

Scroll the board sideways; scroll a column on its own

To do

2

Rewrite the export job

Due Thursday · Ada

Add a webhook retry

Unassigned

In progress

1

Freeze the public API

Due today · Marco

Done

0

Nothing 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.

Classes, generated from the stylesheet source
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.

Design tokens this component reads
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: auto with no tabindex. 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-title is 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-live region saying "Moved to In progress" is the minimum, whether the move came from a drag or a menu.
  • .drag-handle appears 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.

The first column is the rightmost one

قيد الانتظار

١

إعادة كتابة مهمة التصدير

قيد التنفيذ

٠

لا شيء هنا

<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 .list grouped 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.