Skip to main content

ns-ui

Board Kanban ASCII WIP

A kanban board drawn as box-drawing frames, each column header showing its card count against its WIP limit; going over turns the frame accent and appends the overflow count, mid-drag, before you release.

Usage

registry/core/board-kanban-ascii-wip/demo.tsx
"use client";

import { useState } from "react";
import { WipBoard, type WipColumn } from "./component";

const COLUMNS: WipColumn[] = [
  {
    id: "backlog",
    title: "Backlog",
    limit: 8,
    cards: [
      { id: "c-411", ref: "ATL-411", title: "Rotate signing keys", meta: "P1" },
      { id: "c-388", ref: "ATL-388", title: "Dedupe webhook retries", meta: "P2" },
      { id: "c-402", ref: "ATL-402", title: "CSV column mapping", meta: "P2" },
      { id: "c-370", ref: "ATL-370", title: "Audit log retention", meta: "P3" },
    ],
  },
  {
    id: "doing",
    title: "In Progress",
    limit: 3,
    cards: [
      { id: "c-214", ref: "ATL-214", title: "Ship API pagination", meta: "mara" },
      { id: "c-357", ref: "ATL-357", title: "Safari focus trap", meta: "jonas" },
      { id: "c-291", ref: "ATL-291", title: "Stripe backfill", meta: "eli" },
    ],
  },
  {
    id: "review",
    title: "Review",
    limit: 3,
    cards: [
      { id: "c-198", ref: "ATL-198", title: "Rate-limit headers", meta: "+2" },
      { id: "c-233", ref: "ATL-233", title: "Search relevance", meta: "+1" },
      { id: "c-245", ref: "ATL-245", title: "Billing proration", meta: "+3" },
      { id: "c-260", ref: "ATL-260", title: "Seat invite emails", meta: "+1" },
      { id: "c-266", ref: "ATL-266", title: "Locale fallbacks", meta: "+2" },
    ],
  },
];

export default function WipBoardDemo() {
  const [note, setNote] = useState("Review is 2 over its limit");
  const titleOf = (id: string) => COLUMNS.find((c) => c.id === id)?.title ?? id;

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-6 px-8 py-10">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / board-kanban-ascii-wip
      </p>

      <WipBoard
        columns={COLUMNS}
        onOverLimit={(colId, over) =>
          setNote(
            over > 0
              ? `${titleOf(colId)} is ${over} over its limit`
              : `${titleOf(colId)} is within its limit`
          )
        }
      />

      <p className="font-mono text-[11px] text-ns-muted">
        drag a card, or focus one and use the arrow keys &middot; {note}
      </p>
    </div>
  );
}

Component

registry/core/board-kanban-ascii-wip/component.tsx
"use client";

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from "react";

// ---------------------------------------------------------------------------
// WipBoard — a kanban board whose column frames are real box-drawing glyphs.
// Each column header reads `┌─ In Progress ───── 3/3 ─┐`; when the card count
// exceeds the limit the counter gains a `▲1` suffix and the whole frame
// switches from the resting muted ink to the accent token. Dragging a card
// renders a ╌╌╌╌ placeholder at the live insertion point and drives the TARGET
// column's counter with the PREDICTED count while the card is still in the air.
// Over-limit drops are allowed: a WIP limit is a policy signal, not a lock, and
// `onOverLimit` lets the consumer decide. Plain React state, no rAF loop and no
// canvas; the only motion is a 120ms FLIP transform on card reflow, skipped
// entirely under prefers-reduced-motion.
// ---------------------------------------------------------------------------

export interface WipCard {
  id: string;
  /** short issue key rendered at the head of the row, e.g. "ATL-214" */
  ref?: string;
  title: string;
  /** right-aligned trailing note: assignee, age, review count */
  meta?: string;
}

export interface WipColumn {
  id: string;
  title: string;
  /** the column's WIP limit — the denominator of the header counter */
  limit: number;
  cards: WipCard[];
}

export interface WipBoardProps {
  /** the board's columns, each with its own WIP limit */
  columns: WipColumn[];
  /** column width in monospace character cells (the frame tiles to exactly this) */
  width?: number;
  /** fires on every committed move, pointer or keyboard */
  onChange?: (columns: WipColumn[]) => void;
  /** fires per commit for the receiving column: `over` is count - limit, 0 when within policy */
  onOverLimit?: (columnId: string, over: number) => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** accessible name for the board */
  "aria-label"?: string;
}

const DRAG_THRESHOLD = 4; // px of travel before a pointerdown becomes a live drag
const REFLOW_MS = 120;
const DASH = "╌";
// the resting frame ink. NOT `text-border` — --border is tuned for 1px
// hairlines and is invisible as type on the light theme (#ebebeb on #ffffff),
// which would leave the box-drawing frame — the whole point of the component —
// blank at rest. A muted tint reads in both themes and still sits below the
// card text in the hierarchy.
const FRAME_INK = "text-ns-muted/55";

/** `┌─ In Progress ───────── 3/3 ─┐` — exactly `width` characters wide. */
function headerLine(title: string, count: number, limit: number, width: number) {
  const over = Math.max(0, count - limit);
  const label = over > 0 ? `${count}/${limit} ▲${over}` : `${count}/${limit}`;
  const rule = "─".repeat(Math.max(1, width - 8 - title.length - label.length));
  return `┌─ ${title} ${rule} ${label} ─┐`;
}

function moveCard(
  cols: WipColumn[],
  cardId: string,
  toColId: string,
  toIndex: number
): WipColumn[] {
  let card: WipCard | undefined;
  const stripped = cols.map((c) => {
    const found = c.cards.find((x) => x.id === cardId);
    if (!found) return c;
    card = found;
    return { ...c, cards: c.cards.filter((x) => x.id !== cardId) };
  });
  if (!card) return cols;
  const picked = card;
  return stripped.map((c) => {
    if (c.id !== toColId) return c;
    const i = Math.min(c.cards.length, Math.max(0, toIndex));
    return { ...c, cards: [...c.cards.slice(0, i), picked, ...c.cards.slice(i)] };
  });
}

type Drop = { col: string; index: number };

type DragState = {
  id: string;
  fromCol: string;
  pointerId: number;
  offsetX: number;
  offsetY: number;
  startX: number;
  startY: number;
  active: boolean;
  width: number;
  /** column boxes cached at pickup — recomputing them mid-drag would chase the placeholder */
  colRects: { id: string; left: number; right: number }[];
  /** per-column card mid-Y cached at pickup, in committed order */
  cardMids: Map<string, { id: string; mid: number }[]>;
};

export function WipBoard({
  columns,
  width = 34,
  onChange,
  onOverLimit,
  className = "",
  "aria-label": ariaLabel = "Kanban board with WIP limits",
}: WipBoardProps) {
  const [cols, setCols] = useState<WipColumn[]>(columns);
  const [drop, setDrop] = useState<Drop | null>(null);
  const [dragId, setDragId] = useState<string | null>(null);
  const [liveMsg, setLiveMsg] = useState("");

  const colsRef = useRef(cols);
  colsRef.current = cols;
  const dragRef = useRef<DragState | null>(null);
  const dropRef = useRef<Drop | null>(null);
  dropRef.current = drop;

  const boardRef = useRef<HTMLDivElement>(null);
  const colEls = useRef<Map<string, HTMLDivElement>>(new Map());
  const cardEls = useRef<Map<string, HTMLButtonElement>>(new Map());
  const prevRects = useRef<Map<string, DOMRect>>(new Map());
  const focusNext = useRef<string | null>(null);
  const reducedRef = useRef(false);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => {
      reducedRef.current = mq.matches;
    };
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);

  // FLIP: cards live in normal flow, so a commit is measured before/after and
  // played back as one short transform. Never runs mid-drag (the lifted card is
  // position:fixed and its neighbours have already collapsed) or under
  // reduced motion, where the reflow is simply instant.
  useLayoutEffect(() => {
    const next = new Map<string, DOMRect>();
    cardEls.current.forEach((el, id) => next.set(id, el.getBoundingClientRect()));
    if (!reducedRef.current && !dragRef.current) {
      next.forEach((r, id) => {
        const p = prevRects.current.get(id);
        const el = cardEls.current.get(id);
        if (!p || !el) return;
        const dx = p.left - r.left;
        const dy = p.top - r.top;
        if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
        el.style.transition = "none";
        el.style.transform = `translate3d(${dx.toFixed(1)}px, ${dy.toFixed(1)}px, 0)`;
        requestAnimationFrame(() => {
          if (!el.isConnected) return;
          el.style.transition = `transform ${REFLOW_MS}ms cubic-bezier(0.2, 0, 0, 1)`;
          el.style.transform = "";
        });
      });
    }
    prevRects.current = next;
  });

  useEffect(() => {
    const id = focusNext.current;
    if (!id) return;
    focusNext.current = null;
    cardEls.current.get(id)?.focus();
  }, [cols]);

  const announce = useCallback(
    (card: WipCard, next: WipColumn[], toColId: string) => {
      const col = next.find((c) => c.id === toColId);
      if (!col) return;
      const count = col.cards.length;
      const over = Math.max(0, count - col.limit);
      setLiveMsg(
        `Moved ${card.title} to ${col.title}. ${count} of ${col.limit}` +
          (over > 0 ? `, over limit by ${over}.` : ", within limit.")
      );
      onOverLimit?.(col.id, over);
    },
    [onOverLimit]
  );

  const commit = useCallback(
    (cardId: string, toColId: string, toIndex: number, refocus: boolean) => {
      const prev = colsRef.current;
      const card = prev.flatMap((c) => c.cards).find((x) => x.id === cardId);
      const next = moveCard(prev, cardId, toColId, toIndex);
      setCols(next);
      onChange?.(next);
      if (card) announce(card, next, toColId);
      if (refocus) focusNext.current = cardId;
    },
    [announce, onChange]
  );

  const liftEnd = useCallback((id: string) => {
    const el = cardEls.current.get(id);
    if (!el) return;
    el.style.position = "";
    el.style.left = "";
    el.style.top = "";
    el.style.width = "";
    el.style.zIndex = "";
    el.style.transition = "";
    el.style.transform = "";
  }, []);

  // Move/up live on the document, not on the card. A real pointer would be
  // retargeted to the card by its own pointer capture, but the preview driver
  // dispatches synthetic events to whatever it hit-tests under the cursor — so
  // a card-scoped handler goes silent the moment the pointer crosses into
  // another column. Document listeners receive both, and the drag survives.
  const moveHandler = useRef<((e: PointerEvent) => void) | null>(null);
  const upHandler = useRef<((e: PointerEvent) => void) | null>(null);

  const detach = useCallback(() => {
    if (moveHandler.current) document.removeEventListener("pointermove", moveHandler.current);
    if (upHandler.current) {
      document.removeEventListener("pointerup", upHandler.current);
      document.removeEventListener("pointercancel", upHandler.current);
    }
    moveHandler.current = null;
    upHandler.current = null;
  }, []);

  const cancelDrag = useCallback(() => {
    detach();
    const d = dragRef.current;
    if (!d) return;
    dragRef.current = null;
    // Escape ends the drag with no pointerup coming, so the capture the card
    // took on pointerdown has to be handed back explicitly or the card keeps
    // swallowing the pointer stream until the button is physically released.
    const el = cardEls.current.get(d.id);
    if (el?.hasPointerCapture(d.pointerId)) el.releasePointerCapture(d.pointerId);
    if (d.active) liftEnd(d.id);
    setDragId(null);
    setDrop(null);
  }, [detach, liftEnd]);

  const handleMove = useCallback((e: PointerEvent) => {
    const d = dragRef.current;
    if (!d || d.pointerId !== e.pointerId) return;
    const el = cardEls.current.get(d.id);
    if (!el) return;

    if (!d.active) {
      const moved = Math.hypot(e.clientX - d.startX, e.clientY - d.startY);
      if (moved <= DRAG_THRESHOLD) return;
      d.active = true;
      el.style.transition = "none";
      el.style.transform = "";
      el.style.position = "fixed";
      el.style.width = `${d.width}px`;
      el.style.zIndex = "30";
      setDragId(d.id);
    }

    el.style.left = `${e.clientX - d.offsetX}px`;
    el.style.top = `${e.clientY - d.offsetY}px`;

    // target column: the box the pointer is inside, else the nearest edge
    let colId = d.colRects[0]?.id ?? d.fromCol;
    let best = Infinity;
    for (const c of d.colRects) {
      const dist = e.clientX < c.left ? c.left - e.clientX : e.clientX > c.right ? e.clientX - c.right : 0;
      if (dist < best) {
        best = dist;
        colId = c.id;
      }
    }
    const mids = (d.cardMids.get(colId) ?? []).filter((m) => m.id !== d.id);
    let index = 0;
    while (index < mids.length && mids[index].mid < e.clientY) index++;

    const cur = dropRef.current;
    if (!cur || cur.col !== colId || cur.index !== index) setDrop({ col: colId, index });
  }, []);

  const handleUp = useCallback(
    (e: PointerEvent) => {
      const d = dragRef.current;
      if (!d || d.pointerId !== e.pointerId) return;
      detach();
      const card = cardEls.current.get(d.id);
      if (card?.hasPointerCapture(e.pointerId)) card.releasePointerCapture(e.pointerId);
      const target = dropRef.current;
      const wasActive = d.active;
      dragRef.current = null;
      if (wasActive) liftEnd(d.id);
      setDragId(null);
      setDrop(null);
      if (wasActive && target && e.type !== "pointercancel") {
        commit(d.id, target.col, target.index, false);
      }
    },
    [commit, detach, liftEnd]
  );

  const onPointerDown = useCallback(
    (cardId: string, colId: string, e: ReactPointerEvent<HTMLButtonElement>) => {
      const board = boardRef.current;
      if (!board || e.button !== 0) return;
      const rect = e.currentTarget.getBoundingClientRect();
      e.currentTarget.setPointerCapture(e.pointerId);

      const colRects = colsRef.current.map((c) => {
        const el = colEls.current.get(c.id);
        const r = el?.getBoundingClientRect();
        return { id: c.id, left: r?.left ?? 0, right: r?.right ?? 0 };
      });
      const cardMids = new Map<string, { id: string; mid: number }[]>();
      for (const c of colsRef.current) {
        cardMids.set(
          c.id,
          c.cards.map((card) => {
            const r = cardEls.current.get(card.id)?.getBoundingClientRect();
            return { id: card.id, mid: r ? r.top + r.height / 2 : 0 };
          })
        );
      }

      dragRef.current = {
        id: cardId,
        fromCol: colId,
        pointerId: e.pointerId,
        offsetX: e.clientX - rect.left,
        offsetY: e.clientY - rect.top,
        startX: e.clientX,
        startY: e.clientY,
        active: false,
        width: rect.width,
        colRects,
        cardMids,
      };

      detach();
      moveHandler.current = handleMove;
      upHandler.current = handleUp;
      document.addEventListener("pointermove", handleMove);
      document.addEventListener("pointerup", handleUp);
      document.addEventListener("pointercancel", handleUp);
    },
    [detach, handleMove, handleUp]
  );

  // a drag can outlive the component (unmount mid-flight); never leave the
  // document listening to a handler whose state is gone
  useEffect(() => detach, [detach]);

  // Escape is a document listener, not a key handler on the card: the drag is
  // in flight regardless of where focus happens to sit.
  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape" && dragRef.current) cancelDrag();
    }
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [cancelDrag]);

  const onCardKeyDown = useCallback(
    (cardId: string, colId: string, e: ReactKeyboardEvent<HTMLButtonElement>) => {
      const cur = colsRef.current;
      const ci = cur.findIndex((c) => c.id === colId);
      if (ci < 0) return;
      const idx = cur[ci].cards.findIndex((x) => x.id === cardId);
      if (idx < 0) return;

      if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
        const to = ci + (e.key === "ArrowLeft" ? -1 : 1);
        if (to < 0 || to >= cur.length) return;
        e.preventDefault();
        commit(cardId, cur[to].id, Math.min(idx, cur[to].cards.length), true);
        return;
      }
      if (e.key === "ArrowUp" || e.key === "ArrowDown") {
        const to = idx + (e.key === "ArrowUp" ? -1 : 1);
        if (to < 0 || to >= cur[ci].cards.length) return;
        e.preventDefault();
        commit(cardId, colId, to, true);
      }
    },
    [commit]
  );

  const rowCount = Math.max(1, ...cols.map((c) => c.cards.length)) + 1;
  const blank = `│${" ".repeat(Math.max(0, width - 2))}│`;
  const footer = `└${"─".repeat(Math.max(0, width - 2))}┘`;

  return (
    <div
      ref={boardRef}
      data-board
      role="group"
      aria-label={ariaLabel}
      className={`flex select-none items-start gap-3 font-mono text-xs leading-[1.7] ${className}`}
    >
      <div aria-live="polite" role="status" className="sr-only">
        {liveMsg}
      </div>

      {cols.map((col) => {
        const isSource = dragId != null && col.cards.some((c) => c.id === dragId);
        const isTarget = drop?.col === col.id;
        // the predicted count: the lifted card has already left its source
        // column and is already counted by whichever column it is over
        const count = col.cards.length - (isSource ? 1 : 0) + (isTarget ? 1 : 0);
        const over = Math.max(0, count - col.limit);
        const rows: ReactNode[] = [];
        const placeholder = (
          <div key="ph" className="flex whitespace-pre text-ns-accent">
            <span aria-hidden>│</span>
            <span aria-hidden className="min-w-0 flex-1 overflow-hidden px-1">
              {DASH.repeat(Math.max(0, width - 4))}
            </span>
            <span aria-hidden>│</span>
          </div>
        );
        // `slot` counts only the rows that actually occupy space: the lifted
        // card is position:fixed, so it stays in the DOM (its pointer capture
        // depends on it) but its slot has already collapsed.
        let slot = 0;
        let placed = false;

        col.cards.forEach((card, i) => {
          if (isTarget && drop && !placed && slot === drop.index) {
            rows.push(placeholder);
            placed = true;
          }
          if (card.id !== dragId) slot++;
          rows.push(
            // the lifted card is position:fixed, so its row must stop occupying
            // a line too — otherwise the source column stands one row taller
            // than its neighbours and the footers stop aligning mid-drag.
            // `overflow-hidden` here cannot clip the card: no ancestor is
            // transformed, so a fixed descendant escapes this box.
            <div
              key={card.id}
              className={`flex whitespace-pre ${dragId === card.id ? "h-0 overflow-hidden" : ""}`}
            >
              <span aria-hidden>│</span>
              <button
                type="button"
                ref={(el) => {
                  if (el) cardEls.current.set(card.id, el);
                  else cardEls.current.delete(card.id);
                }}
                data-card={card.id}
                aria-label={`${card.title}${card.ref ? `, ${card.ref}` : ""}. ${col.title}, position ${
                  i + 1
                } of ${col.cards.length}, column at ${col.cards.length} of ${col.limit}. Arrow keys move and reorder.`}
                onPointerDown={(e) => onPointerDown(card.id, col.id, e)}
                onKeyDown={(e) => onCardKeyDown(card.id, col.id, e)}
                className={`flex min-w-0 flex-1 cursor-grab touch-none items-baseline gap-2 rounded-sm px-1 text-left text-ns-muted transition-colors duration-150 hover:bg-surface hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ns-accent active:cursor-grabbing ${
                  dragId === card.id ? "bg-surface text-foreground" : ""
                }`}
              >
                {card.ref ? <span className="shrink-0 opacity-60">{card.ref}</span> : null}
                <span className="min-w-0 flex-1 truncate">{card.title}</span>
                {card.meta ? <span className="shrink-0 opacity-60">{card.meta}</span> : null}
              </button>
              <span aria-hidden>│</span>
            </div>
          );
        });

        // insertion past the last card
        if (isTarget && drop && !placed) {
          rows.push(placeholder);
          placed = true;
        }

        const occupied = slot + (placed ? 1 : 0);
        for (let i = occupied; i < rowCount; i++) {
          rows.push(
            <div key={`b${i}`} aria-hidden className="whitespace-pre">
              {blank}
            </div>
          );
        }

        return (
          <div
            key={col.id}
            ref={(el) => {
              if (el) colEls.current.set(col.id, el);
              else colEls.current.delete(col.id);
            }}
            data-column={col.id}
            data-over={over > 0 ? "true" : undefined}
            role="group"
            aria-label={`${col.title}, ${col.cards.length} of ${col.limit}${
              col.cards.length > col.limit ? `, over limit by ${col.cards.length - col.limit}` : ""
            }`}
            style={{ width: `${width}ch` }}
            className={`shrink-0 transition-colors duration-150 ${over > 0 ? "text-ns-accent" : FRAME_INK}`}
          >
            <div className="whitespace-pre" aria-hidden>
              {headerLine(col.title, count, col.limit, width)}
            </div>
            <div aria-hidden className="whitespace-pre">
              {blank}
            </div>
            {rows}
            <div className="whitespace-pre" aria-hidden>
              {footer}
            </div>
          </div>
        );
      })}
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/board-kanban-ascii-wip.tsx, so point the import there.

Use when a kanban or triage board where each column's work-in-progress limit must be readable in the header and visibly crossed while a card is still in the air; pick reorder-drag-wake instead when there is a single vertical list and the interest is the physics of neighbours shouldering aside rather than a per-column capacity signal, or queue-triage-ratchet when items advance through one queue rather than moving between columns.

Install

npx shadcn add https://design.helpmarq.com/r/board-kanban-ascii-wip.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build <WipBoard columns width? onChange? onOverLimit? className? aria-label?> as a kanban board whose column chrome is real box-drawing glyphs. FRAME: each column is exactly `width` monospace cells wide (default 34, set as `width: {n}ch` so the frame tiles to a whole character count) with a `┌─ In Progress ───────── 3/3 ─┐` header, per-row `│` side rules, blank `│ │` rails padding every column to a common row count, and a `└──────┘` footer. The header rule is uniform `─`, sized to `width − 8 − title.length − label.length` so the header is exactly `width` characters regardless of the label. LIMIT: the header label is a plain `count/limit` readout; when `count > limit` it reads `4/3 ▲1` and the whole column — frame, rails, footer and counter — switches from its resting muted ink to the `accent` token. There is no red, no amber, no fill colour and no glow: the overload state is one accent ink plus the number, so it survives both themes and reads at card scale. DRAG: `pointerdown` on a card's button takes `setPointerCapture` and then binds `pointermove`/`pointerup`/`pointercancel` on the DOCUMENT, not on the card — a real pointer would be retargeted to the card by its own capture, but a synthesised pointer (the catalog's preview driver) is dispatched to whatever sits under the cursor, so a card-scoped move handler goes silent the instant the pointer crosses into another column; the document sees both. Past a 4px threshold the card becomes `position: fixed` and tracks the pointer 1:1, and its row is collapsed to `h-0 overflow-hidden` so the source column does not stand a line taller than its neighbours while the card is airborne (no ancestor is transformed, so the fixed card escapes that clip). Column boxes and per-card mid-Y are cached ONCE at pickup — recomputing them mid-drag would make the hit test chase the placeholder it just opened. `pointermove` picks the target column by testing clientX against the cached column boxes (nearest edge when the pointer is between them) and the insertion index by counting cached card mid-Ys above clientY, excluding the dragged card. A `╌╌╌╌` dashed placeholder row opens at that index in the accent ink, and the target column's counter renders the PREDICTED count (`count + 1`) while the card is still in the air, the source column's `count − 1`, so you watch the limit tip over and flip the frame to accent BEFORE the pointer is released. `pointerup` commits; `Escape` mid-drag is a document-level listener (the drag is in flight regardless of where focus sits) that cancels and restores the origin and the true counts. Over-limit drops are deliberately ALLOWED — a WIP limit is a policy signal, not a lock — and `onOverLimit(columnId, over)` fires per commit so a consumer can warn, log or block. KEYBOARD / SEMANTICS: the header line is decorative (`aria-hidden`), so each column div carries `role="group"` with `aria-label="Review, 5 of 3, over limit by 2"` — otherwise an empty column would be entirely invisible to a screen reader. Every card is a real `<button>` with a full accessible name (title, issue ref, column, position, and the column's current count/limit) and a visible `focus-visible` accent outline inset by 2px so the ring sits inside the box rule. ArrowLeft/ArrowRight move the focused card between columns at the same clamped index, ArrowUp/ArrowDown reorder it within its column, both through the exact same `commit()` path as the pointer, focus follows the card across the move, and an `aria-live="polite"` region announces `Moved Ship API pagination to In Progress. 4 of 3, over limit by 1.` MOTION: plain React state — no rAF loop, no canvas, nothing on a hot path. The only animation is a FLIP on commit: card rects are measured in `useLayoutEffect` before and after, the delta is applied as an instant inverse transform and released on the next frame over 120ms `cubic-bezier(0.2,0,0,1)`. It never runs mid-drag (the lifted card is fixed and its neighbours have already collapsed) and is skipped entirely when `prefers-reduced-motion: reduce` matches, where reflow is instant and the placeholder alone carries the anticipation. THEME: every colour is a token (`accent`, `muted`, `foreground`, `surface`) — the frame is `text-ns-muted/55` (deliberately NOT `text-border`: `--border` is tuned for 1px hairlines and is invisible as type on the light theme, #ebebeb on #ffffff, which would leave the box frame blank at rest) and switches to `text-ns-accent` on overload, a card is `text-ns-muted` at rest and rises to `text-foreground` on a `bg-surface` chip on hover, so both themes invert correctly with no hardcoded ink anywhere.

Props

PropTypeDefaultDescription
columnsWipColumn[]—the board's columns, each with its own WIP limit
width?number34column width in monospace character cells (the frame tiles to exactly this)
onChange?(columns: WipColumn[]) => void—fires on every committed move, pointer or keyboard
onOverLimit?(columnId: string, over: number) => void—fires per commit for the receiving column: `over` is count - limit, 0 when within policy
className?string—extra classes merged onto the rendered root element
aria-label?string"Kanban board with WIP limits"accessible name for the board