Skip to content

Kanban

Organize cards into columns with drag and keyboard controls, work-in-progress limits and swimlanes.

import { Kanban, KanbanColumn, KanbanLane } from '@youj/react'

Installation & usage

npm i @youj/react

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Use a kanban board when work moves through stages and people move it themselves: issues through a sprint, deals through a pipeline, content through review. The board owns the card's surface, focus and dragging; you render what's inside each card. For a read-only list of stages, a Stepper or a Timeline says it with less. For items people sort and filter rather than drag, use a data table.

Cards are one flat list, and each names its column. A card's position is its order among the cards of its column. onMove hands you the move and the whole next list.

import { useState } from 'react'
import { Kanban, type KanbanCardData, type KanbanColumn } from '@youj/react'

const columns: KanbanColumn[] = [
  { id: 'todo', title: 'To do' },
  { id: 'doing', title: 'In progress', limit: 3 },
  { id: 'done', title: 'Done', tone: 'success' },
]

export function Board({ initial }: { initial: KanbanCardData[] }) {
  const [cards, setCards] = useState(initial)
  return (
    <Kanban
      aria-label="Sprint 24"
      columns={columns}
      cards={cards}
      renderCard={(card) => <p className="px-(--card-spacing) font-medium">{card.title}</p>}
      onMove={(_, next) => setCards(next)}
    />
  )
}

Each card needs an id, a column and a title. The title is what the board announces, so extend KanbanCardData with whatever else your cards show. Inside renderCard, pad content with px-(--card-spacing) so it lines up with the card's own padding.

Column width and the space beside a sidebar

A column is 18rem wide and never narrower; the board scrolls sideways when its columns don't fit. fill lets the columns grow to share any width left over, but it never shrinks them, so five columns need about 92rem (18rem each plus the gaps), and beside a 16rem sidebar the board scrolls below about 110rem of window. That is deliberate: a card narrower than 18rem wraps its title and crowds its badges. To fit more on screen, fold the columns people rarely drag into (Backlog, Done) to 44px rails with defaultCollapsed, or split the board with lanes. On a phone, snap makes the sideways scroll stop at each column.

<Kanban fill snap defaultCollapsed={['backlog', 'done']} /* … */ />

Reference

Propscolumns, cards, renderCard… (15)

Props

Kanban props
PropType
columnsRequiredKanbanColumn[]–
cardsRequiredC[]–
lanesKanbanLane[]

Swimlanes: each card's lane names one.

–
hideEmptyLanesboolean

Hides a lane while it has no cards (the lane a card is leaving stays until the move lands).

–
renderCardRequired(card: C, state: { lifted: boolean; selected: boolean }) => ReactNode

The inside of a card. The Card itself (surface, focus, drag, the selected look) is the board's.

–
onMove(move: KanbanMove<C>, next: C[]) => unknown

A card moved. Return a promise to have the board roll the move back if it rejects.

–
onAddCard(place: { column: string; lane?: string }, title: string) => void

Enables the add-card composer at the foot of each column.

–
onCardAction(card: C, event: ReactMouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void

Enter, or a click, on a card. The event carries its modifier keys (Shift, ⌘/Ctrl), for selecting several.

–
selectedIdsReadonlyArray<string>

The cards the app has selected: the board draws them selected and tells assistive tech. Selection is the app's (see onCardAction).

–
cardMenu(card: C) => ReactNode

Context-menu items for a card.

–
collapsedstring[]–
defaultCollapsedstring[]

default []

[]
onCollapsedChange(ids: string[]) => void–
fillboolean

Columns grow to share the board's width, each at least its usual 18rem. A collapsed one still folds to a rail.

–
snapboolean

Horizontal scrolling snaps a column to the board's start edge: for phones, where one column fills the view. Off while a card is dragged.

–
Other props pass through: extends ComponentProps<'div'>.

A column (KanbanColumn) has an id, a title, and optionally a WIP limit and a tone for the dot beside its title. A lane (KanbanLane) has an id, a title (a string or a node) and a label, its name in words when the title isn't a string.

renderCard receives the card and its state: lifted (picked up or dragged) and selected (in selectedIds).

AttributeOnMeans
data-lifteda cardpicked up from the keyboard
data-selecteda cardin selectedIds
data-draggingthe boarda card is being dragged
data-fill, data-yj-snapthe board, its scrollerfill, snap
MotionLift, cards make room, drop… · reduced motion

Motion

Motion tokens
MomentTokens
Liftthe preview scales to 1.04 and tilts 2.5°, with a deeper shadow
spring.snappy

0.2s, bounce 0

0.2s, bounce 0
Cards make roomthe cards around the gap glide aside (layout)
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
Dropthe preview settles into the gap, level and at rest size
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
Keyboard movethe lifted card glides from its old slot to the new one
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
Keyboard liftthe focused card scales to 1.02 and takes the ring color
duration.fastease.standard

140ms · cubic-bezier(0.2, 0, 0, 1)

140mscubic-bezier(0.2, 0, 0, 1)
Column foldswidth, to a rail and back
duration.baseease.standard

220ms · cubic-bezier(0.2, 0, 0, 1)

220mscubic-bezier(0.2, 0, 0, 1)

Reduced motion

No lift, tilt or scale, and no gliding: a moved card appears in its new place and the others step aside at once.

Reduced motion

Cards don't lift or tilt. A dragged card still follows the pointer, but the gap and the cards around it change at once, and on release, or from the keyboard, the card appears in its new place. The announcements are the same.

AccessibilityKeyboard, ARIA, focus

Accessibility

  • The board is a group with aria-roledescription="board", named by aria-label. Columns and lanes are named regions, and each column is a list.
  • Cards are buttons with aria-roledescription="draggable card", described by instructions on how to move them. A lifted card is aria-pressed. A selected card says so at the end of its name ("Fix login, selected"); the app's own count of the selection belongs in a live region, as the Selection example's is.
  • Every pick-up, move, drop, cancel and rollback is announced politely, with the column and the position.
  • Column counts are named, including the limit when there is one, and the collapse buttons say which column they fold.
KeyDoes
TabMoves to the board's cards, as one stop.
← → ↑ ↓Walk the cards. With a card lifted, move it. Left and right follow the reading direction.
Home / EndThe first or last card in the column.
SpacePicks the card up, or drops it.
EnterDrops a lifted card, or runs onCardAction on the focused one (with Shift, ⌘ or Ctrl in its event, to add to a selection).
EscCancels the move and puts the card back.
Shift+F10Opens the card's menu, when there's a cardMenu.