Board
A Kanban board. Columns go across. Lanes go down. Each card sits in the cell where its column and lane meet. Drag cards to any cell, drag columns by their grip, and click a card to open it.
Click a card to open it.
Usage
import { Board, type BoardColumn, type BoardItem } from "@tulipfarm/sprout/board";
const columns: BoardColumn[] = [
{ id: "todo", title: "To do" },
{ id: "doing", title: "In progress", limit: 3 },
{ id: "done", title: "Done" },
];
const [items, setItems] = useState<BoardItem[]>([
{ id: "1", title: "Frost alert texts", columnId: "todo", laneId: "ana" },
{ id: "2", title: "Seed order import", columnId: "doing", laneId: "ben" },
]);
<Board
columns={columns}
lanes={[{ id: "ana", title: "Ana" }, { id: "ben", title: "Ben" }]}
items={items}
onItemsChange={setItems}
onItemAdd={({ title, columnId, laneId }) => addCard(title, columnId, laneId)}
onItemOpen={(item) => openSheet(item)}
renderBody={(item) => <Badge>{item.priority}</Badge>}
searchable
/>columns:{ id, title, limit? }[]. Overlimit, the header turns amber and says “Over limit”.lanes:{ id, title }[]. Leave it out for a plain board with no lane bars.items:{ id, columnId, laneId?, title }[]plus your own fields. Cards in a cell show in their order initems.onItemsChange: gets the whole new list after a move, a rename, or a column delete. Without it, cards do not move.onColumnsChange: gets the new columns after a reorder, rename, or delete. Without it, columns are fixed.onItemAddandonColumnAdd: show “Add card” in every cell and “Add column” after the last column. Your app makes the id.onItemOpen,renderBody,itemActions: what a click does, the card body under the title, and the items in the card menu.renderTitleandrenderCard: change the title, or draw the whole card. See “Custom cards”.searchable,searchText,laneBy: the toolbar. Search filters by title and any text you add. “Lanes by” is a select; your app regroupslanesandlaneId.
Without lanes
With no lanes, the board is one row and the cells fill its height.
Custom cards
renderTitle changes only the title. The playground above uses it to show a key before each title. Screen readers still readitem.title.
renderCard draws the whole card. The board removes its own frame and gives you parts.title and parts.menu. Put them where you want. They keep rename and the card menu working. The board still owns drag, focus, keys, click, and selection.
<Board
columns={columns}
items={items}
onItemsChange={setItems}
renderCard={(item, parts) => (
<div className="flex gap-3 rounded-lg border p-2">
<EntityAvatar size="sm" name={item.assignee} />
<div className="flex-1">
<div className="flex items-start gap-1">
{parts.title}
{parts.menu}
</div>
<span className="text-xs">{item.points} pt</span>
</div>
</div>
)}
/>Large boards
50 columns, 20 lanes, and 10,000 cards. Only the lanes and cards in view render. The card you drag always stays rendered.
Selecting and moving many cards
Cmd-click or Ctrl-click adds a card to the selection. Shift-click selects a range. Drag one selected card and the rest go with it. Escape clears the selection.
Accessibility
The board is a named region. Each cell is a list named by its column and lane. Tab moves to a card. Space picks it up, arrow keys move it, Space drops it, and Escape puts it back. A screen reader hears each step with the card title and its cell. On touch, press and hold a card to pick it up; a swipe still scrolls.
Enter opens a card. F2 or Rename in the card menu renames it. With noonItemOpen, a double-click renames too. In a title field, Enter saves and Escape cancels. Columns move with their grip button in the same way. The “Over limit” state is in words, not only color.
Theme tokens
Cards use --sprout-card-raised and--sprout-card-raised-shadow. Columns tint--sprout-hover over --sprout-canvas. Over limit uses --sprout-warning-subtle, and selection--sprout-primary. Set --sprout-board-height,--sprout-board-column-width, and--sprout-board-cell-height on the board to size it.