Skip to content
ilinxa/pro-ui

Kanban Board

alphav0.6.0

Drag-and-drop kanban board with swimlanes, tinted columns, per-column rules, and a renderer registry that hosts any card type.

Category: Data DisplayUpdated: 2026-08-17Created: 2026-05-05Author: ilinxa

Context

Use when you need a Trello/Linear/JIRA-style board for tracking work, pipelines, or stage transitions. The renderer-registry pattern lets a single column mix the lightweight built-in `kanban-card`, the `kanban-note` annotation, and any rich card from elsewhere in this registry — all as siblings in an ordered, JSON-serializable item list. CRUD affordances are opt-in via callbacks; DnD via @dnd-kit (touch + keyboard accessible).

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/kanban-board

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/kanban-board-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

To do3
Platform
Wire OAuth flow for new SDK

Validate against the staging IDP; fall back to existing token endpoint.

authplatform
  • due:May 12
ALBY
Reminder

Coordinate with the auth team on the new session shape before merging.

Product
drag
In progress2 / 3
Platform
drag
Product
Empty-state polish

Tighten spacing on the notifications panel.

design
CN
Review2
Platform
Cache eviction policy

Decide between LRU and ARC for the new session cache layer.

perfinfra
  • due:May 9
BY
Product
drag
Done2
Platform
Postmortem ready

Posted in #infra-incidents — link in the action items list.

Product
Empty-state illustrations
design
FE

Demo source

demo.tsxtsx
"use client"; import { useCallback, useMemo, useState } from "react";import { CardTree } from "@/registry/components/data/card-tree";import type { CardTreeJsonNode } from "@/registry/components/data/card-tree";import { KanbanBoard } from "./kanban-board";import { kanbanCardRenderer } from "./parts/kanban-card";import { kanbanNoteRenderer } from "./parts/kanban-note";import type { KanbanCardRenderer, KanbanData } from "./types"; // Rich-card adapter — registers the full <CardTree> as a kanban renderer.// Items with rendererId="card-tree" carry a CardTreeJsonNode. We use// dragHandle="header" so the kanban grip strip handles outer reorder while the// CardTree body keeps its click-to-edit + internal DnD intact.function makeCardTreeRenderer(  onItemDataChange: (itemId: string, next: CardTreeJsonNode) => void,): KanbanCardRenderer<CardTreeJsonNode> {  return {    id: "card-tree",    label: "Card tree",    dragHandle: "header",    render: (data, ctx) => (      <div className="rounded-b-md border-x border-b border-border bg-card text-card-foreground shadow-xs">        <CardTree          key={ctx.itemId}          defaultValue={data}          editable          defaultCollapsed={(level) => level >= 1}          metaPresentation="popover"          onChange={(tree) => onItemDataChange(ctx.itemId, tree)}          aria-label={`Card tree item ${ctx.itemId}`}          className="text-xs"        />      </div>    ),    newItem: () => ({      __rcid: `rc-${Date.now().toString(36)}`,      title: "New rich card",    }),  };} const INITIAL_DATA: KanbanData = {  swimlanes: [    { id: "lane-platform", title: "Platform" },    { id: "lane-product", title: "Product" },  ],  columns: [    {      id: "col-todo",      title: "To do",      color: "slate",      items: [        {          id: "item-1",          rendererId: "kanban-card",          swimlaneId: "lane-platform",          data: {            title: "Wire OAuth flow for new SDK",            description:              "Validate against the staging IDP; fall back to existing token endpoint.",            tags: [{ label: "auth" }, { label: "platform" }],            assignees: [              { id: "u-ada", name: "Ada Lovelace" },              { id: "u-bo", name: "Bo Yang" },            ],            meta: [{ key: "due", label: "due", value: "May 12" }],          },        },        {          id: "item-rich-1",          rendererId: "card-tree",          swimlaneId: "lane-product",          data: {            __rcid: "rich-onboarding",            __rcmeta: {              owner: "design",              created: "2026-04-29",              priority: "high",            },            title: "Onboarding flow v2",            summary: "Replace splash modal with an inline tour",            estimated_hours: 16,            blocked: false,            quote:              "Friction in the first 30 seconds defines the whole product.",            list: [              "audit current funnel",              "wireframe v2",              "prototype",              "user-test",              "ship",            ],            requirements: {              __rcid: "rich-onboarding-req",              __rcmeta: { source: "PM brief", revision: 3 },              browsers: ["Chrome", "Safari", "Firefox"],              minimum_version: "ES2020",              codearea: {                format: "ts",                content:                  "type OnboardingStep =\n  | 'welcome'\n  | 'connect'\n  | 'invite'\n  | 'done';\n\ninterface FlowState {\n  step: OnboardingStep;\n  startedAt: string;\n}",              },            },            metrics: {              __rcid: "rich-onboarding-metrics",              kpi: "completion rate",              baseline: 0.41,              target: 0.62,              table: {                headers: ["step", "drop-off (v1)", "target"],                rows: [                  ["welcome", 0.08, 0.05],                  ["connect", 0.31, 0.15],                  ["invite", 0.18, 0.1],                ],              },            },          } satisfies CardTreeJsonNode,        },        {          id: "item-2",          rendererId: "kanban-note",          swimlaneId: "lane-platform",          data: {            title: "Reminder",            body: "Coordinate with the auth team on the new session shape before merging.",          },        },      ],    },    {      id: "col-doing",      title: "In progress",      color: "lime",      maxItems: 3,      items: [        {          id: "item-rich-2",          rendererId: "card-tree",          swimlaneId: "lane-platform",          data: {            __rcid: "rich-migration",            __rcmeta: {              ticket: "PLAT-482",              risk: "medium",              window: "2026-05-09T22:00:00Z",            },            title: "Migrate session store",            stage: "implementation",            rollback_ready: true,            image: {              src: "https://images.unsplash.com/photo-1518770660439-4636190af475?w=600&q=70",              alt: "Architecture diagram",            },            steps: {              __rcid: "rich-migration-steps",              expected_duration_minutes: 45,              list: [                "snapshot current store",                "drain in-flight sessions",                "swap connection pool",                "verify auth probe",                "monitor 30m",              ],            },            schema: {              __rcid: "rich-migration-schema",              codearea: {                format: "sql",                content:                  "ALTER TABLE sessions\n  ADD COLUMN store_version SMALLINT NOT NULL DEFAULT 2;\n\nCREATE INDEX CONCURRENTLY idx_sessions_v2\n  ON sessions (user_id, store_version);",              },            },          } satisfies CardTreeJsonNode,        },        {          id: "item-4",          rendererId: "kanban-card",          swimlaneId: "lane-product",          data: {            title: "Empty-state polish",            description: "Tighten spacing on the notifications panel.",            tags: [{ label: "design" }],            assignees: [{ id: "u-cn", name: "Cy Nguyen" }],          },        },      ],    },    {      id: "col-review",      title: "Review",      color: "sky",      items: [        {          id: "item-rich-3",          rendererId: "card-tree",          swimlaneId: "lane-product",          data: {            __rcid: "rich-spec",            __rcmeta: { reviewer: "Ada Lovelace", round: 2 },            title: "API spec — billing v3",            status: "in-review",            breaking_changes: false,            quote:              "If we get the deprecation window right, nobody notices the migration.",            endpoints: {              __rcid: "rich-spec-endpoints",              count: 6,              table: {                headers: ["method", "path", "auth"],                rows: [                  ["GET", "/v3/invoices", "scope:read"],                  ["POST", "/v3/invoices", "scope:write"],                  ["GET", "/v3/customers/:id", "scope:read"],                  ["DELETE", "/v3/invoices/:id", "scope:write"],                ],              },            },            sample: {              __rcid: "rich-spec-sample",              codearea: {                format: "json",                content:                  '{\n  "id": "inv_82a",\n  "amount_cents": 12500,\n  "currency": "EUR",\n  "due": "2026-05-30"\n}',              },            },            notes: ["soft-deprecate v2 by EOY", "x-version header optional"],          } satisfies CardTreeJsonNode,        },        {          id: "item-5",          rendererId: "kanban-card",          swimlaneId: "lane-platform",          data: {            title: "Cache eviction policy",            description:              "Decide between LRU and ARC for the new session cache layer.",            tags: [{ label: "perf" }, { label: "infra" }],            assignees: [{ id: "u-bo", name: "Bo Yang" }],            meta: [{ key: "due", label: "due", value: "May 9" }],          },        },      ],    },    {      id: "col-done",      title: "Done",      color: "emerald",      allowReorder: false,      items: [        {          id: "item-6",          rendererId: "kanban-card",          swimlaneId: "lane-product",          data: {            title: "Empty-state illustrations",            tags: [{ label: "design" }],            assignees: [{ id: "u-fe", name: "Farah Eid" }],          },        },        {          id: "item-7",          rendererId: "kanban-note",          swimlaneId: "lane-platform",          data: {            title: "Postmortem ready",            body: "Posted in #infra-incidents — link in the action items list.",          },        },      ],    },  ],}; export default function KanbanBoardDemo() {  const [data, setData] = useState<KanbanData>(INITIAL_DATA);   // Rich-card renderer fires this whenever its inner state changes — we walk  // the board and replace the matching item's data so kanban stays the source of truth.  const updateCardTreeData = useCallback(    (itemId: string, next: CardTreeJsonNode) => {      setData((prev) => ({        ...prev,        columns: prev.columns.map((col) => ({          ...col,          items: col.items.map((it) =>            it.id === itemId ? { ...it, data: next } : it,          ),        })),      }));    },    [],  );   const renderers = useMemo(    () => [      kanbanCardRenderer,      kanbanNoteRenderer,      makeCardTreeRenderer(updateCardTreeData),    ],    [updateCardTreeData],  );   return (    <div className="h-160 w-full overflow-hidden rounded-md border border-border bg-background">      <KanbanBoard        renderers={renderers}        data={data}        onChange={setData}      />    </div>  );} 

Usage

When to use

Reach for KanbanBoard when you need a column-based board with drag-and-drop reordering, optional swimlanes, optional CRUD, and the flexibility to host any card from this registry (or your own custom components) as first-class items in any column.

Key concepts

  • Items are pure JSON. A column's items[] is an array of { id, rendererId, data, swimlaneId?, locked? } records. The board never holds JSX in its data layer.
  • Renderers are pluggable. Two ship built-in (kanbanCardRenderer, kanbanNoteRenderer); register more via the renderers prop. Each declares an id and a render(data, ctx).
  • Drag works for items and columns. Items reorder within a column, move across columns, and (when swimlanes are provided) move across swimlane cells. Column headers themselves are draggable for reorder.
  • CRUD is opt-in. Pass onItemCreateand an inline "+ Add" row appears under each column. Same pattern for edit, delete, and column CRUD callbacks.

Basic example

import { KanbanBoard } from "@/components/kanban-board";
import { kanbanCardRenderer } from "@/components/kanban-board/parts/kanban-card";
import { kanbanNoteRenderer } from "@/components/kanban-board/parts/kanban-note";

export function Example() {
  return (
    <KanbanBoard
      renderers={[kanbanCardRenderer, kanbanNoteRenderer]}
      defaultData={{
        columns: [
          {
            id: "todo",
            title: "To do",
            items: [
              { id: "c1", rendererId: "kanban-card", data: { title: "Wire auth flow" } },
              { id: "n1", rendererId: "kanban-note", data: { title: "Reminder", body: "Coordinate with infra." } },
            ],
          },
          { id: "doing", title: "In progress", color: "lime", items: [] },
          { id: "done",  title: "Done",        color: "emerald", items: [], allowReorder: false },
        ],
      }}
    />
  );
}

Movement controls

  • column.allowReorder: false — items cannot reorder within this column.
  • column.allowIncoming: false — items cannot be dropped into this column from elsewhere.
  • column.allowOutgoing: false — items cannot leave this column.
  • column.acceptsRendererIds: [...] — only host the listed renderer kinds.
  • item.locked: true — pin an individual item; it cannot be dragged anywhere.
  • readOnly at the board level kills all DnD and CRUD affordances; items remain clickable.

Mixing rich cards (renderer adapter)

Any sibling registry component can be plugged in as a third renderer with all of its features intact. The demo wires <CardTree> from @ilinxa/card-tree — same pattern works for any rich card you author. Set dragHandle: "header" for renderers that own internal pointer interactions so the kanban grip appears on top and the body stays interactive:

import { CardTree, type CardTreeJsonNode } from "@ilinxa/card-tree";

function makeCardTreeRenderer(
  onChange: (id: string, next: CardTreeJsonNode) => void,
): KanbanCardRenderer<CardTreeJsonNode> {
  return {
    id: "card-tree",
    label: "Card tree",
    dragHandle: "header",   // ← thin grip strip; body stays interactive
    render: (data, ctx) => (
      <CardTree
        key={ctx.itemId}
        defaultValue={data}
        editable
        onChange={(tree) => onChange(ctx.itemId, tree)}
      />
    ),
  };
}

<KanbanBoard
  renderers={[kanbanCardRenderer, kanbanNoteRenderer, makeCardTreeRenderer(updateData)]}
  defaultData={{ /* items reference rendererId: "kanban-card" | "kanban-note" | "card-tree" */ }}
/>

dragHandle modes: "shell" (default) makes the whole card the drag activator — right for plain content cards. "header" renders a small grip strip on top and leaves the body fully interactive — right for renderers with click-to-edit fields, embedded inputs, or their own internal DnD.

Pre-built renderer — todo items: @ilinxa/task-card exports a ready-to-use taskCardKanbanRenderer (typed as KanbanCardRenderer<TaskItem>, dragHandle: "header") — no factory wrapper needed. Drop it directly into renderers={[...]} and give each item rendererId: "task-card" with a TaskItem-shaped data payload. See the task-card detail page for the live kanban demo + the full code recipe.

Keyboard

  • Tab cycles focus through items and column headers.
  • Space on a focused item lifts it (DnD mode); arrow keys move; Space drops; Escape cancels.
  • Enter on a focused item fires onItemClick.

Features

  • Renderer registry — items are pure JSON, the board delegates rendering by id
  • Two built-in renderers: kanban-card (title + meta + tags + assignees) and kanban-note (title + body)
  • Pluggable card-tree adapter pattern — wrap any sibling component (e.g. card-tree) as a renderer with full feature passthrough
  • Per-renderer dragHandle mode — `shell` (whole-card grab) or `header` (top grip strip; body stays interactive for renderers with internal pointer interactions)
  • Drag-and-drop reorder within column, across columns, and across swimlane cells — drop anywhere in a column, not only onto a card
  • Column reorder by dragging the column header
  • Per-column movement flags (allowReorder, allowIncoming, allowOutgoing, acceptsRendererIds)
  • Per-item lock pins an item against any movement
  • Built-in 6-swatch color palette per column (semantic CSS vars; overridable)
  • Collapsible columns (~40px vertical strip) with auto-expand on drop
  • Optional swimlanes — each (column × lane) cell is its own droppable
  • Soft maxItems cap with overflow chip; no drop blocking
  • Optional CRUD via callbacks (no callback = no affordance); inline editors per renderer
  • Controlled and uncontrolled state
  • Keyboard accessible drag (Space lift, arrows, Space drop, Escape cancel)
  • Read-only mode disables all DnD and CRUD, leaves clicks active
  • Native vertical column scroll when content overflows; vertical mouse-wheel scrolls the board horizontally
  • v0.6 barrel completeness: `AnyKanbanCardRenderer` — the declared element type of `KanbanBoardProps.renderers` and of the exported `findRenderer`'s first parameter — is now importable from the package root. Type-only, additive.

Tags

kanbanboarddrag-and-dropdnd-kitswimlanescolumnstaskscard-tree

Dependencies

shadcn primitives: avatar, badge, button, dropdown-menu, input, popover, textarea
npm peer deps: @dnd-kit/core@^6.3.1, @dnd-kit/sortable@^10.0.0, @dnd-kit/utilities@^3.2.2, lucide-react@^1.11.0