Kanban

Drag-and-drop board primitives for pipelines, with columns, cards, and scroll controls.

Quick Preview

Step 1 of 4

New2

A

Amara Okoye

Lekki Gardens Phase 2

₦85M

T

Tunde Bakare

Ikoyi Heights

₦120M

Step 2 of 4

Contacted1

Z

Zainab Bello

Maitama Courts

₦48M

Step 3 of 4

Viewing Booked2

C

Chidi Nwosu

Banana Island Villa

₦210M

F

Fatima Sule

Gwarinpa Terraces

₦62M

Step 4 of 4

Offer Made1

O

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

A

Amara Okoye

Lekki Gardens Phase 2

₦85M

T

Tunde Bakare

Ikoyi Heights

₦120M

Step 2 of 4

Contacted1

Z

Zainab Bello

Maitama Courts

₦48M

Step 3 of 4

Viewing Booked2

C

Chidi Nwosu

Banana Island Villa

₦210M

F

Fatima Sule

Gwarinpa Terraces

₦62M

Step 4 of 4

Offer Made1

O

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

Amara Okoye

Accepts drops

In Review2

Tunde Bakare
Zainab BelloLocked

Accepts drops

Approved1

Chidi Nwosu

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

Amara Okoye
Tunde Bakare

Contacted1

Zainab Bello

Offer Made1

Chidi Nwosu
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/APITypeDefaultDescription
KanbanRootdiv 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.
KanbanBoarddiv props-Horizontally scrolling column strip with drag auto-scroll. Give it a height.
KanbanColumnsection props plus columnId: string, dropDisabled?: booleandropDisabled: falseOne 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 / KanbanColumnDescriptionelement props-Header slots: stack, heading row, status dot, count pill, and caption.
KanbanColumnBodydiv props-Vertically scrolling card list with drag auto-scroll.
KanbanDropPlaceholderdiv props-"Drop here" block, rendered only while a card from another column is over this one.
KanbanCarddiv props plus cardId: string, dragDisabled?: booleandragDisabled: falseA 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.
KanbanCardDragHandlebutton propsgrip iconRestricts dragging to this element. Decorative and out of the tab order — the keyboard route is the card's own menu.
KanbanScrollControls / KanbanScrollButtondiv 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.
  • KanbanRoot renders a polite live region. Return a sentence from announceMove so a drop is spoken, and call announce from useKanbanFeedback for 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-motion leaves it on.
  • KanbanCardDragHandle is aria-hidden and 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 under prefers-reduced-motion. Do not pass behavior: "smooth" to a custom control's scrollBy — that would override the preference.
  • KanbanColumnTitle renders an h3. 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.

On this page