Skip to content

Board

A Kanban / column board where a drag is a field mutation, not a reorder — composing the `sortable` primitive for every drag interaction.

Betav0.4.2added in v0.2.0@garn/ui/board
On this page

Default

The composable path — `Board.Column` children each own their ordered `value`; cross-column moves fire both columns' onValueChange plus one onCardMove, with a hand-placed Board.Overlay.

Overview

Composable

The composable path — `Board.Column` children each own their ordered `value`; cross-column moves fire both columns' onValueChange plus one onCardMove, with a hand-placed Board.Overlay.

Data Driven

The data-driven path — `columns` + `items` + `renderCard`; the board maps the model onto the parts and applies each move atomically. `onCardAdd` turns on the inline composer.

Columns

Wip Limit

WIP limits — a column soft-warns (count amber → red, a danger ring) at and over its `limit`; 'In progress' starts over a limit of 2.

Collapsible

Collapse a column to a rail — the header rotates, keeps its count, and flips its toggle to expand ('Done' starts collapsed).

Cards

Card Handle

A rich card with an explicit grip (`Board.CardHandle`) — only the grip drags, so the card's own menu button stays clickable and semantic.

Grouping

Swimlanes

Swimlanes — horizontal bands crossing every column; cards move across columns AND lanes on one machine, cells keyed by Board.cellKey. Shared column headers sit once on top.

States

Loading

The loading state — skeleton columns while cards stream in.

Empty

An empty column still renders a labelled 'No cards' drop-zone.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add board
First time? Set up garn in your project
Registry
board
Deps
lucide-react
Registry deps
utilsuse-controllable-statesortablecardbuttonskeleton

Import

import { Board.Column, Board.ColumnHeader, Board.Card, Board.CardHandle, Board.CardAdd, Board.Swimlane } from "@garn/ui/board";

Anatomy

The parts this component is built from — compose them to assemble it.

Board.ColumnRequired[data-slot="board-column"]

One column — a droppable Sortable keyed by columnId, owning its ordered value (controlled/uncontrolled), an optional WIP limit, accent tone, and collapsed/collapsible state. Routes its children into header / cards / composer regions.

Board.ColumnHeader[data-slot="board-column-header"]

The column header — title + live count + accent tone, a trailing actions slot (add / overflow), and the collapse toggle. Reads the column context; must render inside a Board.Column.

Board.Card[data-slot="board-card"]

A card — keyed by cardId (its identity in the column's order); disabled pins it (non-draggable). Composes card for the surface and nests a Sortable.Item for the drag/a11y wiring.

Board.CardHandle[data-slot="sortable-handle"]

An optional grip that makes only itself the drag activator, so the card's own buttons/links stay clickable and semantic. Carries the sortable-handle marker so the engine detects it however deeply nested in the card's JSX — but a handle rendered inside one of your own components is invisible to that walk; declare it with hasHandle on Board.Card. Defaults to a grip icon.

Board.CardAdd[data-slot="board-card-add"]

The inline composer — onAdd(text) appends, label names the collapsed trigger, placeholder the textarea. Enter commits and re-focuses (rapid add); Esc cancels.

Board.Swimlane[data-slot="board-swimlane"]

A horizontal grouping band keyed by laneId, with a title and defaultCollapsed; collapsible via an aria-expanded toggle. Exported for hand-composition; the data-driven swimlanes prop composes these.

Props

The component's public props and their types.

autoScroll
Description
Auto-scroll the nearest scroll container when a drag nears its edge.
Type
boolean
Default
true
columns
Description
Column definitions. Passing this switches Board to the data-driven path (it renders and manages the columns for you).
Type
BoardColumnDef[]
defaultItems
Description
Initial card ids per column for the uncontrolled data-driven path.
Type
Record<string, string[]>
getCardLabel
Description
Map a card id to the label screen readers announce during a drag.
Type
(cardId: string) => string
items
Description
Ordered card ids per column (or per `lanecolumn` cell when swimlaned).
Type
Record<string, string[]>
loading
Description
Show skeleton columns instead of content.
Type
boolean
Default
false
onCardAdd
Description
Enable an inline composer per column; receives the target column (+ lane).
Type
(columnId: string, text: string, laneId?: string) => void
onCardMove
Description
Fired once per committed move — the write-back contract (drag = mutation).
Type
(cardId: string, meta: BoardCardMoveMeta) => void
onItemsChange
Description
Fired with the full next `items` map after each committed move (uncontrolled data-driven path).
Type
(items: Record<string, string[]>) => void
renderCard
Description
Render one card's content by id (data-driven).
Type
(cardId: string, ctx: { columnId: string; laneId?: string; }) => React.ReactNode
size
Description
Column width and card density.
Type
BoardSize
Default
"md"
swimlanes
Description
Swimlane definitions — horizontal bands that cross every column (data-driven path).
Type
BoardSwimlaneDef[]
touchDelay
Description
Long-press (ms) before a touch drag begins.
Type
number

Plus 276 inherited native <div> attributes.

Board.Column

collapsed
Description
Collapse the column to a rail (controlled).
Type
boolean
collapsible
Description
Show the collapse affordance.
Type
boolean
Default
true
columnIdRequired
Description
Stable column key — the group value (drag write-back is keyed on it).
Type
string
defaultCollapsed
Description
Start collapsed in the uncontrolled case.
Type
boolean
defaultValue
Description
Initial ordered card ids (uncontrolled).
Type
string[]
laneId
Description
Internal — set by the swimlane renderer so the container id folds (lane, column).
Type
string
limit
Description
WIP limit — soft-warns at/over it.
Type
number
onCollapsedChange
Description
Fired when the column collapses or expands.
Type
(collapsed: boolean) => void
onValueChange
Description
Fired with the next ordered card ids after a reorder or cross-column move.
Type
(value: string[]) => void
tone
Description
Stage color carried by the column header tint.
Type
BoardTone
Default
"neutral"
value
Description
Ordered card ids (controlled).
Type
string[]

Plus 278 inherited native HTML attributes.

Board.ColumnHeader

actions
Description
Trailing slot — an add button, overflow menu, etc.
Type
React.ReactNode

Plus 280 inherited native HTML attributes.

Board.Card

cardIdRequired
Description
The card's stable key — its identity in the column's ordered value.
Type
string
disabled
Description
Make this card non-draggable (a pinned card).
Type
boolean
hasHandle
Description
Declare that a `Board.CardHandle` lives somewhere in this card's rendered output. Handle auto-detection only walks the JSX passed as children — a handle rendered inside one of your own components is invisible to it, which would silently make the whole card the drag activator.
Type
boolean

Plus 280 inherited native HTML attributes.

Board.CardAdd

label
Description
The collapsed trigger label.
Type
string
Default
"Add a card"
onAdd
Description
Called with the trimmed text when a card is added.
Type
(text: string) => void
placeholder
Description
Placeholder for the composer input.
Type
string
Default
"Enter a title…"

Plus 279 inherited native HTML attributes.

Board.Swimlane

defaultCollapsed
Description
Start the lane collapsed.
Type
boolean
Default
false
laneIdRequired
Description
Stable lane key — pairs with a column id to key the swimlane's cells.
Type
string
title
Description
Lane header content; falls back to `laneId`.
Type
React.ReactNode

Plus 279 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="board"][data-slot="board-column"][data-slot="board-column-header"][data-slot="board-count"][data-slot="board-card"][data-slot="board-empty"][data-slot="board-card-add"][data-slot="board-swimlane"][data-slot="sortable-overlay"]

States

SelectorState
[data-slot=board-column][data-collapsed]collapsed
[data-slot=board-column][data-over-limit]over-limit
[data-slot=board-card-add][data-open]composer-open
[data-slot=board-swimlane][data-collapsed]swimlane-collapsed
[data-slot=board-card] [data-dragging]dragging
[data-slot=board-card] [data-placeholder]placeholder
PropertyTokenTier
columnWidth--garn-board-col-wcomponent · identity
columnWidthSm--garn-board-col-w-smcomponent · identity
railWidth--garn-board-rail-wcomponent · identity
headerTint--garn-badge-brand-bgsemantic
overLimitEdge--garn-badge-danger-edgesemantic
surface--garn-mutedsemantic
hairline--garn-bordersemantic

Private instance vars (never externalize): --garn-board-col

When to use

A Kanban / status board where cards move between ordered columns and the move writes back a field: a sprint / project board (To do → In progress → Done), a CRM pipeline, a content workflow, an agent task board. Reach for it with Board.Column children, or drive it from data with columns + items + renderCard. Add WIP limits, swimlanes, collapse-to-rail and an inline composer as needed.

Reach for something else when

  • A single reorderable list with no columns (use sortable or list's reorderable). A row/column data grid or a spreadsheet (use table). A read-only chronological feed (use timeline). Work laid out against a horizontal time axis with dependencies (use gantt) or an hour-grid calendar (use scheduler). The raw drag mechanism with no column/card presentation (use sortable directly).

Overview

Board is a Kanban / column board — records grouped into ordered columns (a status/stage field), where a drag is a mutation: move a card and it fires onCardMove(id, { fromColumn, toColumn, index }). It is the one renderer where reordering means changing data, not just order. Every drag behaviour — pointer, keyboard (Space lifts · arrows move across columns · Esc cancels), touch long-press, edge auto-scroll, the portal overlay ghost and the screen-reader announcements — is delegated to the sortable primitive (a Sortable.Group hoists one drag machine across the per-column Sortables). On top of that engine Board adds what makes it a board: column headers with a live count + accent tone, WIP limits with an over-limit warning, collapse-to-rail, swimlanes, an inline card composer, and empty/loading states — with the structural a11y (a labelled region whose columns are labelled regions with the count in their name) sitting over sortable's drag a11y. Author it composable (Board.Column children, each owning its value) or data-driven (columns + items + renderCard), controlled or uncontrolled. It is the named CollectionView .Board renderer.

Guidelines

Pick a path: composable or data-driven. For a fixed, hand-laid board use Board.Column children — each column owns its ordered value/onValueChange (exactly the sortable recipe it packages). For a model-driven board (or the CollectionView adapter), pass columns + items + renderCard; Board maps the model onto the same parts and applies each move atomically in one onDragEnd.

A drag is a mutation — own the write-back. onCardMove (and, data-driven, onItemsChange) hand you the committed move; apply it to your source of truth. Nothing persists until you do.

Reach for the built-ins. limit for WIP discipline (soft-warn: the count turns amber → red, a danger ring, and the status is announced); collapsed/collapsible for collapse-to-rail; swimlanes (keyed per Board.cellKey(lane, col)) for a second grouping axis; Board.CardAdd / onCardAdd for the inline composer. Style only through tone on the column and the card surface; size via size, never hand-padding.

Keep it operable. Give the board an accessible name, add a Board.CardHandle when a card has its own buttons/links, and always render a Board.Overlay (the data-driven path does this for you) so the dragged card escapes a column's overflow. Don't strip the focus rings or silence the announcements.

Best practices

Do
  • Give the board an accessible name (aria-label / aria-labelledby) — it's a required contract.
  • Own the write-back: apply onCardMove (and data-driven onItemsChange) to your state; a drag doesn't persist until you do.
  • Pick one authoring path — composable Board.Column children (each owns its value) or data-driven columns/items/renderCard.
  • Add a Board.CardHandle when a card has its own buttons/links, so only the grip drags.
  • Use limit for WIP discipline, swimlanes (keyed per Board.cellKey) for a second axis, and Board.CardAdd for low-friction capture.
  • Keep a Board.Overlay in the tree (the data-driven path renders one) so the dragged card isn't clipped by a column's overflow.
Don't
  • Don't reimplement drag on top of Board — pointer, keyboard, touch, auto-scroll, the overlay and announcements all come from sortable.
  • Don't make a card with interactive content the bare drag activator — nest a Board.CardHandle, or its clicks start a drag.
  • Don't hardcode a column colour or a card padding — use tone on the column and size; geometry rides --garn-board-* tokens.
  • Don't expect a drag to persist by itself — Board reports the move; you apply it (drag = mutation, but you own the data).
  • Don't type the swimlane cell key by hand — compose it with Board.cellKey(laneId, columnId) (it folds an internal separator).

Troubleshooting

A card's button / menu isn't clickable, or clicking it starts a drag.

Cause. A bare Board.Card is the drag activator (via the nested Sortable.Item), so interactive card content conflicts with the drag.

Fix. Add a <Board.CardHandle> to the card — only the grip starts a drag, and the rest of the card (buttons, links) stays interactive and semantic.

A card gets clipped when it's dragged out of its column.

Cause. Cross-column drags need a portal ghost; a column's overflow clips the self-translated card.

Fix. Keep a Board.Overlay in the tree. The data-driven path renders one automatically; in the composable path add <Board.Overlay>{(id) => <Card/>}</Board.Overlay> inside <Board>.

Dragging a card does nothing — it snaps back.

Cause. A drag is a mutation, but Board doesn't change your data for you — it reports the move.

Fix. In the composable path apply each column's onValueChange (use functional setState so a cross-column move doesn't stale-overwrite); data-driven, apply onItemsChange (or read onCardMove). Nothing persists until you do.

The board dev-warns about a missing accessible name, or a screen reader can't tell the columns apart.

Cause. The board region has no name, or a column has no header to label it.

Fix. Set aria-label / aria-labelledby on <Board>, and give each Board.Column a Board.ColumnHeader (its count is folded into the column region's name). Headerless swimlane cells get an aria-label instead.

Over-limit doesn't block the drop the way I expected.

Cause. WIP is soft-warn in v1 — the count pill turns red and data-over-limit fires, but the drop is still allowed.

Fix. That's intended (soft-warn). A hard block (strictWip) needs a canDrop/veto hook sortable doesn't yet expose. Enforce the block in your onCardMove if you need it now.

Swimlane cards land in the wrong cell, or items keys don't match.

Cause. The swimlaned items map is keyed per (lane, column) cell with an internal separator, not by column id.

Fix. Key each cell with Board.cellKey(laneId, columnId) (also exported as boardCellKey) — never type the separator yourself. onCardMove meta then carries fromLane/toLane too.

Accessibility

Role
group
ARIA APG
Drag-and-Drop (keyboard-operable reorder)
Focus
Focus follows the card through a keyboard drag (via sortable's roving activators); focus-visible rings are preserved on cards, handles and collapse toggles. Don't strip them.

Accessibility requirements

error

The board root has an accessible name via `aria-label` or `aria-labelledby` (a dev warning fires otherwise).

when always

warn

Each column region's accessible name includes its live card count (and WIP status when a `limit` is set), spoken via an sr-only phrase — the visible count pill stays aria-hidden.

when a column is rendered

warn

Add a `Board.CardHandle` so only the grip drags and the card content stays clickable; a bare card is the drag activator and would swallow the clicks.

when a card carries interactive content (buttons, links, inputs)

warn

The collapse control is a button with aria-expanded and a label naming the column/lane.

when a collapsible column or swimlane is rendered

Keyboard

TabMove into the cards; one card is tabbable per column (sortable's roving activators).
SpaceEnterOn a card (or its Board.CardHandle): pick it up; press again to drop it at the chosen position.
ArrowUpArrowDownMove the lifted card within its column.
ArrowLeftArrowRightMove the lifted card to the previous / next column (RTL-aware).
HomeEndSend the lifted card to the first / last position in its column.
EscapeCancel the drag and restore the card to its original column and position.
A1.3.1Info and RelationshipsA2.1.1KeyboardAA2.4.7Focus VisibleAA2.5.7Dragging MovementsA4.1.2Name, Role, ValueAA4.1.3Status Messages
  • The board is a role=group region that REQUIRES an accessible name (`aria-label` / `aria-labelledby`); a dev-only warning fires if neither is set.
  • Each column is a labelled role=group whose accessible name carries the live count and WIP status — e.g. "To do, 3 cards", "In progress, 3 of 2 cards, over the limit" — composed from the header title + an sr-only phrase (the visible count pill is aria-hidden).
  • Cards inherit sortable's drag a11y: the activator is a real button with an accessible name, aria-roledescription and an aria-describedby pointing at the keyboard instructions; Space/Enter lifts, arrows move (cross-axis hops between columns), Esc cancels; a polite live region announces pick-up / move / drop / cancel. `getCardLabel` names each card for those announcements.
  • Collapse toggles (column and swimlane) are real buttons with aria-expanded and a name ("Collapse/Expand {title}"); a collapsed column's header becomes the rail and keeps the count + expand button.
  • A `Board.CardHandle` makes only the grip the drag activator, so the card body's own buttons/links stay clickable and semantic — required when a card carries interactive content.
  • The empty-column drop-zone is aria-hidden (the labelled column region already announces "0 cards"); swimlane bands are labelled role=group rows.