Skip to contentVibraUI
Data display

Kanban board

Columns of cards a reader moves by pointer or by keyboard, with WIP limits and a live region that says every move.

The rows stay yours. The board renders each card where its own column says and reports every move through onMove(cardId, column, index) — it never reorders anything itself, so the order lives in one reducer of your own rather than in two places that can disagree. index is the position in the destination column after the card has been taken out of wherever it was, so splicing it in at that index is the whole move. The pointer half is window-level: the press is captured on the card, the move and the release are read off window, so a drag that leaves the board — or leaves the window — still ends, and where it lands is measured rather than guessed (the column under the pointer, then the slot among the cards whose midpoints it has passed). A press that never travels five pixels is a click, not a drag, and a control inside a card — a link, a button, anything marked data-no-drag — keeps its own press, which is how a card can be both the drag surface and hold an "open" button. The keyboard half needs no pointer at all: left and right move a focused card between columns, up and down reorder it inside one, space picks it up and puts it down, escape puts it back where it started; focus follows the card to wherever it lands. Both halves announce through one polite role="status" region, which names the card by the text you rendered in it unless cardLabel says otherwise. A column over its limit renders data-over-limit, wears a warning count, and the move that put it there says so out loud. Columns scroll sideways inside the board, so a five-column board never makes the page scroll.

Install

npx shadcn@latest add @vibra/kanban-board

Needs the @vibra registry in your components.json — set it up once.

Examples

Without a pointer

Every move on the board made from the keyboard alone.

Props

PropTypeDefaultDescription
columns{ id: string; title: string; limit?: number; meta?: React.ReactNode }[]—The columns, in order. title is what the live region says; meta is a second line under it — a total, a share of the pipeline.
cards(T & { id: string; column: string })[]—Your rows, each carrying the column it belongs to. Order within a column is the array's.
renderCard(card: T & { id: string; column: string }) => React.ReactNode—What a card shows. The board draws the shell, the lift and the focus ring around it.
onMove(cardId: string, column: string, index: number) => void—A card has been put somewhere. Splice it into that column at that index; a move that would leave it exactly where it is is never reported.
cardLabel(card) => stringthe card's own textHow the live region names a card, when the rendered text is longer than a sentence.
size"sm" | "default""default"Card padding and type scale.
emptyMessageReact.ReactNode"Nothing here"What an empty column says, so it reads as empty rather than as missing.
headingLevel2 | 3 | 43The level the column titles are headings at. A board under a card's h2 keeps the default; a page whose h1 sits straight over the board passes 2, or its outline skips a level.

Dependencies

Source

components/ui/kanban-board/index.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { KanbanCardShell } from "./card"
import { KanbanColumnShell } from "./column"
import { DRAG_THRESHOLD, cardText, dropTargetAt, type DropTarget } from "./geometry"

export type KanbanColumnSpec = {
  id: string
  title: string
  /** The WIP limit. A column holding more than this is marked and announced. */
  limit?: number
  /** A second line under the title — the sum of what is in the column, a share. */
  meta?: React.ReactNode
}

/** A row of yours, plus the two fields the board needs to place it. */
export type KanbanCard<T> = T & { id: string; column: string }

export type KanbanBoardProps<T> = React.ComponentProps<"div"> & {
  columns: KanbanColumnSpec[]
  cards: KanbanCard<T>[]
  renderCard: (card: KanbanCard<T>) => React.ReactNode
  /**
   * A card has been put somewhere. `index` is its position in the destination
   * column *after* it has been taken out of wherever it was, so splicing it in
   * at that index is the whole move.
   */
  onMove: (cardId: string, column: string, index: number) => void
  /** How the live region names a card; by default, the text you rendered in it. */
  cardLabel?: (card: KanbanCard<T>) => string
  size?: "sm" | "default"
  /** What an empty column says. */
  emptyMessage?: React.ReactNode
  /** The level the column titles are headings at: 2 for a board straight under a page's h1. */
  headingLevel?: 2 | 3 | 4
}

const ARROWS: Record<string, "up" | "down" | "left" | "right"> = {
  ArrowUp: "up",
  ArrowDown: "down",
  ArrowLeft: "left",
  ArrowRight: "right",
}

/**
 * Columns of cards a reader can move by pointer or by keyboard.
 *
 * The rows are yours: the board renders `cards` where their `column` says, and
 * reports every move through `onMove` — it never reorders anything itself, so
 * one state update in your own reducer is the only place the order lives.
 *
 * The pointer half is window-level: the press is captured on the card, the
 * move and the release are read off `window` — so a drag that leaves the board
 * still ends — and where it lands is measured rather than guessed (see
 * `geometry.ts`). The keyboard half needs no pointer at all: arrows move a
 * focused card between columns and within one, Space picks it up and puts it
 * down, Escape puts it back. Both halves say what happened in one live region.
 */
function KanbanBoard<T>({
  className,
  columns,
  cards,
  renderCard,
  onMove,
  cardLabel,
  size = "default",
  emptyMessage,
  headingLevel,
  "aria-label": ariaLabel = "Board",
  ...props
}: KanbanBoardProps<T>) {
  const hintId = React.useId()
  const boardNode = React.useRef<HTMLDivElement | null>(null)
  const columnNodes = React.useRef(new Map<string, HTMLElement | null>())
  const cardNodes = React.useRef(new Map<string, HTMLElement | null>())
  const pointer = React.useRef<
    { id: string; pointerId: number; x: number; y: number; active: boolean } | null
  >(null)
  /** Which card to put focus back on once the move has re-rendered the board. */
  const pendingFocus = React.useRef<string | null>(null)

  const [dragging, setDragging] = React.useState<string | null>(null)
  const [drop, setDrop] = React.useState<DropTarget | null>(null)
  /** Where a keyboard-held card started, so Escape can put it back. */
  const [held, setHeld] = React.useState<{ id: string; column: string; index: number } | null>(null)
  const [announcement, setAnnouncement] = React.useState("")

  // Everything the window listeners need, in a ref: they are registered once,
  // and a stale `cards` array in a closure would drop a card in the wrong slot.
  // Written in an effect rather than during render, and read only from an
  // event handler — which always runs after the effect that filled it in.
  const latest = React.useRef({ columns, cards, onMove, cardLabel })
  React.useEffect(() => {
    latest.current = { columns, cards, onMove, cardLabel }
  })

  React.useEffect(() => {
    const id = pendingFocus.current
    if (!id) return
    pendingFocus.current = null
    cardNodes.current.get(id)?.focus()
  })

  const labelOf = React.useCallback((id: string): string => {
    const { cards: rows, cardLabel: naming } = latest.current
    const card = rows.find((row) => row.id === id)
    if (card && naming) return naming(card)
    return cardText(cardNodes.current.get(id))
  }, [])

  /**
   * Moves a card and says so. A move that would put the card back exactly
   * where it already is is not a move, and neither reorders nor announces.
   */
  const commit = React.useCallback(
    (id: string, column: string, index: number) => {
      const { columns: specs, cards: rows, onMove: move } = latest.current
      const card = rows.find((row) => row.id === id)
      const spec = specs.find((entry) => entry.id === column)
      if (!card || !spec) return

      const here = rows.filter((row) => row.column === card.column).findIndex((row) => row.id === id)
      if (card.column === column && here === index) return

      const label = labelOf(id)
      const total = rows.filter((row) => row.column === column && row.id !== id).length + 1
      const over = spec.limit !== undefined && total > spec.limit

      pendingFocus.current = id
      move(id, column, index)
      setAnnouncement(
        `${label} moved to ${spec.title}, position ${index + 1} of ${total}.` +
          (over ? ` ${spec.title} is over its limit of ${spec.limit}.` : "")
      )
    },
    [labelOf]
  )

  /** Which column and which slot the pointer is over, measured off the DOM. */
  const targetAt = React.useCallback((x: number, y: number, dragId: string): DropTarget | null => {
    const { columns: specs, cards: rows } = latest.current
    return dropTargetAt({
      columns: specs.map((spec) => spec.id),
      cards: rows,
      columnNodes: columnNodes.current,
      cardNodes: cardNodes.current,
      board: boardNode.current,
      dragId,
      x,
      y,
    })
  }, [])

  React.useEffect(() => {
    function handleMove(event: PointerEvent) {
      const drag = pointer.current
      if (!drag || event.pointerId !== drag.pointerId) return
      if (!drag.active) {
        const travelled = Math.abs(event.clientX - drag.x) + Math.abs(event.clientY - drag.y)
        if (travelled < DRAG_THRESHOLD) return
        drag.active = true
        setDragging(drag.id)
      }
      // Stops the page from selecting text under the card as it is dragged.
      event.preventDefault()
      setDrop(targetAt(event.clientX, event.clientY, drag.id))
    }

    function handleUp(event: PointerEvent) {
      const drag = pointer.current
      if (!drag || event.pointerId !== drag.pointerId) return
      pointer.current = null
      setDragging(null)
      setDrop(null)
      if (!drag.active) return
      const target = targetAt(event.clientX, event.clientY, drag.id)
      // Outside the board entirely (the header, a sidebar): the card stays
      // exactly where it was, and the live region says the drop did not happen
      // rather than leaving a reader who heard the pick-up wondering.
      if (target) commit(drag.id, target.column, target.index)
      else setAnnouncement("Move cancelled.")
    }

    window.addEventListener("pointermove", handleMove)
    window.addEventListener("pointerup", handleUp)
    window.addEventListener("pointercancel", handleUp)
    return () => {
      window.removeEventListener("pointermove", handleMove)
      window.removeEventListener("pointerup", handleUp)
      window.removeEventListener("pointercancel", handleUp)
    }
  }, [commit, targetAt])

  function handlePointerDown(event: React.PointerEvent<HTMLLIElement>, id: string) {
    if (event.button !== 0) return
    // A control the card renders keeps its own press: a link, a menu button,
    // a checkbox on the card is not a handle for the card.
    if ((event.target as HTMLElement).closest("a,button,input,select,textarea,[data-no-drag]")) return
    pointer.current = { id, pointerId: event.pointerId, x: event.clientX, y: event.clientY, active: false }
    // The card keeps receiving the pointer even once it is dragged out from
    // under it; the events still bubble to the window listeners above.
    event.currentTarget.setPointerCapture?.(event.pointerId)
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLLIElement>, card: KanbanCard<T>) {
    if (event.key === " " || event.key === "Spacebar") {
      event.preventDefault()
      if (held?.id === card.id) {
        const spec = columns.find((entry) => entry.id === card.column)
        setHeld(null)
        setAnnouncement(`${labelOf(card.id)} dropped in ${spec?.title ?? card.column}.`)
        return
      }
      const index = cards.filter((row) => row.column === card.column).findIndex((row) => row.id === card.id)
      setHeld({ id: card.id, column: card.column, index })
      setAnnouncement(`${labelOf(card.id)} picked up. Use the arrow keys to move it, space to drop it.`)
      return
    }

    if (event.key === "Escape") {
      if (held?.id !== card.id) return
      event.preventDefault()
      commit(card.id, held.column, held.index)
      setHeld(null)
      setAnnouncement("Move cancelled.")
      return
    }

    const direction = ARROWS[event.key]
    if (!direction) return

    const members = cards.filter((row) => row.column === card.column)
    const index = members.findIndex((row) => row.id === card.id)

    if (direction === "up" || direction === "down") {
      const next = index + (direction === "down" ? 1 : -1)
      if (next < 0 || next >= members.length) return
      event.preventDefault()
      commit(card.id, card.column, next)
      return
    }

    const at = columns.findIndex((entry) => entry.id === card.column)
    const next = at + (direction === "right" ? 1 : -1)
    if (next < 0 || next >= columns.length) return
    event.preventDefault()
    const destination = columns[next]
    const occupants = cards.filter((row) => row.column === destination.id).length
    // The same row of the destination where that column has one, its end
    // otherwise — a card never lands in a gap the column does not have.
    commit(card.id, destination.id, Math.min(index, occupants))
  }

  return (
    <div
      ref={boardNode}
      data-slot="kanban-board"
      data-size={size}
      role="group"
      aria-label={ariaLabel}
      className={cn("group/kanban-board flex w-full min-w-0 flex-col gap-2", className)}
      {...props}
    >
      <div
        data-slot="kanban-board-columns"
        className="flex items-start gap-3 overflow-x-auto pb-1 [scrollbar-width:thin]"
      >
        {columns.map((spec) => {
          const members = cards.filter((card) => card.column === spec.id)
          return (
            <KanbanColumnShell
              key={spec.id}
              ref={(node: HTMLDivElement | null) => {
                columnNodes.current.set(spec.id, node)
              }}
              data-column-id={spec.id}
              title={spec.title}
              count={members.length}
              limit={spec.limit}
              meta={spec.meta}
              dropTarget={drop?.column === spec.id}
              emptyMessage={emptyMessage}
              headingLevel={headingLevel}
            >
              {members.map((card) => (
                <KanbanCardShell
                  key={card.id}
                  ref={(node: HTMLLIElement | null) => {
                    cardNodes.current.set(card.id, node)
                  }}
                  data-card-id={card.id}
                  dragging={dragging === card.id}
                  grabbed={held?.id === card.id}
                  aria-describedby={hintId}
                  onPointerDown={(event) => handlePointerDown(event, card.id)}
                  onKeyDown={(event) => handleKeyDown(event, card)}
                >
                  {renderCard(card)}
                </KanbanCardShell>
              ))}
            </KanbanColumnShell>
          )
        })}
      </div>

      <p id={hintId} className="sr-only">
        Arrow keys move this card; space picks it up and drops it; escape puts it back.
      </p>
      <div data-slot="kanban-board-status" role="status" aria-live="polite" className="sr-only">
        {announcement}
      </div>
    </div>
  )
}

export { KanbanBoard, KanbanCardShell, KanbanColumnShell }
components/ui/kanban-board/card.tsx
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"

import { cn } from "@/lib/utils"

export type KanbanCardShellProps = React.ComponentProps<"li"> & {
  /** Under the pointer right now: lifted, and faded where it came from. */
  dragging?: boolean
  /** Held by the keyboard: the reader has picked it up and not put it down. */
  grabbed?: boolean
}

/**
 * One card on the board. It is the drag surface as well as the card, so a
 * reader grabs it anywhere rather than hunting for a handle — the grip in the
 * corner says so without being the only place that works. `touch-none` is what
 * lets a finger drag it: without it the browser scrolls the column instead.
 */
function KanbanCardShell({
  className,
  dragging = false,
  grabbed = false,
  children,
  ...props
}: KanbanCardShellProps) {
  return (
    <li
      data-slot="kanban-card"
      data-dragging={dragging || undefined}
      data-grabbed={grabbed || undefined}
      tabIndex={0}
      aria-roledescription="Draggable card"
      className={cn(
        "group/kanban-card relative cursor-grab touch-none rounded-lg bg-card p-3 text-sm shadow-(--shadow-sheet) ring-1 ring-border select-none",
        "transition-[box-shadow,opacity] duration-(--duration-fast) ease-(--ease-standard) focus-ring",
        "group-data-[size=sm]/kanban-board:p-2.5 group-data-[size=sm]/kanban-board:text-xs",
        // Lifted while it is held, either way it was picked up.
        "data-dragging:cursor-grabbing data-dragging:opacity-60 data-dragging:shadow-(--shadow-float)",
        "data-grabbed:bg-brand-muted data-grabbed:shadow-(--shadow-float)",
        className
      )}
      {...props}
    >
      <GripVerticalIcon
        aria-hidden="true"
        className="pointer-events-none absolute top-2.5 end-1 size-3.5 text-faint-foreground opacity-0 transition-opacity duration-(--duration-fast) group-hover/kanban-card:opacity-100 group-focus-visible/kanban-card:opacity-100"
      />
      <div data-slot="kanban-card-body" className="min-w-0 pe-3">
        {children}
      </div>
    </li>
  )
}

export { KanbanCardShell }
components/ui/kanban-board/column.tsx
import * as React from "react"

import { cn } from "@/lib/utils"

export type KanbanColumnShellProps = React.ComponentProps<"div"> & {
  title: React.ReactNode
  count: number
  /** The WIP limit, when the column has one. */
  limit?: number
  /** A second line under the title — a total, a share, a sum of what is in it. */
  meta?: React.ReactNode
  /** The card being dragged would land here if the pointer were released now. */
  dropTarget?: boolean
  /** What an empty column says instead of showing nothing at all. */
  emptyMessage?: React.ReactNode
  /**
   * The heading level the title is set at. A board under a card's h2 keeps
   * the default; a page whose h1 sits directly over the board passes 2, or
   * its outline skips from h1 to h3.
   */
  headingLevel?: 2 | 3 | 4
}

/**
 * One column: a heading with its count, then the cards. `role="group"` rather
 * than a landmark — five regions on one board is five landmarks a screen
 * reader has to walk past, and a column is a grouping, not a section of the
 * page.
 */
function KanbanColumnShell({
  className,
  title,
  count,
  limit,
  meta,
  dropTarget = false,
  emptyMessage = "Nothing here",
  headingLevel = 3,
  children,
  ...props
}: KanbanColumnShellProps) {
  const headingId = React.useId()
  const over = limit !== undefined && count > limit
  const Heading = `h${headingLevel}` as const

  return (
    <div
      data-slot="kanban-column"
      data-over-limit={over || undefined}
      data-drop-target={dropTarget || undefined}
      role="group"
      aria-labelledby={headingId}
      className={cn(
        "flex min-w-64 flex-1 shrink-0 flex-col gap-2 rounded-xl bg-secondary/60 p-2",
        "transition-colors duration-(--duration-fast) ease-(--ease-standard)",
        "data-drop-target:bg-brand-muted",
        className
      )}
      {...props}
    >
      {/* `relative` because of the visually-hidden note below: sr-only is
          `position: absolute`, and without a containing block inside this
          column it would resolve against the page, escape the horizontal
          scroller and stretch the document sideways at 390. */}
      <div
        data-slot="kanban-column-header"
        className="relative flex items-start justify-between gap-2 px-1 pt-0.5"
      >
        <div className="min-w-0">
          <Heading id={headingId} data-slot="kanban-column-title" className="type-label truncate">
            {title}
          </Heading>
          {meta ? (
            <p data-slot="kanban-column-meta" className="type-eyebrow truncate text-faint-foreground">
              {meta}
            </p>
          ) : null}
        </div>
        <span
          data-slot="kanban-column-count"
          className={cn(
            "type-eyebrow shrink-0 rounded-full px-1.5 py-0.5 tabular-nums",
            // The tone's own ink on its tint, as StatusBadge pairs them;
            // -foreground is for a solid fill and is unreadable here.
            over ? "bg-warning-muted text-warning" : "text-faint-foreground"
          )}
        >
          {limit === undefined ? count : `${count} / ${limit}`}
        </span>
        {over ? <span className="sr-only">Over the limit of {limit}</span> : null}
      </div>

      <ul data-slot="kanban-column-cards" className="flex min-h-16 flex-col gap-2">
        {children}
      </ul>

      {count === 0 ? (
        <p
          data-slot="kanban-column-empty"
          className="rounded-lg border border-dashed border-input px-3 py-4 text-center text-xs text-muted-foreground"
        >
          {emptyMessage}
        </p>
      ) : null}
    </div>
  )
}

export { KanbanColumnShell }
components/ui/kanban-board/geometry.ts
/**
 * Where a drag would land, measured off the DOM.
 *
 * A board is a picture, and a drop is a question about that picture: which
 * column is the pointer over, and how many cards has it passed. Both answers
 * come from `getBoundingClientRect`, so the board never has to model its own
 * layout — which is what lets a column wrap, scroll, or change width without
 * any of this knowing.
 */

/** How far the pointer has to travel before a press becomes a drag. */
export const DRAG_THRESHOLD = 5

/**
 * How far outside the board's own rect a pointer may still be and count as
 * "over" it — a little slack for the last column's own edge and for
 * measurement noise, not enough to reach the header or the sidebar above it.
 */
export const BOARD_TOLERANCE = 24

export type DropTarget = { column: string; index: number }

/** The text a card shows, on one line, short enough to read out. */
export function cardText(node: HTMLElement | null | undefined): string {
  const text = (node?.textContent ?? "").replace(/\s+/g, " ").trim()
  return text.length > 80 ? `${text.slice(0, 79)}…` : text
}

export type DropQuery = {
  /** The column ids, in the order they are drawn. */
  columns: string[]
  /** Every card's column, keyed by card id, in the order they are drawn. */
  cards: { id: string; column: string }[]
  columnNodes: Map<string, HTMLElement | null>
  cardNodes: Map<string, HTMLElement | null>
  /** The board's own rect; a pointer outside it (past BOARD_TOLERANCE) drops nothing. */
  board: HTMLElement | null
  /** The card being dragged; it is not counted among what the pointer passes. */
  dragId: string
  x: number
  y: number
}

/**
 * The column under `x` — or, past either end of the board, the nearest one —
 * and the slot at `y`: before the first card whose midpoint the pointer has
 * not reached, and at the end when it has passed all of them. `null` once the
 * pointer is outside the board itself — the "nearest column" fallback is for
 * a release that overshot the columns, not one over the header or a sidebar.
 */
export function dropTargetAt({
  columns,
  cards,
  columnNodes,
  cardNodes,
  board,
  dragId,
  x,
  y,
}: DropQuery): DropTarget | null {
  if (board) {
    const box = board.getBoundingClientRect()
    const outside =
      x < box.left - BOARD_TOLERANCE ||
      x > box.right + BOARD_TOLERANCE ||
      y < box.top - BOARD_TOLERANCE ||
      y > box.bottom + BOARD_TOLERANCE
    if (outside) return null
  }

  let column: string | null = null
  let nearest = Number.POSITIVE_INFINITY
  for (const id of columns) {
    const node = columnNodes.get(id)
    if (!node) continue
    const box = node.getBoundingClientRect()
    const distance = x < box.left ? box.left - x : x > box.right ? x - box.right : 0
    if (distance < nearest) {
      nearest = distance
      column = id
    }
  }
  if (column === null) return null

  const members = cards.filter((card) => card.column === column && card.id !== dragId)
  let index = members.length
  for (const [position, member] of members.entries()) {
    const node = cardNodes.get(member.id)
    if (!node) continue
    const box = node.getBoundingClientRect()
    if (y < box.top + box.height / 2) {
      index = position
      break
    }
  }
  return { column, index }
}