Components

Board

A kanban board with drag-and-drop between columns — a deals pipeline surface.

A kanban for a deals pipeline: horizontally scrolling columns (a dot + title + live count header, a "New" button, a stack of cards) with native drag-and-drop between columns. The parent owns the cards, so moving one is a controlled state update — drag a card below and drop it in another column.

Its chrome is built for a deals pipeline, but the columns/cards are generic data you supply — use it as-is for any kanban surface.

Available since@crisp-ui-kit/crisp@0.11.0

Installation

npm install @crisp-ui-kit/crisp

Usage

import { Board, type BoardCard } from "@crisp-ui-kit/crisp";

type Task = BoardCard & { title: string };

const [cards, setCards] = useState<Task[]>([
  { id: "t1", columnId: "todo", title: "Draft the proposal" },
  { id: "t2", columnId: "doing", title: "Design the deck" },
]);

<Board
  columns={[
    { id: "todo", title: "To do", accent: "#8a94a6" },
    { id: "doing", title: "In progress", accent: "#266df0" },
    { id: "done", title: "Done", accent: "#22a565" },
  ]}
  cards={cards}
  onCardsChange={setCards}
  onNewCard={(columnId) => setCards((cs) => [...cs, newTask(columnId)])}
  newCardLabel="New task"
  renderCard={(card) => <span>{card.title}</span>}
/>;

Props

Prop

Type

Keyboard

Native HTML5 drag-and-drop is pointer-only by specification, so the board has its own key model — without it, moving a card would have no keyboard equivalent at all.

KeyAction
TabMove through the cards; each is focusable and announced as a list item.
Ctrl/Cmd + ← / →Move the focused card to the previous or next column.
Ctrl/Cmd + ↑ / ↓With onCardMove, move the focused card up or down within its column.

The modifier is deliberate. A screen reader owns the bare arrow keys in browse mode, and a board scrolls horizontally, so a plain arrow on a focused card would fight both. At the first or last column the key is left unhandled rather than swallowed, so it still reaches the browser.

Each move is announced through a polite live region, and focus follows the card into its new column — React remounts it under the new parent, so without that the focus would be lost to <body> on every move.

BoardColumn is { id: string; title: string; accent?: string } (accent is a CSS colour for the header dot). BoardCard is { id: string; columnId: string } — extend it with your own fields.

Pass calc (#604) for a per-column footer, mirroring the table's own (measured live off a Deals pipeline): each column gets its own "+ Add calculation" trigger, opening a menu of eight aggregations — Count empty, Count filled, Percent empty, Percent filled, Sum, Average, Max, Min — computed over just that column's own cards. getValue reads the number to aggregate off a card (e.g. a deal's value); return null/undefined for a card with nothing set there. Omit calc and there's no footer at all, exactly as before.

Notes

  • Drag-and-drop. Cards are native draggable elements; dropping one on a column reparents it through onCardsChange. The hovered drop column highlights while dragging.
  • Positioned moves. Pass onCardMove and a drop reports { cardId, fromColumnId, toColumnId, beforeCardId }, so a card can be dropped between two others and reordered inside its column, with a line marking the drop position. The keyboard moves (Ctrl/Cmd with the arrow keys) fire it too. A board without onCardMove behaves as before.
  • Opening a card. onCardClick fires on a plain click or Enter on the focused card. It does not fire after a drag, nor for clicks on the card's own buttons and inputs.
  • Controlled. The board never mutates its own cards — it asks the parent to, so a board and a table can be two views over the same list of records.
  • Counts are live. Each column header shows its current card count, derived from cards.
  • The calc footer's aggregation set is its own — measured separately from the table's calc footer (count/filled/sum/avg/min/max in RecordsTable); Board's live menu splits "not empty" into a count and a percent, and does the same for "empty". Not reconciled into one shared vocabulary — two different Attio surfaces, two different measured menus.

On this page