Kanban
Drag-and-drop board primitives for pipelines, with columns, cards, and scroll controls.
Quick Preview
Step 1 of 4
New2
Amara Okoye
Lekki Gardens Phase 2
₦85M
Tunde Bakare
Ikoyi Heights
₦120M
Step 2 of 4
Contacted1
Zainab Bello
Maitama Courts
₦48M
Step 3 of 4
Viewing Booked2
Chidi Nwosu
Banana Island Villa
₦210M
Fatima Sule
Gwarinpa Terraces
₦62M
Step 4 of 4
Offer Made1
Ope Adeyemi
Yaba Loft
₦95M
Usage
import {
applyKanbanCardMove,
KanbanBoard,
KanbanCard,
KanbanColumn,
KanbanColumnBody,
KanbanColumnCount,
KanbanColumnDot,
KanbanColumnHeader,
KanbanColumnTitle,
KanbanDropPlaceholder,
KanbanRoot,
KanbanScrollControls,
type KanbanCardMove,
} from "@giddaa-housing/ui/kanban";
function PipelineBoard({ stages, cardsByStage, onMove }) {
const handleCardMove = (move: KanbanCardMove) => {
const result = applyKanbanCardMove({
move,
sourceIds: cardsByStage[move.fromColumnId],
targetIds: cardsByStage[move.toColumnId],
});
if (!result.changed) return;
onMove(move, result);
};
return (
<KanbanRoot
onCardMove={handleCardMove}
announceMove={(move) => `${cardName(move.cardId)} moved to ${stageName(move.toColumnId)}.`}
>
<KanbanScrollControls />
<KanbanBoard className="h-[32rem]">
{stages.map((stage) => (
<KanbanColumn key={stage.id} columnId={stage.id} className="h-full">
<KanbanColumnHeader>
<KanbanColumnTitle>
<KanbanColumnDot className="bg-status-info" />
<span className="min-w-0 flex-1 truncate">{stage.name}</span>
<KanbanColumnCount>{cardsByStage[stage.id].length}</KanbanColumnCount>
</KanbanColumnTitle>
</KanbanColumnHeader>
<KanbanColumnBody>
<KanbanDropPlaceholder>Drop here to move to {stage.name}</KanbanDropPlaceholder>
{cardsByStage[stage.id].map((card) => (
<KanbanCard key={card.id} cardId={card.id} className="p-3">
{card.name}
</KanbanCard>
))}
</KanbanColumnBody>
</KanbanColumn>
))}
</KanbanBoard>
</KanbanRoot>
);
}The board is headless about data. It never fetches, mutates, or reorders
anything: KanbanRoot reports a completed drag as a KanbanCardMove, and your
screen decides what that move means. Everything else — layout, tokens, the drop
affordances — belongs to the slots.
KanbanBoard scrolls horizontally and each KanbanColumnBody scrolls
vertically, so give the board a height (h-96, h-[calc(100dvh-28rem)]) and
the columns h-full. Without a height the columns grow with their content and
nothing scrolls.
Examples
Default board
Columns are yours to build from the header slots; cards are whatever the screen
renders inside KanbanCard. This board keeps its order in component state —
swap that for a mutation plus an optimistic cache update in a real screen.
Step 1 of 4
New2
Amara Okoye
Lekki Gardens Phase 2
₦85M
Tunde Bakare
Ikoyi Heights
₦120M
Step 2 of 4
Contacted1
Zainab Bello
Maitama Courts
₦48M
Step 3 of 4
Viewing Booked2
Chidi Nwosu
Banana Island Villa
₦210M
Fatima Sule
Gwarinpa Terraces
₦62M
Step 4 of 4
Offer Made1
Ope Adeyemi
Yaba Loft
₦95M
Locked columns and cards
dropDisabled makes a column drag-out only — use it for a bucket the server
cannot move cards back into ("No stage", "Unassigned"). dragDisabled gates a
single card on permission or record state; it stays a valid drop target, it
just cannot be picked up.
Drag out only
Unassigned1
Accepts drops
In Review2
Accepts drops
Approved1
Keyboard moves, announcements, and the flash
Dragging is pointer-only, so a board needs a second route to the same move. Give
each card a menu and drive it with useKanbanFeedback, which reaches the board's
two feedback channels: announce speaks the change into the live region, and
flashCard highlights the card wherever it lands — including a column it has
just remounted into.
A drop already does both: the board flashes the dropped card, and reads whatever
announceMove returns. Only the app knows the names behind the ids, which is why
the sentence comes from there rather than being invented out of ids.
This example also puts dragging behind a KanbanCardDragHandle. Once a card
carries its own controls, a whole-card drag means every press on the menu is a
drag waiting to start.
New2
Contacted1
Offer Made1
const { announce, flashCard } = useKanbanFeedback();
moveLead(lead.id, stage.id);
flashCard(lead.id);
announce(`${lead.name} moved to ${stage.name}.`);Applying a move
applyKanbanCardMove is the pure counterpart to the drag: it turns a
KanbanCardMove into the two columns' new id lists and tells you whether
anything actually changed. An in-column reorder returns the same array for both
lists.
A card released over its own column but not over any card comes back
changed: false — that drop showed no insertion point, so moving the card would
be the board choosing for the user. A drop on another column lands at the top,
where the placeholder was.
const result = applyKanbanCardMove({
move,
sourceIds: source.cards.map((card) => card.id),
targetIds: target.cards.map((card) => card.id),
});
if (!result.changed) return;
// A reorder inside one column is position only — there is nothing to persist
// server-side beyond the remembered order.
if (move.fromColumnId === move.toColumnId) {
order.saveColumns({ [move.toColumnId]: result.targetIds });
return;
}
setStageOptimistically(move.cardId, move.toColumnId);
changeStage.mutate({ id: move.cardId, stageId: move.toColumnId });Remembering where cards sit
Column membership belongs to the server; where a card sits inside a column
usually has no home there. useKanbanCardOrder keeps that per-board arrangement
in this browser's localStorage, so the board comes back the way the user left
it. Untouched columns keep pure API order, and cards the saved order has never
seen sort after the arranged ones.
const order = useKanbanCardOrder("lead-pipeline-order");
const cards = order
.sortColumn(stage.id, apiCardIds)
.map((id) => cardsById[id]);
// …in onCardMove, after applyKanbanCardMove
order.saveColumns({
[move.fromColumnId]: result.sourceIds,
[move.toColumnId]: result.targetIds,
});Storage is read after mount, so the first paint shows API order — a board that rendered saved order on the server would be a hydration mismatch.
Scroll controls
KanbanScrollControls steps the board one column at a time and disables itself
at each end. Keep the row mounted even when every column fits: the buttons
disable, but a control row that appears and disappears with the window width
reads as a bug. useKanbanScroll backs a custom control or a "showing N of M"
caption.
function BoardScrollBar() {
const { columnCount, visibleColumnCount, canScrollRight } = useKanbanScroll();
if (columnCount === 0) return null;
return (
<div className="flex items-center gap-4">
<p className="min-w-0 flex-1 truncate text-fg-secondary text-gdt-xs">
Showing {visibleColumnCount} of {columnCount} stages
{canScrollRight ? " — scroll right for more" : null}
</p>
<KanbanScrollControls />
</div>
);
}Props
| Prop/API | Type | Default | Description |
|---|---|---|---|
KanbanRoot | div props plus onCardMove?: (move: KanbanCardMove) => void, announceMove?: (move: KanbanCardMove) => string | null | - | Owns drag, scroll, and feedback state for one board, and renders its live region. Without onCardMove the board drags but nothing lands; without announceMove a drop is silent. Two roots on a page cannot cross-drop. |
KanbanBoard | div props | - | Horizontally scrolling column strip with drag auto-scroll. Give it a height. |
KanbanColumn | section props plus columnId: string, dropDisabled?: boolean | dropDisabled: false | One column, and a drop target for cards. dropDisabled makes it drag-out only. Marks itself data-drop-target while a drag is over it. |
KanbanColumnHeader / KanbanColumnTitle / KanbanColumnDot / KanbanColumnCount / KanbanColumnDescription | element props | - | Header slots: stack, heading row, status dot, count pill, and caption. |
KanbanColumnBody | div props | - | Vertically scrolling card list with drag auto-scroll. |
KanbanDropPlaceholder | div props | - | "Drop here" block, rendered only while a card from another column is over this one. |
KanbanCard | div props plus cardId: string, dragDisabled?: boolean | dragDisabled: false | A draggable card and a drop target. Marks itself data-dragging while in flight, data-just-moved for the post-move flash, and draws the insertion line for the closest edge. |
KanbanCardDragHandle | button props | grip icon | Restricts dragging to this element. Decorative and out of the tab order — the keyboard route is the card's own menu. |
KanbanScrollControls / KanbanScrollButton | div props / Button props plus direction: "left" | "right" | - | Step the board one column sideways; disabled at that end. |
applyKanbanCardMove | ({ move, sourceIds, targetIds }) => { changed, sourceIds, targetIds } | - | Pure. Turns a reported move into the two columns' new id lists. |
sortIdsBySavedOrder | (ids, savedOrder?) => string[] | - | Pure. Saved ids first in saved order, unknown ids after them in API order. |
useKanbanCardOrder | (storageKey: string) => { sortColumn, saveColumns } | - | Per-board card order remembered in localStorage. |
useKanbanScroll | () => { canScrollLeft, canScrollRight, columnCount, visibleColumnCount, scroll } | - | Board scroll state, for a custom control or caption. Both canScroll* false means nothing to scroll. |
useKanbanDrag | () => KanbanDragState | null | - | The in-flight drag (cardId, fromColumnId, overColumnId), for app-level styling. Null when nothing is being dragged. |
useKanbanFeedback | () => { announce, flashCard } | - | The board's live region and card flash, for moves made outside a drag. |
Accessibility
- Dragging is a pointer-only affordance. Every board needs a keyboard-reachable way to make the same move — a stage select on the card, a "move to" menu, or a dialog. Ship that alongside the board, not after it; the keyboard moves example is the shape it usually takes.
KanbanRootrenders a polite live region. Return a sentence fromannounceMoveso a drop is spoken, and callannouncefromuseKanbanFeedbackfor moves made through the menu. Repeated identical messages are announced again rather than swallowed.- A moved card flashes a focus-coloured ring for 700ms wherever it lands, which
is what tells anyone — not only screen-reader users — where the card went. It
is a colour fade rather than motion, so
prefers-reduced-motionleaves it on. KanbanCardDragHandleisaria-hiddenand out of the tab order on purpose: it does nothing on click, and a focusable control that only works with a mouse is a trap, not an affordance.- Scroll buttons carry
aria-label="Scroll board left"/"right"and disable at each end. Keyboard users can also scroll the board directly, so the buttons are an affordance rather than the only route across. - The board scrolls with
motion-safe:scroll-smooth, so a step is instant underprefers-reduced-motion. Do not passbehavior: "smooth"to a custom control'sscrollBy— that would override the preference. KanbanColumnTitlerenders anh3. Keep the surrounding page headings consistent with that level, or restyle a different tag through the slot.- Column dots and the card insertion line are decorative (
aria-hidden); never let colour alone carry a stage's meaning — the title says it too.