Kanban board
A pipeline you rearrange by hand: the card becomes the stage it's over while still in the air, and every column's total rolls as it passes.
Install with the shadcn CLI
$ npxnpx shadcn@latest add https://hairlineui.com/r/kanban-board.jsonOverview
Most boards drag a floating copy of the card, snap a gap open somewhere and only update the counts after you drop. Here you carry the card itself. Pick it up and it follows you exactly, tilting a little with how fast you carry it and settling upright when you stop; the column under it eases open a gap at the exact slot while the one it left closes up. While it is still in the air its status chip turns into the stage it is over: the label morphs, the colour crossfades, and a finishing column draws a check. Each column's count and total roll as the card leaves one and enters the next, so a pipeline's money moves while you hold it. A column at its limit, or with a rule the card breaks, turns amber, says why and opens no gap; let go and the card springs home. Drops land with a slight overshoot, a failed save flies the card back with a shake, "+ Add deal" grows into a card you type into, columns fold to a strip, and Space, the arrows and Space carry a card by keyboard.
States
- pick up
- After 4px (or a 300ms hold on touch, with a short vibration where supported) the card lifts: 1.03 scale, a darker hairline, above everything. It follows the pointer one to one and tilts up to 5° with its sideways speed, eased by one frame loop that runs only while it's carried.
- gaps
- The column under the card opens a gap at the slot its centre is over (240ms, transforms only) and grows by a card; the column it left closes up behind it. Same column: the cards between the old and new slot make way.
- becomes
- Over another column the card's chip takes that column's name (TextMorph) and colour (300ms), and a done column draws a check; the column's border and ground take its tint. Counts and totals roll in both columns as if it had already moved.
- refuse
- A column at its limit, or whose refuse() returns a reason, turns amber, gives a short shake, says why under its title and opens no gap. Let go there and the card springs home (460ms, slight overshoot); the reason stays 3.2s.
- drop
- Everything on the board glides from where it is on screen to its new place; the carried card unwinds its tilt and lands with a slight overshoot. If onMove throws, it flies back the same way, shakes, and its column says it couldn't move.
- add
- "+ Add deal" fades as a ring and ground grow into a card-sized field by grid rows (380ms). Enter adds what onAdd returns; Escape or an empty field folds it back.
- collapse
- A column folds to a 48px strip with its count and a vertical title (width 420ms). It still takes a carried card, at its end.
Usage
import { KanbanBoard } from "@/components/kanban-board";<KanbanBoard columns={[{ id: "todo", title: "Todo" }, { id: "doing", title: "In progress" }, { id: "done", title: "Done", done: true }]} cards={issues} onCardsChange={setIssues} renderCard={(issue) => <p className="text-[13px]">{issue.title}</p>}/>Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.
Works with
A sales pipeline that saves to your CRM
Cards are { id, column, … } in the order they stand; the board hands back the new order on every move and calls onMove to save it. value totals each column, refuse keeps a rule (here: no amount, no win), and limit caps a column.
"use client";import { useState } from "react";import { KanbanBoard, type KanbanColumn } from "@/components/kanban-board";type Deal = { id: string; column: string; company: string; amount: number | null };const stages: KanbanColumn[] = [ { id: "lead", title: "Lead", tone: 4 }, { id: "qualified", title: "Qualified", tone: 1 }, { id: "proposal", title: "Proposal", tone: 5, limit: 6 }, { id: "won", title: "Won", tone: 3, done: true },];export function Pipeline({ initial }: { initial: Deal[] }) { const [deals, setDeals] = useState(initial); return ( <KanbanBoard columns={stages} cards={deals} onCardsChange={setDeals} cardLabel={(d) => d.company} value={(d) => d.amount} valueFormat={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }} refuse={(d, stage) => (stage.id === "won" && d.amount == null ? "Add an amount before it can be won" : null)} onMove={async (deal, stage, index) => { const res = await fetch(`/api/deals/${deal.id}`, { method: "PATCH", body: JSON.stringify({ stage: stage.id, index }) }); if (!res.ok) throw new Error("Not saved"); }} addLabel="deal" onAdd={async (stage, company) => { const res = await fetch("/api/deals", { method: "POST", body: JSON.stringify({ stage: stage.id, company }) }); return res.json(); }} renderCard={(d) => <p className="truncate text-[13.5px] font-medium">{d.company}</p>} /> );}Props
| Prop | Type | Description |
|---|---|---|
| columns | KanbanColumn[] | { id, title, tone?, limit?, done? }. tone picks one of the five chart colours for its chip; limit caps it; done draws a check on its chip. |
| cards / defaultCards | T extends { id, column }[] | The cards, in the order they stand in their columns. |
| onCardsChange | (cards: T[]) => void | The new order after every move, add and failed-save revert. |
| renderCard | (card, { column, lifted }) => ReactNode | The card's own content. The board adds the status chip (the column it's over while carried) and the value under it. |
| cardLabel | (card: T) => string | How a card is named in its accessible name and announcements. |
| refuse | (card, column) => string | null | A reason a column won't take a card; the column says it while the card is over it. Limits are checked for you. |
| onMove | (card, to, index) => Promise<void> | Saves a move (the card already has its new column). Throw to fly it back. |
| value | (card: T) => number | null | An amount per card, shown on it and totalled (rolling) in each column head. |
| valueFormat / locales | Intl.NumberFormatOptions / string | How amounts are written, e.g. { style: "currency", currency: "USD" }. |
| onAdd | (column, title) => T | Promise<T> | Shows "+ Add …" at the foot of each column; return the new card. |
| addLabel | stringdefault "card" | The noun in "+ Add …" and the new card's field. |
| label | stringdefault "Board" | Accessible name of the board. |
Accessibility and motion
- Each column is a labelled section with an ordered list; each card says its name, column, place and amount. Space or Enter picks a card up, arrows carry it (up and down in its column, left and right across columns, skipping none: a column that refuses says why), Space drops it and Escape puts it back. Every step is announced, and onMove runs once, on the drop.
- While a card is carried the board writes transforms only: the card's own (with its tilt) and the shift of the cards making way. React re-renders only when the column or slot under it changes, for the chip and the totals. On the drop every card is snapshotted where it is on screen, the new order is laid out, and each glides from its snapshot.
- On touch, the board scrolls as usual; a 300ms hold picks a card up and only then does the page stop scrolling under the finger. Near the board's sides it scrolls sideways, near the window's edges the page scrolls. Narrow, columns snap one at a time.
- Reduced motion moves cards in place, without tilt, glides or shakes; chips, totals, amber refusals and the announcements still say what happened.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.
The CLI also installs Number roll, Text morph.
Source
"use client";import * as React from "react";import { FoldHorizontal, Plus, UnfoldHorizontal } from "lucide-react";import { NumberRoll } from "./number-roll";import { TextMorph } from "./text-morph";import { useBoardDrag, type BoardOver } from "./use-board-drag";/* ───────────────────────────────────────────────────────── * KANBAN BOARD: a pipeline you rearrange by hand * * carry pick a card up (4px, or a hold on touch) and it * follows you, tilting with your speed; the column * under it opens a gap at the exact slot while the * one it left closes up * becomes its status chip turns into the column it's over * while it's still in the air: the label morphs, * the colour crossfades, a done column draws a check * totals the counts and the money in each column roll as * the card leaves one and enters the next * refuse a column at its limit, or with a rule the card * breaks, turns amber, says why and opens no gap; * let go and the card springs home * drop it lands in its slot with a slight overshoot; a * save that fails flies it back with a shake * add "+ Add deal" grows into a card you type into * keys Space picks up, arrows carry, Space drops, Escape * puts it back * ───────────────────────────────────────────────────────── */export interface KanbanColumn { id: string; title: string; /** Which of the theme's five chart colours its chip uses. */ tone?: 1 | 2 | 3 | 4 | 5; /** How many cards it holds; it refuses more. */ limit?: number; /** The finishing column: its chip draws a check. */ done?: boolean;}export type KanbanCard = { id: string; column: string };export interface KanbanBoardProps<T extends KanbanCard> extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "defaultValue"> { columns: KanbanColumn[]; cards?: T[]; defaultCards?: T[]; onCardsChange?: (cards: T[]) => void; /** The card's own content; the board adds the status chip and value under it. */ renderCard: (card: T, state: { column: KanbanColumn; lifted: boolean }) => React.ReactNode; /** How a card is named when announced. */ cardLabel?: (card: T) => string; /** A reason a column won't take a card, or nothing. Limits are checked for you. */ refuse?: (card: T, column: KanbanColumn) => string | null | undefined | void; /** A card moved. Throw to fly it back. */ onMove?: (card: T, to: KanbanColumn, index: number) => Promise<void> | void; /** Each card's amount: shown on the card and totalled in its column. */ value?: (card: T) => number | null | undefined; valueFormat?: Intl.NumberFormatOptions; locales?: string; /** Shows "+ Add …" at the foot of each column. Return the new card (or a promise of it). */ onAdd?: (column: KanbanColumn, title: string) => T | void | Promise<T | void>; /** The noun in "+ Add …". */ addLabel?: string; label?: string;}type Snapshot = Map<string, { x: number; y: number; transform: string }>;const EASE = "cubic-bezier(0.16,1,0.3,1)";const THROW = "cubic-bezier(0.34,1.36,0.64,1)";const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const COL_W = 268;const COLLAPSED_W = 48;const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => { const query = window.matchMedia(reducedQuery); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange);};const useReducedMotion = () => React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);const tint = (tone: number, amount: number) => `color-mix(in oklab, var(--chart-${tone}) ${amount}%, var(--background))`;const ink = (tone: number) => `color-mix(in oklab, var(--chart-${tone}) 72%, var(--foreground))`;/** The card's status: the column it's in, or (while carried) the column it's over. */function Chip({ column, reduced }: { column: KanbanColumn; reduced: boolean }) { const tone = column.tone ?? 1; const t = reduced ? "none" : `color 300ms ${EASE}, background-color 300ms ${EASE}, box-shadow 300ms ${EASE}`; return ( <span className="inline-flex h-5 items-center gap-1 rounded-full px-1.5 text-[11px] font-medium" style={{ color: ink(tone), background: tint(tone, 12), boxShadow: `inset 0 0 0 1px color-mix(in oklab, var(--chart-${tone}) 30%, transparent)`, transition: t }} > <span className="relative flex size-3 items-center justify-center" aria-hidden="true"> <span className="absolute size-1.5 rounded-full bg-current" style={{ opacity: column.done ? 0 : 1, transform: column.done ? "scale(0.4)" : "none", transition: t === "none" ? t : `opacity 200ms ${EASE}, transform 300ms ${EASE}` }} /> <svg viewBox="0 0 16 16" className="absolute size-3" fill="none"> <path d="M3.5 8.5 6.5 11.5 12.5 4.5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" pathLength={1} strokeDasharray={1} style={{ strokeDashoffset: column.done ? 0 : 1, transition: reduced ? "none" : `stroke-dashoffset 380ms ${EASE} ${column.done ? 80 : 0}ms` }} /> </svg> </span> <TextMorph>{column.title}</TextMorph> </span> );}/** A sentence that changes crossfades whole. */function Line({ text, className = "" }: { text: string; className?: string }) { const [shown, setShown] = React.useState({ text, key: 0 }); const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]); if (text !== shown.text) { if (shown.text) setLeaving((l) => [...l, shown]); setShown({ text, key: shown.key + 1 }); } return ( <span className={`relative block ${className}`}> <span key={shown.key} className="block" ref={(el) => { if (!el || shown.key === 0 || el.dataset.in) return; el.dataset.in = "1"; el.animate( [ { opacity: 0, transform: "translateY(0.35em)", filter: "blur(4px)" }, { opacity: 1, transform: "none", filter: "blur(0px)" }, ], { duration: 380, delay: 60, easing: EASE, fill: "backwards" }, ); }} > {shown.text || " "} </span> {leaving.map((l) => ( <span key={l.key} aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 block" ref={(el) => { if (!el || el.dataset.out) return; el.dataset.out = "1"; el.animate( [ { opacity: 1, filter: "blur(0px)" }, { opacity: 0, filter: "blur(4px)" }, ], { duration: 180, easing: EASE, fill: "forwards" }, ).onfinish = () => setLeaving((all) => all.filter((x) => x.key !== l.key)); }} > {l.text} </span> ))} </span> );}export function KanbanBoard<T extends KanbanCard>({ columns, cards: controlled, defaultCards, onCardsChange, renderCard, cardLabel = (c) => c.id, refuse, onMove, value, valueFormat, locales, onAdd, addLabel = "card", label = "Board", className = "", ...props}: KanbanBoardProps<T>) { const reduced = useReducedMotion(); const uid = React.useId(); const [own, setOwn] = React.useState<T[]>(defaultCards ?? []); const cards = controlled ?? own; const cardsRef = React.useRef(cards); cardsRef.current = cards; const [over, setOver] = React.useState<BoardOver | null>(null); const [collapsed, setCollapsed] = React.useState<ReadonlySet<string>>(() => new Set()); const [adding, setAdding] = React.useState<string | null>(null); const [keyLift, setKeyLift] = React.useState<{ id: string; column: string; index: number } | null>(null); const [notes, setNotes] = React.useState<Record<string, { text: string; tone: "warn" | "error"; key: number }>>({}); const [announce, setAnnounce] = React.useState(""); const [flipTick, setFlipTick] = React.useState(0); const boardRef = React.useRef<HTMLDivElement>(null); const flip = React.useRef<{ before: Snapshot; lifted?: string } | null>(null); const say = (text: string) => setAnnounce((a) => (a === text ? `${text} ` : text)); const commit = (next: T[]) => { cardsRef.current = next; if (controlled === undefined) setOwn(next); onCardsChange?.(next); }; const note = (column: string, text: string, tone: "warn" | "error") => { const key = Date.now(); setNotes((n) => ({ ...n, [column]: { text, tone, key } })); window.setTimeout(() => setNotes((n) => (n[column]?.key === key ? (({ [column]: _, ...rest }) => rest)(n) : n)), 3200); }; const colOf = (id: string) => columns.find((c) => c.id === id)!; const inColumn = (list: T[], column: string) => list.filter((c) => c.column === column); const fmt = React.useMemo(() => new Intl.NumberFormat(locales, valueFormat), [locales, valueFormat && JSON.stringify(valueFormat)]); // eslint-disable-line react-hooks/exhaustive-deps /** Why `card` can't go into `column`: the limit, then the caller's rule. */ const reason = (card: T, column: KanbanColumn) => { if (card.column === column.id) return null; if (column.limit !== undefined && inColumn(cardsRef.current, column.id).length >= column.limit) return `${column.title} holds ${column.limit} at most`; return (refuse?.(card, column) as string | null | undefined) || null; }; /** Moves a card in the list: out of where it is, into column at index (among that column's cards). */ const placed = (list: T[], id: string, column: string, index: number) => { const card = list.find((c) => c.id === id)!; const rest = list.filter((c) => c.id !== id); const moved = { ...card, column } as T; const peers = rest.filter((c) => c.column === column); if (index >= peers.length) { const last = peers[peers.length - 1]; const at = last ? rest.indexOf(last) + 1 : rest.length; return [...rest.slice(0, at), moved, ...rest.slice(at)]; } const at = rest.indexOf(peers[index]); return [...rest.slice(0, at), moved, ...rest.slice(at)]; }; // ── FLIP after any change of place ───────────────────── const engine = useBoardDrag({ board: boardRef, refuse: (id, column) => { if (collapsed.has(column)) return null; const card = cardsRef.current.find((c) => c.id === id); return card ? reason(card, colOf(column)) : null; }, onOver: setOver, onDrop: (id, column, index) => { const card = cardsRef.current.find((c) => c.id === id); if (!card) return; move(card, column, index, true); }, onCancel: (id, refused, column) => { if (!refused) return; // The reason stays a moment after the card springs home. if (column) note(column, refused, "warn"); const card = cardsRef.current.find((c) => c.id === id); say(`${card ? cardLabel(card) : "The card"} went back. ${refused}.`); }, reduced, disabled: !!keyLift || !!adding, }); const play = React.useCallback(() => { const f = flip.current; const board = boardRef.current; if (!f || !board) return; flip.current = null; engine.clear(); if (reduced) return; board.querySelectorAll<HTMLElement>("[data-card-id]").forEach((el) => { const id = el.dataset.cardId!; const was = f.before.get(id); if (!was) return; const r = el.getBoundingClientRect(); const dx = was.x - (r.left + r.width / 2); const dy = was.y - (r.top + r.height / 2); const tilt = Number(/rotate\(([-\d.]+)deg\)/.exec(was.transform)?.[1] ?? 0); const lifted = !!was.transform; if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5 && !lifted) return; el.animate( [{ transform: `translate(${dx}px, ${dy}px) rotate(${tilt}deg) scale(${lifted ? 1.03 : 1})`, zIndex: lifted ? 50 : 1 }, { transform: "none", zIndex: lifted ? 50 : 1 }], { duration: lifted ? 460 : 380, easing: lifted ? THROW : EASE }, ); }); }, [engine, reduced]); React.useLayoutEffect(() => { play(); }, [flipTick, cards, play]); /** Commits a move (optimistic), FLIPs everything into place, then saves; a failed save flies it back. */ const move = async (card: T, column: string, index: number, save: boolean) => { const before = engine.snapshot(); const previous = cardsRef.current; const next = placed(previous, card.id, column, index); flip.current = { before }; commit(next); setFlipTick((t) => t + 1); const to = colOf(column); const position = inColumn(next, column).findIndex((c) => c.id === card.id) + 1; const of = `${position} of ${inColumn(next, column).length}`; say(save ? `Moved ${cardLabel(card)} to ${to.title}, ${of}.` : `${cardLabel(card)}, ${to.title}, ${of}.`); if (!save || !onMove) return; try { await onMove({ ...card, column } as T, to, position - 1); } catch { // Back where it came from, from wherever it is now. flip.current = { before: engine.snapshot() }; const restored = placed(cardsRef.current, card.id, card.column, inColumn(previous, card.column).findIndex((c) => c.id === card.id)); commit(restored); setFlipTick((t) => t + 1); note(card.column, `Couldn't move ${cardLabel(card)}. It's back.`, "error"); say(`Couldn't move ${cardLabel(card)}. It's back in ${colOf(card.column).title}.`); window.setTimeout(() => { if (reduced) return; boardRef.current?.querySelector<HTMLElement>(`[data-card-id="${CSS.escape(card.id)}"] > div`)?.animate( [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }], { duration: 380, easing: "ease-out" }, ); }, 420); } }; // ── Keys: Space picks up, arrows carry, Space drops, Escape puts back ── const onCardKey = (e: React.KeyboardEvent, card: T) => { const list = inColumn(cardsRef.current, card.column); const index = list.findIndex((c) => c.id === card.id); if (e.key === " " || e.key === "Enter") { e.preventDefault(); if (keyLift?.id === card.id) { const from = keyLift; setKeyLift(null); if (from.column !== card.column || from.index !== index) { // The carrying already showed the move; this saves it. const original = { ...card, column: from.column } as T; say(`Dropped ${cardLabel(card)} in ${colOf(card.column).title}, ${index + 1} of ${list.length}.`); if (onMove) Promise.resolve() .then(() => onMove(card, colOf(card.column), index)) .catch(() => { flip.current = { before: engine.snapshot() }; commit(placed(cardsRef.current, card.id, from.column, from.index)); setFlipTick((t) => t + 1); note(from.column, `Couldn't move ${cardLabel(original)}. It's back.`, "error"); say(`Couldn't move ${cardLabel(original)}. It's back.`); }); } else say(`Dropped ${cardLabel(card)} where it was.`); } else { setKeyLift({ id: card.id, column: card.column, index }); say(`Picked up ${cardLabel(card)}, ${colOf(card.column).title}, ${index + 1} of ${list.length}. Arrow keys carry it, Space drops it, Escape puts it back.`); } return; } if (keyLift?.id !== card.id) return; const ci = columns.findIndex((c) => c.id === card.column); if (e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); const to = index + (e.key === "ArrowUp" ? -1 : 1); if (to < 0 || to >= list.length) return; move(card, card.column, to, false); } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { e.preventDefault(); const dir = e.key === "ArrowLeft" ? -1 : 1; for (let j = ci + dir; j >= 0 && j < columns.length; j += dir) { const col = columns[j]; const why = reason(card, col); if (why) { note(col.id, why, "warn"); say(`${col.title} won't take it: ${why}.`); return; } move(card, col.id, Math.min(index, inColumn(cardsRef.current, col.id).length), false); return; } } else if (e.key === "Escape") { e.preventDefault(); setKeyLift(null); move(card, keyLift.column, keyLift.index, false); say(`Put ${cardLabel(card)} back.`); } }; // Keep focus on the carried card as it moves between columns (it's a new element there), before anything paints. React.useLayoutEffect(() => { if (!keyLift) return; boardRef.current?.querySelector<HTMLElement>(`[data-card-id="${CSS.escape(keyLift.id)}"]`)?.focus({ preventScroll: false }); }, [cards, keyLift]); // ── Adding ───────────────────────────────────────────── const add = async (column: KanbanColumn, title: string) => { setAdding(null); if (!title.trim() || !onAdd) return; try { const made = await onAdd(column, title.trim()); if (made) { commit([...cardsRef.current, made]); say(`Added ${cardLabel(made)} to ${column.title}.`); } } catch { note(column.id, `Couldn't add “${title.trim()}”.`, "error"); } }; // ── Render ───────────────────────────────────────────── // While a card is carried, it counts in the column it's over (unless that column refuses it). const carried = over && !over.refused ? cards.find((c) => c.id === over.card) : undefined; const effective = (c: T) => (carried && c.id === carried.id ? over!.column : c.column); const total = (column: string) => cards.filter((c) => effective(c) === column).reduce((sum, c) => sum + (value?.(c) ?? 0), 0); const count = (column: string) => cards.filter((c) => effective(c) === column).length; return ( <div className={`@container w-full text-foreground ${className}`} {...props}> <div ref={boardRef} role="region" aria-label={label} aria-describedby={`${uid}-how`} {...engine.handlers} className="-mx-1 overflow-x-auto overscroll-x-contain px-1 pb-3 [scrollbar-width:thin] @max-[40rem]:snap-x @max-[40rem]:snap-mandatory" > <div className="flex w-max items-start gap-3"> {columns.map((column) => { const tone = column.tone ?? 1; const list = inColumn(cards, column.id); const isCollapsed = collapsed.has(column.id); const n = count(column.id); const full = column.limit !== undefined && n >= column.limit; const refusedHere = over?.refused && over.column === column.id; const hereNote = notes[column.id]; const showNote = refusedHere ? { text: over!.refused!, tone: "warn" as const } : hereNote; return ( <section key={column.id} data-column-id={column.id} aria-label={`${column.title}, ${n} ${n === 1 ? "card" : "cards"}${value ? `, ${fmt.format(total(column.id))}` : ""}`} className={`shrink-0 snap-start rounded-[18px] border transition-[width,border-color,background-color] duration-[420ms] ${isCollapsed ? "overflow-hidden" : "@max-[40rem]:!w-[min(82cqw,300px)]"}`} style={{ width: isCollapsed ? COLLAPSED_W : COL_W, transitionTimingFunction: EASE, borderColor: refusedHere ? "color-mix(in oklab, #f59e0b 55%, var(--border))" : over && !over.refused && over.column === column.id ? `color-mix(in oklab, var(--chart-${tone}) 45%, var(--border))` : "var(--border)", background: over && !over.refused && over.column === column.id ? tint(tone, 4) : "color-mix(in oklab, var(--muted) 45%, var(--background))", }} > {/* Head: title, count (of the limit), total; it gives a shake when it refuses a card. */} <header data-column-head className={`flex h-11 items-center gap-2 pl-3 pr-1.5 ${isCollapsed ? "flex-col justify-start !h-auto gap-3 px-0 py-3" : ""}`}> {isCollapsed ? ( <> <button type="button" aria-label={`Open ${column.title}`} aria-expanded={false} onClick={() => setCollapsed((s) => new Set([...s].filter((x) => x !== column.id)))} className={`flex size-7 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${FOCUS}`}> <UnfoldHorizontal className="size-3.5" strokeWidth={1.75} /> </button> <span className="text-[12px] tabular-nums text-muted-foreground"> <NumberRoll value={n} /> </span> <span className="text-[13px] font-medium [writing-mode:vertical-rl]">{column.title}</span> </> ) : ( <> <span className="size-2 shrink-0 rounded-full" style={{ background: `var(--chart-${tone})` }} aria-hidden="true" /> <h3 className="min-w-0 truncate text-[13.5px] font-medium tracking-[-0.01em]">{column.title}</h3> <span className={`inline-flex h-5 shrink-0 items-center rounded-full px-1.5 text-[11.5px] tabular-nums transition-colors duration-300 ${full ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground"}`} style={{ boxShadow: "inset 0 0 0 1px var(--border)" }} > <NumberRoll value={n} /> {column.limit !== undefined && <span>/{column.limit}</span>} </span> {value && ( <span className="ml-auto truncate text-[12px] tabular-nums text-muted-foreground"> <NumberRoll value={total(column.id)} format={valueFormat} locales={locales} duration={800} /> </span> )} <button type="button" aria-label={`Collapse ${column.title}`} aria-expanded onClick={() => setCollapsed((s) => new Set([...s, column.id]))} className={`flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${value ? "" : "ml-auto"} ${FOCUS}`} > <FoldHorizontal className="size-3.5" strokeWidth={1.75} /> </button> </> )} </header> {!isCollapsed && ( <div className="grid transition-[grid-template-rows] duration-300" style={{ gridTemplateRows: showNote ? "1fr" : "0fr", transitionTimingFunction: EASE }}> <div className="min-h-0 overflow-hidden"> <Line text={showNote?.text ?? ""} className={`px-3 pb-2 text-[12px] ${showNote?.tone === "error" ? "text-red-600 dark:text-red-400" : "text-amber-700 dark:text-amber-400"}`} /> </div> </div> )} <ol data-column-list data-collapsed={isCollapsed || undefined} aria-label={column.title} className={`relative flex min-h-12 flex-col gap-2 px-2 pb-2 ${isCollapsed ? "hidden" : ""}`} > {list.map((card) => { const lifted = keyLift?.id === card.id; const shownColumn = colOf(over?.card === card.id && !over.refused ? over.column : card.column); const position = list.indexOf(card) + 1; const amount = value?.(card); return ( <li key={card.id} data-card-id={card.id} tabIndex={0} aria-roledescription="draggable card" aria-label={`${cardLabel(card)}, ${column.title}, ${position} of ${list.length}${amount != null ? `, ${fmt.format(amount)}` : ""}`} aria-describedby={`${uid}-keys`} aria-pressed={lifted || undefined} onKeyDown={(e) => onCardKey(e, card)} onBlur={() => { // A carried card moving column is a new element for a moment: only a real blur drops the carry. if (keyLift?.id !== card.id) return; requestAnimationFrame(() => !boardRef.current?.contains(document.activeElement) && setKeyLift(null)); }} className={`group/card relative cursor-grab select-none rounded-[12px] outline-none data-[lifted]:cursor-grabbing ${FOCUS}`} > <div className={`rounded-[12px] bg-background p-3 transition-[box-shadow] duration-200 ${ lifted ? "shadow-[inset_0_0_0_1.5px_var(--ring)]" : "shadow-[inset_0_0_0_1px_var(--border)] group-data-[lifted]/card:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_34%,transparent)]" }`} > {renderCard(card, { column: shownColumn, lifted })} <div className="mt-2.5 flex items-center justify-between gap-2"> <Chip column={shownColumn} reduced={reduced} /> {amount != null && <span className="text-[12px] tabular-nums text-muted-foreground">{fmt.format(amount)}</span>} </div> </div> </li> ); })} </ol> {/* "+ Add …" grows into a card you type into, and folds back. */} {onAdd && !isCollapsed && ( <div className="px-2 pb-2"> <div className="grid rounded-[12px] transition-[background-color,box-shadow] duration-300" style={{ background: adding === column.id ? "var(--background)" : "transparent", boxShadow: adding === column.id ? "inset 0 0 0 1px var(--ring)" : "inset 0 0 0 1px transparent" }} > <button type="button" onClick={() => setAdding(column.id)} className={`col-start-1 row-start-1 flex h-9 cursor-pointer items-center gap-1.5 rounded-[12px] px-2.5 text-[12.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`} style={{ opacity: adding === column.id ? 0 : 1, pointerEvents: adding === column.id ? "none" : undefined, transition: reduced ? "none" : `opacity 200ms ${EASE}` }} tabIndex={adding === column.id ? -1 : 0} > <Plus className="size-3.5" strokeWidth={1.75} aria-hidden="true" /> Add {addLabel} </button> <div className="col-start-1 row-start-1 grid transition-[grid-template-rows] duration-[380ms]" style={{ gridTemplateRows: adding === column.id ? "1fr" : "0fr", transitionTimingFunction: EASE, pointerEvents: adding === column.id ? undefined : "none" }} > <div className="min-h-0 overflow-hidden"> {adding === column.id && ( <input autoFocus placeholder={`New ${addLabel}`} aria-label={`New ${addLabel} in ${column.title}`} onKeyDown={(e) => { if (e.key === "Enter") add(column, e.currentTarget.value); if (e.key === "Escape") setAdding(null); }} onBlur={(e) => add(column, e.currentTarget.value)} className="h-[60px] w-full bg-transparent px-3 text-[13px] outline-none placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]" /> )} </div> </div> </div> </div> )} </section> ); })} </div> </div> <p id={`${uid}-how`} className="sr-only"> Drag a card to another column or place. On a touch screen, hold it first. </p> <p id={`${uid}-keys`} className="sr-only"> Space picks the card up, arrow keys carry it within and across columns, Space drops it, Escape puts it back. </p> <p className="sr-only" role="status" aria-live="polite"> {announce} </p> </div> );}"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * BOARD DRAG: a card you pick up and carry across columns * * lift after 4px (or a hold on touch) the card itself * follows the pointer exactly, a touch larger, and * tilts with how fast you're carrying it sideways, * settling upright when you stop * gaps the column under it eases open a gap at the exact * slot while the one it left closes its own; all * transforms, nothing re-laid out per move * refuse a column that won't take the card (a limit, a * rule) opens no gap; let go and it springs home * scroll near the board's sides it scrolls sideways, near * the window's top or bottom the page scrolls * * The hook reports the column and slot only when they change; * the component re-renders for those (the card's status chip, * the column totals) and runs its own FLIP on drop. Elements: * data-column-id (a column), data-column-list (its list), * data-card-id (a card's list item). * ───────────────────────────────────────────────────────── */export type BoardOver = { card: string; column: string; index: number; refused: string | null };type Options = { board: React.RefObject<HTMLElement | null>; /** Why a card can't go in a column, or null. */ refuse: (card: string, column: string) => string | null; onOver: (over: BoardOver | null) => void; /** Dropped on a slot. The card's tilt and lift are on its element still; FLIP from there. */ onDrop: (card: string, column: string, index: number) => void; /** Dropped where it can't go (and which column refused it), or not moved: it's sprung home already. */ onCancel: (card: string, refused: string | null, column: string | null) => void; reduced: boolean; disabled?: boolean;};type ColumnBox = { id: string; left: number; right: number; listTop: number; list: HTMLElement; collapsed: boolean; cards: { id: string; el: HTMLElement; top: number; height: number }[] };const EASE = "cubic-bezier(0.16,1,0.3,1)";const THROW = "cubic-bezier(0.34,1.36,0.64,1)";const GAP = 8;const HOLD = 300;const EDGE = 48;export function useBoardDrag(o: Options) { const opts = React.useRef(o); opts.current = o; const drag = React.useRef<{ id: string; pointerId: number; pointerType: string; el: HTMLElement; from: string; fromIndex: number; x0: number; y0: number; x: number; y: number; scroll0: number; page0: number; gap: number; columns: ColumnBox[]; over: { column: string; index: number; refused: string | null } | null; active: boolean; timer: number; vx: number; lastX: number; lastT: number; tilt: number; } | null>(null); const loop = React.useRef(0); const write = () => { const d = drag.current; if (!d) return; const dx = d.x - d.x0 + ((opts.current.board.current?.scrollLeft ?? 0) - d.scroll0); const dy = d.y - d.y0 + (window.scrollY - d.page0); d.el.style.transform = `translate3d(${dx}px, ${dy}px, 0) rotate(${d.tilt}deg) scale(1.03)`; }; /** One loop while carrying: eases the tilt toward the carrying speed and back to upright when still. */ const run = () => { cancelAnimationFrame(loop.current); const tick = () => { const d = drag.current; if (!d?.active) return; const now = performance.now(); if (now - d.lastT > 60) d.vx *= 0.8; const target = opts.current.reduced ? 0 : Math.max(-5, Math.min(5, d.vx * 3.2)); d.tilt += (target - d.tilt) * 0.18; write(); autoScroll(); loop.current = requestAnimationFrame(tick); }; loop.current = requestAnimationFrame(tick); }; const measure = (skip: string): ColumnBox[] => { const board = opts.current.board.current; if (!board) return []; return [...board.querySelectorAll<HTMLElement>("[data-column-id]")].map((col) => { const r = col.getBoundingClientRect(); const list = col.querySelector<HTMLElement>("[data-column-list]")!; const lr = list.getBoundingClientRect(); return { id: col.dataset.columnId!, left: r.left, right: r.right, listTop: lr.top, list, collapsed: list.hasAttribute("data-collapsed"), cards: [...list.querySelectorAll<HTMLElement>(":scope > [data-card-id]")] .filter((el) => el.dataset.cardId !== skip) .map((el) => ({ id: el.dataset.cardId!, el, top: el.offsetTop, height: el.offsetHeight })), }; }); }; /** Which column and slot the card's centre is over (screen positions, adjusted for any scrolling since pickup). */ const locate = () => { const d = drag.current!; const sx = (opts.current.board.current?.scrollLeft ?? 0) - d.scroll0; const sy = window.scrollY - d.page0; const cols = d.columns; let col = cols.find((c) => d.x >= c.left - sx && d.x <= c.right - sx); if (!col) col = cols.reduce((best, c) => (Math.abs((c.left + c.right) / 2 - sx - d.x) < Math.abs((best.left + best.right) / 2 - sx - d.x) ? c : best), cols[0]); // The carried card's centre (its laid-out centre plus how far it's been carried), in the target list's coordinates. const home = d.columns.find((c) => c.id === d.from)!; const cardCentre = d.el.offsetTop + d.el.offsetHeight / 2 + (d.y - d.y0) + sy + (home.listTop - col.listTop); // A collapsed column shows no cards: it takes the card at its end. if (col.collapsed) return { column: col.id, index: col.cards.length }; let index = 0; for (const c of col.cards) if (cardCentre > c.top + c.height / 2) index++; return { column: col.id, index }; }; const shift = (el: HTMLElement, y: number) => { el.style.transition = opts.current.reduced ? "none" : `transform 240ms ${EASE}`; el.style.transform = y ? `translate3d(0, ${y}px, 0)` : ""; }; /** Open the gap where the card would land and close the one it left. */ const arrange = () => { const d = drag.current!; const { column, index } = locate(); const refused = column === d.from ? null : opts.current.refuse(d.id, column); if (d.over && d.over.column === column && d.over.index === index && d.over.refused === refused) return; if ((!d.over || d.over.column !== column) && refused) { // Entering a column that won't take it: its head says no once. const head = d.columns.find((c) => c.id === column)?.list.parentElement?.querySelector<HTMLElement>("[data-column-head]"); if (head && !opts.current.reduced) head.animate([{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }], { duration: 380, easing: "ease-out" }); } d.over = { column, index, refused }; for (const c of d.columns) { const lands = !refused && c.id === column; const left = c.id === d.from; c.cards.forEach((card, i) => { let y = 0; if (left && !(lands && column === d.from)) { // The column it left closes up behind it. if (i >= d.fromIndex) y = -d.gap; } if (lands) { if (column === d.from) { // Same column: the cards between the old and new slot make way. if (index > d.fromIndex && i >= d.fromIndex && i < index) y = -d.gap; if (index < d.fromIndex && i >= index && i < d.fromIndex) y = d.gap; } else if (i >= index) y = d.gap; } if (card.el.style.transform !== (y ? `translate3d(0, ${y}px, 0)` : "")) shift(card.el, y); }); // The column it would land in grows by a card; the one it left keeps its height until the drop. c.list.style.transition = opts.current.reduced ? "none" : `padding-bottom 240ms ${EASE}`; c.list.style.paddingBottom = lands && column !== d.from ? `${d.gap}px` : ""; } opts.current.onOver({ card: d.id, column, index: refused ? -1 : index, refused }); }; const autoScroll = () => { const d = drag.current; const board = opts.current.board.current; if (!d?.active || !board) return; const r = board.getBoundingClientRect(); const sideways = d.x < r.left + EDGE ? -(r.left + EDGE - d.x) / EDGE : d.x > r.right - EDGE ? (d.x - (r.right - EDGE)) / EDGE : 0; const upDown = d.y < EDGE ? -(EDGE - d.y) / EDGE : d.y > window.innerHeight - EDGE ? (d.y - (window.innerHeight - EDGE)) / EDGE : 0; let moved = false; if (sideways) { const before = board.scrollLeft; board.scrollLeft += sideways * 14; moved ||= board.scrollLeft !== before; } if (upDown) { const before = window.scrollY; window.scrollBy(0, upDown * 12); moved ||= window.scrollY !== before; } if (moved) arrange(); }; /** Puts everything back where it was laid out (the card springs home unless asked not to). */ const release = (home: boolean) => { const d = drag.current; if (!d) return; for (const c of d.columns) { c.cards.forEach((card) => shift(card.el, 0)); c.list.style.paddingBottom = ""; } if (home) { const from = d.el.style.transform; d.el.style.transform = ""; if (from && !opts.current.reduced) d.el.animate([{ transform: from }, { transform: "none" }], { duration: 460, easing: THROW }); } d.el.style.zIndex = ""; d.el.style.willChange = ""; d.el.removeAttribute("data-lifted"); }; const activate = () => { const d = drag.current; if (!d || d.active) return; d.active = true; if (d.pointerType === "touch") navigator.vibrate?.(8); d.columns = measure(d.id); d.gap = d.el.offsetHeight + GAP; d.el.style.transition = "none"; d.el.style.zIndex = "50"; d.el.style.willChange = "transform"; // Snapping would pull every small auto-scroll step straight back; it's off while a card is carried. const board = opts.current.board.current; if (board) board.style.scrollSnapType = "none"; d.el.setAttribute("data-lifted", ""); arrange(); run(); }; const end = (dropped: boolean) => { const d = drag.current; if (!d) return; window.clearTimeout(d.timer); cancelAnimationFrame(loop.current); const board = opts.current.board.current; if (board) board.style.scrollSnapType = ""; if (!d.active) { drag.current = null; return; } const over = d.over; const moved = over && (over.column !== d.from || over.index !== d.fromIndex); if (dropped && over && !over.refused && moved) { // The component re-lays the board out and runs the FLIP from where everything is now. opts.current.onDrop(d.id, over.column, over.index); drag.current = null; opts.current.onOver(null); return; } release(true); drag.current = null; opts.current.onOver(null); opts.current.onCancel(d.id, dropped && over?.refused ? over.refused : null, over?.column ?? null); }; const onPointerDown = (e: React.PointerEvent<HTMLElement>) => { if (opts.current.disabled || drag.current || (e.pointerType === "mouse" && e.button !== 0)) return; const target = e.target as HTMLElement; if (target.closest("button, input, a, textarea, [data-no-drag]")) return; const el = target.closest<HTMLElement>("[data-card-id]"); if (!el) return; const column = el.closest<HTMLElement>("[data-column-id]")!.dataset.columnId!; const siblings = [...el.parentElement!.querySelectorAll<HTMLElement>(":scope > [data-card-id]")]; drag.current = { id: el.dataset.cardId!, pointerId: e.pointerId, pointerType: e.pointerType, el, from: column, fromIndex: siblings.indexOf(el), x0: e.clientX, y0: e.clientY, x: e.clientX, y: e.clientY, scroll0: opts.current.board.current?.scrollLeft ?? 0, page0: window.scrollY, gap: 0, columns: [], over: null, active: false, timer: 0, vx: 0, lastX: e.clientX, lastT: performance.now(), tilt: 0, }; if (e.pointerType === "touch") { drag.current.timer = window.setTimeout(activate, HOLD); } else { e.currentTarget.setPointerCapture(e.pointerId); } }; const onPointerMove = (e: React.PointerEvent<HTMLElement>) => { const d = drag.current; if (!d || d.pointerId !== e.pointerId) return; if (!d.active) { const moved = Math.hypot(e.clientX - d.x0, e.clientY - d.y0); if (d.pointerType === "touch") { if (moved > 8) end(false); return; } if (moved < 4) return; activate(); } const now = performance.now(); const dt = Math.max(1, now - d.lastT); d.vx = d.vx * 0.6 + ((e.clientX - d.lastX) / dt) * 0.4; d.lastX = e.clientX; d.lastT = now; d.x = e.clientX; d.y = e.clientY; arrange(); }; const onPointerUp = (e: React.PointerEvent<HTMLElement>) => { if (drag.current?.pointerId === e.pointerId) end(true); }; const onPointerCancel = (e: React.PointerEvent<HTMLElement>) => { if (drag.current?.pointerId === e.pointerId) end(false); }; // A held card on touch: the page mustn't scroll under it, and no context menu. React.useEffect(() => { const el = o.board.current; if (!el) return; const touchMove = (e: TouchEvent) => { if (drag.current?.active && e.cancelable) e.preventDefault(); }; const menu = (e: Event) => { if (drag.current) e.preventDefault(); }; el.addEventListener("touchmove", touchMove, { passive: false }); el.addEventListener("contextmenu", menu); return () => { el.removeEventListener("touchmove", touchMove); el.removeEventListener("contextmenu", menu); }; }, [o.board]); React.useEffect(() => () => cancelAnimationFrame(loop.current), []); return { handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, /** Where every card is on screen right now (lifted and shifted ones included), for a FLIP after the drop. */ snapshot: () => { const out = new Map<string, { x: number; y: number; transform: string }>(); opts.current.board.current?.querySelectorAll<HTMLElement>("[data-card-id]").forEach((el) => { const r = el.getBoundingClientRect(); out.set(el.dataset.cardId!, { x: r.left + r.width / 2, y: r.top + r.height / 2, transform: el.hasAttribute("data-lifted") ? el.style.transform : "" }); }); return out; }, /** Clears what a drag left on the elements (shifts, the lift, list padding) so the new layout can be measured. */ clear: () => { opts.current.board.current?.querySelectorAll<HTMLElement>("[data-card-id]").forEach((el) => { el.style.transition = "none"; el.style.transform = ""; el.style.zIndex = ""; el.style.willChange = ""; el.removeAttribute("data-lifted"); }); opts.current.board.current?.querySelectorAll<HTMLElement>("[data-column-list]").forEach((el) => { el.style.transition = "none"; el.style.paddingBottom = ""; }); }, };}Registry item: /r/kanban-board.json