Vesture

Data

KanbanBoard

A columns-of-cards board with pointer drag-and-drop (reorder within a column, move across columns, reorder columns) and a required keyboard alternative — built on the exact same Pointer Events technique as Scheduler's drag-to-reschedule, not a second drag mechanism.

KanbanBoard

Preview

To do2
Write design docdocs
Set up project scaffoldingsetup
In progress11/2
Build KanbanBoard componentDrag-and-drop plus a keyboard-accessible alternative.feature
Done1
Add Scheduler componentfeature

Usage

tsx
import { useState } from "react";
import { KanbanBoard, type KanbanColumn } from "@vesture/react";

function Example() {
  const [columns, setColumns] = useState<KanbanColumn[]>([
    {
      id: "todo",
      title: "To do",
      cards: [{ id: "1", title: "Write design doc", tags: ["docs"] }],
    },
    {
      id: "in-progress",
      title: "In progress",
      wipLimit: 3,
      cards: [
        {
          id: "2",
          title: "Build KanbanBoard",
          assignee: { name: "Ada Lovelace" },
        },
      ],
    },
    { id: "done", title: "Done", cards: [] },
  ]);

  return (
    <KanbanBoard
      columns={columns}
      onColumnsChange={setColumns}
      onCardClick={(card, columnId) => console.log(card, columnId)}
      onAddCard={(columnId) =>
        setColumns((prev) =>
          prev.map((col) =>
            col.id === columnId
              ? { ...col, cards: [...col.cards, { id: crypto.randomUUID(), title: "New card" }] }
              : col
          )
        )
      }
      onAddColumn={() =>
        setColumns((prev) => [
          ...prev,
          { id: crypto.randomUUID(), title: "New column", cards: [] },
        ])
      }
    />
  );
}

Behavior

  • Fully controlled: KanbanBoard never mutates your columns array. Every structural change — add/remove card or column, drag reorder, keyboard move — calls onColumnsChange with the fully updated array; you decide whether/how to apply it, same philosophy as every other component here.
  • Card drag: press and hold a card to start dragging it (within its column or across columns). The original dims in place, a small floating preview follows the pointer, and an insertion line shows exactly where it will land — computed from the pointer's X (which column) and Y (position among that column's cards). Releasing at the same spot it started fires onCardClick instead of a reorder.
  • Column drag: press and hold a column's header (not its card list) to reorder columns horizontally, same insertion-line treatment one level up.
  • Required keyboard alternative — not optional: focus a card and use ArrowUp/ArrowDown to move it within its column, or ArrowLeft/ArrowRight to move it to the same position in the adjacent column. Focus a column header and use Shift+ArrowLeft/Shift+ArrowRight to reorder columns. Every keypress commits immediately via onColumnsChange and is announced through an aria-live region for screen readers.
  • wipLimit is informational, never enforced: a column over its limit shows a warning-colored count badge, and dragging a card over a column that would put it over the limit shows that same warning treatment while hovering — but the drop is never blocked.
  • renderCard fully overrides a card's content (falls back to a default layout using Card, Badge for tags, and Avatar for the assignee) — the card's focusability, drag handling, and keyboard reordering all live on the wrapping element regardless, so a custom render only needs to supply presentation content.
  • No built-in virtualization: plain rendering was checked at 60 cards in a single column and held up fine, so — unlike DataGrid/TreeView — there's no scrollTop-driven windowing here. Revisit only if a real column is an order of magnitude larger than that.

Props

KanbanCard

PropTypeDefaultDescription
idstringStable identity, used for drag tracking, focus retention, and React keys.
titlestringRequired.
descriptionstringShown under the title in the default card layout.
tagsstring[]Rendered as Badges in the default card layout.
assignee{ name: string; avatarUrl?: string }Rendered as an Avatar in the default card layout.
dataunknownPassthrough slot for your own metadata — KanbanBoard never reads it.

KanbanColumn

PropTypeDefaultDescription
id / titlestringRequired.
cardsKanbanCard[]Required.
wipLimitnumberOptional work-in-progress cap, shown as a count badge — visually flagged when exceeded, never blocking (see behavior above).

KanbanBoardProps

PropTypeDefaultDescription
columnsKanbanColumn[]Required.
onColumnsChange(columns: KanbanColumn[]) => voidRequired for any interactivity — add/remove, drag, and keyboard reordering are all gated on this being passed; with no onColumnsChange, the board renders read-only.
onCardClick(card: KanbanCard, columnId: string) => voidFires on a plain click (or a drag released at its start position) — not fired for a click that ends an actual drag.
onAddCard(columnId: string) => voidFires when a column's "+ Add card" affordance is used. KanbanBoard has no built-in card-creation form — wire up your own Modal/Drawer and call onColumnsChange once you have the new card.
onAddColumn() => voidFires when the "+ Add column" affordance is used.
renderCard(card: KanbanCard) => ReactNodeOverrides the default card layout entirely.