{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"kanban-board","type":"registry:component","title":"Kanban board","description":"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.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/kanban-board.tsx","type":"registry:component","content":"// Hairline UI · kanban-board · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { FoldHorizontal, Plus, UnfoldHorizontal } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\nimport { useBoardDrag, type BoardOver } from \"./use-board-drag\";\n\n/* ─────────────────────────────────────────────────────────\n * KANBAN BOARD: a pipeline you rearrange by hand\n *\n *   carry     pick a card up (4px, or a hold on touch) and it\n *             follows you, tilting with your speed; the column\n *             under it opens a gap at the exact slot while the\n *             one it left closes up\n *   becomes   its status chip turns into the column it's over\n *             while it's still in the air: the label morphs,\n *             the colour crossfades, a done column draws a check\n *   totals    the counts and the money in each column roll as\n *             the card leaves one and enters the next\n *   refuse    a column at its limit, or with a rule the card\n *             breaks, turns amber, says why and opens no gap;\n *             let go and the card springs home\n *   drop      it lands in its slot with a slight overshoot; a\n *             save that fails flies it back with a shake\n *   add       \"+ Add deal\" grows into a card you type into\n *   keys      Space picks up, arrows carry, Space drops, Escape\n *             puts it back\n * ───────────────────────────────────────────────────────── */\n\nexport interface KanbanColumn {\n  id: string;\n  title: string;\n  /** Which of the theme's five chart colours its chip uses. */\n  tone?: 1 | 2 | 3 | 4 | 5;\n  /** How many cards it holds; it refuses more. */\n  limit?: number;\n  /** The finishing column: its chip draws a check. */\n  done?: boolean;\n}\n\nexport type KanbanCard = { id: string; column: string };\n\nexport interface KanbanBoardProps<T extends KanbanCard> extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\" | \"defaultValue\"> {\n  columns: KanbanColumn[];\n  cards?: T[];\n  defaultCards?: T[];\n  onCardsChange?: (cards: T[]) => void;\n  /** The card's own content; the board adds the status chip and value under it. */\n  renderCard: (card: T, state: { column: KanbanColumn; lifted: boolean }) => React.ReactNode;\n  /** How a card is named when announced. */\n  cardLabel?: (card: T) => string;\n  /** A reason a column won't take a card, or nothing. Limits are checked for you. */\n  refuse?: (card: T, column: KanbanColumn) => string | null | undefined | void;\n  /** A card moved. Throw to fly it back. */\n  onMove?: (card: T, to: KanbanColumn, index: number) => Promise<void> | void;\n  /** Each card's amount: shown on the card and totalled in its column. */\n  value?: (card: T) => number | null | undefined;\n  valueFormat?: Intl.NumberFormatOptions;\n  locales?: string;\n  /** Shows \"+ Add …\" at the foot of each column. Return the new card (or a promise of it). */\n  onAdd?: (column: KanbanColumn, title: string) => T | void | Promise<T | void>;\n  /** The noun in \"+ Add …\". */\n  addLabel?: string;\n  label?: string;\n}\n\ntype Snapshot = Map<string, { x: number; y: number; transform: string }>;\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst COL_W = 268;\nconst COLLAPSED_W = 48;\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst tint = (tone: number, amount: number) => `color-mix(in oklab, var(--chart-${tone}) ${amount}%, var(--background))`;\nconst ink = (tone: number) => `color-mix(in oklab, var(--chart-${tone}) 72%, var(--foreground))`;\n\n/** The card's status: the column it's in, or (while carried) the column it's over. */\nfunction Chip({ column, reduced }: { column: KanbanColumn; reduced: boolean }) {\n  const tone = column.tone ?? 1;\n  const t = reduced ? \"none\" : `color 300ms ${EASE}, background-color 300ms ${EASE}, box-shadow 300ms ${EASE}`;\n  return (\n    <span\n      className=\"inline-flex h-5 items-center gap-1 rounded-full px-1.5 text-[11px] font-medium\"\n      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 }}\n    >\n      <span className=\"relative flex size-3 items-center justify-center\" aria-hidden=\"true\">\n        <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}` }} />\n        <svg viewBox=\"0 0 16 16\" className=\"absolute size-3\" fill=\"none\">\n          <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` }} />\n        </svg>\n      </span>\n      <TextMorph>{column.title}</TextMorph>\n    </span>\n  );\n}\n\n/** A sentence that changes crossfades whole. */\nfunction Line({ text, className = \"\" }: { text: string; className?: string }) {\n  const [shown, setShown] = React.useState({ text, key: 0 });\n  const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]);\n  if (text !== shown.text) {\n    if (shown.text) setLeaving((l) => [...l, shown]);\n    setShown({ text, key: shown.key + 1 });\n  }\n  return (\n    <span className={`relative block ${className}`}>\n      <span\n        key={shown.key}\n        className=\"block\"\n        ref={(el) => {\n          if (!el || shown.key === 0 || el.dataset.in) return;\n          el.dataset.in = \"1\";\n          el.animate(\n            [\n              { opacity: 0, transform: \"translateY(0.35em)\", filter: \"blur(4px)\" },\n              { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            ],\n            { duration: 380, delay: 60, easing: EASE, fill: \"backwards\" },\n          );\n        }}\n      >\n        {shown.text || \" \"}\n      </span>\n      {leaving.map((l) => (\n        <span\n          key={l.key}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 top-0 block\"\n          ref={(el) => {\n            if (!el || el.dataset.out) return;\n            el.dataset.out = \"1\";\n            el.animate(\n              [\n                { opacity: 1, filter: \"blur(0px)\" },\n                { opacity: 0, filter: \"blur(4px)\" },\n              ],\n              { duration: 180, easing: EASE, fill: \"forwards\" },\n            ).onfinish = () => setLeaving((all) => all.filter((x) => x.key !== l.key));\n          }}\n        >\n          {l.text}\n        </span>\n      ))}\n    </span>\n  );\n}\n\nexport function KanbanBoard<T extends KanbanCard>({\n  columns,\n  cards: controlled,\n  defaultCards,\n  onCardsChange,\n  renderCard,\n  cardLabel = (c) => c.id,\n  refuse,\n  onMove,\n  value,\n  valueFormat,\n  locales,\n  onAdd,\n  addLabel = \"card\",\n  label = \"Board\",\n  className = \"\",\n  ...props\n}: KanbanBoardProps<T>) {\n  const reduced = useReducedMotion();\n  const uid = React.useId();\n  const [own, setOwn] = React.useState<T[]>(defaultCards ?? []);\n  const cards = controlled ?? own;\n  const cardsRef = React.useRef(cards);\n  cardsRef.current = cards;\n  const [over, setOver] = React.useState<BoardOver | null>(null);\n  const [collapsed, setCollapsed] = React.useState<ReadonlySet<string>>(() => new Set());\n  const [adding, setAdding] = React.useState<string | null>(null);\n  const [keyLift, setKeyLift] = React.useState<{ id: string; column: string; index: number } | null>(null);\n  const [notes, setNotes] = React.useState<Record<string, { text: string; tone: \"warn\" | \"error\"; key: number }>>({});\n  const [announce, setAnnounce] = React.useState(\"\");\n  const [flipTick, setFlipTick] = React.useState(0);\n  const boardRef = React.useRef<HTMLDivElement>(null);\n  const flip = React.useRef<{ before: Snapshot; lifted?: string } | null>(null);\n\n  const say = (text: string) => setAnnounce((a) => (a === text ? `${text} ` : text));\n  const commit = (next: T[]) => {\n    cardsRef.current = next;\n    if (controlled === undefined) setOwn(next);\n    onCardsChange?.(next);\n  };\n  const note = (column: string, text: string, tone: \"warn\" | \"error\") => {\n    const key = Date.now();\n    setNotes((n) => ({ ...n, [column]: { text, tone, key } }));\n    window.setTimeout(() => setNotes((n) => (n[column]?.key === key ? (({ [column]: _, ...rest }) => rest)(n) : n)), 3200);\n  };\n\n  const colOf = (id: string) => columns.find((c) => c.id === id)!;\n  const inColumn = (list: T[], column: string) => list.filter((c) => c.column === column);\n  const fmt = React.useMemo(() => new Intl.NumberFormat(locales, valueFormat), [locales, valueFormat && JSON.stringify(valueFormat)]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  /** Why `card` can't go into `column`: the limit, then the caller's rule. */\n  const reason = (card: T, column: KanbanColumn) => {\n    if (card.column === column.id) return null;\n    if (column.limit !== undefined && inColumn(cardsRef.current, column.id).length >= column.limit) return `${column.title} holds ${column.limit} at most`;\n    return (refuse?.(card, column) as string | null | undefined) || null;\n  };\n\n  /** Moves a card in the list: out of where it is, into column at index (among that column's cards). */\n  const placed = (list: T[], id: string, column: string, index: number) => {\n    const card = list.find((c) => c.id === id)!;\n    const rest = list.filter((c) => c.id !== id);\n    const moved = { ...card, column } as T;\n    const peers = rest.filter((c) => c.column === column);\n    if (index >= peers.length) {\n      const last = peers[peers.length - 1];\n      const at = last ? rest.indexOf(last) + 1 : rest.length;\n      return [...rest.slice(0, at), moved, ...rest.slice(at)];\n    }\n    const at = rest.indexOf(peers[index]);\n    return [...rest.slice(0, at), moved, ...rest.slice(at)];\n  };\n\n  // ── FLIP after any change of place ─────────────────────\n  const engine = useBoardDrag({\n    board: boardRef,\n    refuse: (id, column) => {\n      if (collapsed.has(column)) return null;\n      const card = cardsRef.current.find((c) => c.id === id);\n      return card ? reason(card, colOf(column)) : null;\n    },\n    onOver: setOver,\n    onDrop: (id, column, index) => {\n      const card = cardsRef.current.find((c) => c.id === id);\n      if (!card) return;\n      move(card, column, index, true);\n    },\n    onCancel: (id, refused, column) => {\n      if (!refused) return;\n      // The reason stays a moment after the card springs home.\n      if (column) note(column, refused, \"warn\");\n      const card = cardsRef.current.find((c) => c.id === id);\n      say(`${card ? cardLabel(card) : \"The card\"} went back. ${refused}.`);\n    },\n    reduced,\n    disabled: !!keyLift || !!adding,\n  });\n\n  const play = React.useCallback(() => {\n    const f = flip.current;\n    const board = boardRef.current;\n    if (!f || !board) return;\n    flip.current = null;\n    engine.clear();\n    if (reduced) return;\n    board.querySelectorAll<HTMLElement>(\"[data-card-id]\").forEach((el) => {\n      const id = el.dataset.cardId!;\n      const was = f.before.get(id);\n      if (!was) return;\n      const r = el.getBoundingClientRect();\n      const dx = was.x - (r.left + r.width / 2);\n      const dy = was.y - (r.top + r.height / 2);\n      const tilt = Number(/rotate\\(([-\\d.]+)deg\\)/.exec(was.transform)?.[1] ?? 0);\n      const lifted = !!was.transform;\n      if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5 && !lifted) return;\n      el.animate(\n        [{ transform: `translate(${dx}px, ${dy}px) rotate(${tilt}deg) scale(${lifted ? 1.03 : 1})`, zIndex: lifted ? 50 : 1 }, { transform: \"none\", zIndex: lifted ? 50 : 1 }],\n        { duration: lifted ? 460 : 380, easing: lifted ? THROW : EASE },\n      );\n    });\n  }, [engine, reduced]);\n\n  React.useLayoutEffect(() => {\n    play();\n  }, [flipTick, cards, play]);\n\n  /** Commits a move (optimistic), FLIPs everything into place, then saves; a failed save flies it back. */\n  const move = async (card: T, column: string, index: number, save: boolean) => {\n    const before = engine.snapshot();\n    const previous = cardsRef.current;\n    const next = placed(previous, card.id, column, index);\n    flip.current = { before };\n    commit(next);\n    setFlipTick((t) => t + 1);\n    const to = colOf(column);\n    const position = inColumn(next, column).findIndex((c) => c.id === card.id) + 1;\n    const of = `${position} of ${inColumn(next, column).length}`;\n    say(save ? `Moved ${cardLabel(card)} to ${to.title}, ${of}.` : `${cardLabel(card)}, ${to.title}, ${of}.`);\n    if (!save || !onMove) return;\n    try {\n      await onMove({ ...card, column } as T, to, position - 1);\n    } catch {\n      // Back where it came from, from wherever it is now.\n      flip.current = { before: engine.snapshot() };\n      const restored = placed(cardsRef.current, card.id, card.column, inColumn(previous, card.column).findIndex((c) => c.id === card.id));\n      commit(restored);\n      setFlipTick((t) => t + 1);\n      note(card.column, `Couldn't move ${cardLabel(card)}. It's back.`, \"error\");\n      say(`Couldn't move ${cardLabel(card)}. It's back in ${colOf(card.column).title}.`);\n      window.setTimeout(() => {\n        if (reduced) return;\n        boardRef.current?.querySelector<HTMLElement>(`[data-card-id=\"${CSS.escape(card.id)}\"] > div`)?.animate(\n          [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n          { duration: 380, easing: \"ease-out\" },\n        );\n      }, 420);\n    }\n  };\n\n  // ── Keys: Space picks up, arrows carry, Space drops, Escape puts back ──\n  const onCardKey = (e: React.KeyboardEvent, card: T) => {\n    const list = inColumn(cardsRef.current, card.column);\n    const index = list.findIndex((c) => c.id === card.id);\n    if (e.key === \" \" || e.key === \"Enter\") {\n      e.preventDefault();\n      if (keyLift?.id === card.id) {\n        const from = keyLift;\n        setKeyLift(null);\n        if (from.column !== card.column || from.index !== index) {\n          // The carrying already showed the move; this saves it.\n          const original = { ...card, column: from.column } as T;\n          say(`Dropped ${cardLabel(card)} in ${colOf(card.column).title}, ${index + 1} of ${list.length}.`);\n          if (onMove)\n            Promise.resolve()\n              .then(() => onMove(card, colOf(card.column), index))\n              .catch(() => {\n                flip.current = { before: engine.snapshot() };\n                commit(placed(cardsRef.current, card.id, from.column, from.index));\n                setFlipTick((t) => t + 1);\n                note(from.column, `Couldn't move ${cardLabel(original)}. It's back.`, \"error\");\n                say(`Couldn't move ${cardLabel(original)}. It's back.`);\n              });\n        } else say(`Dropped ${cardLabel(card)} where it was.`);\n      } else {\n        setKeyLift({ id: card.id, column: card.column, index });\n        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.`);\n      }\n      return;\n    }\n    if (keyLift?.id !== card.id) return;\n    const ci = columns.findIndex((c) => c.id === card.column);\n    if (e.key === \"ArrowUp\" || e.key === \"ArrowDown\") {\n      e.preventDefault();\n      const to = index + (e.key === \"ArrowUp\" ? -1 : 1);\n      if (to < 0 || to >= list.length) return;\n      move(card, card.column, to, false);\n    } else if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") {\n      e.preventDefault();\n      const dir = e.key === \"ArrowLeft\" ? -1 : 1;\n      for (let j = ci + dir; j >= 0 && j < columns.length; j += dir) {\n        const col = columns[j];\n        const why = reason(card, col);\n        if (why) {\n          note(col.id, why, \"warn\");\n          say(`${col.title} won't take it: ${why}.`);\n          return;\n        }\n        move(card, col.id, Math.min(index, inColumn(cardsRef.current, col.id).length), false);\n        return;\n      }\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      setKeyLift(null);\n      move(card, keyLift.column, keyLift.index, false);\n      say(`Put ${cardLabel(card)} back.`);\n    }\n  };\n\n  // Keep focus on the carried card as it moves between columns (it's a new element there), before anything paints.\n  React.useLayoutEffect(() => {\n    if (!keyLift) return;\n    boardRef.current?.querySelector<HTMLElement>(`[data-card-id=\"${CSS.escape(keyLift.id)}\"]`)?.focus({ preventScroll: false });\n  }, [cards, keyLift]);\n\n  // ── Adding ─────────────────────────────────────────────\n  const add = async (column: KanbanColumn, title: string) => {\n    setAdding(null);\n    if (!title.trim() || !onAdd) return;\n    try {\n      const made = await onAdd(column, title.trim());\n      if (made) {\n        commit([...cardsRef.current, made]);\n        say(`Added ${cardLabel(made)} to ${column.title}.`);\n      }\n    } catch {\n      note(column.id, `Couldn't add “${title.trim()}”.`, \"error\");\n    }\n  };\n\n  // ── Render ─────────────────────────────────────────────\n  // While a card is carried, it counts in the column it's over (unless that column refuses it).\n  const carried = over && !over.refused ? cards.find((c) => c.id === over.card) : undefined;\n  const effective = (c: T) => (carried && c.id === carried.id ? over!.column : c.column);\n  const total = (column: string) => cards.filter((c) => effective(c) === column).reduce((sum, c) => sum + (value?.(c) ?? 0), 0);\n  const count = (column: string) => cards.filter((c) => effective(c) === column).length;\n\n  return (\n    <div className={`@container w-full text-foreground ${className}`} {...props}>\n      <div\n        ref={boardRef}\n        role=\"region\"\n        aria-label={label}\n        aria-describedby={`${uid}-how`}\n        {...engine.handlers}\n        className=\"-mx-1 overflow-x-auto overscroll-x-contain px-1 pb-3 [scrollbar-width:thin] @max-[40rem]:snap-x @max-[40rem]:snap-mandatory\"\n      >\n        <div className=\"flex w-max items-start gap-3\">\n          {columns.map((column) => {\n            const tone = column.tone ?? 1;\n            const list = inColumn(cards, column.id);\n            const isCollapsed = collapsed.has(column.id);\n            const n = count(column.id);\n            const full = column.limit !== undefined && n >= column.limit;\n            const refusedHere = over?.refused && over.column === column.id;\n            const hereNote = notes[column.id];\n            const showNote = refusedHere ? { text: over!.refused!, tone: \"warn\" as const } : hereNote;\n            return (\n              <section\n                key={column.id}\n                data-column-id={column.id}\n                aria-label={`${column.title}, ${n} ${n === 1 ? \"card\" : \"cards\"}${value ? `, ${fmt.format(total(column.id))}` : \"\"}`}\n                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)]\"}`}\n                style={{\n                  width: isCollapsed ? COLLAPSED_W : COL_W,\n                  transitionTimingFunction: EASE,\n                  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)\",\n                  background: over && !over.refused && over.column === column.id ? tint(tone, 4) : \"color-mix(in oklab, var(--muted) 45%, var(--background))\",\n                }}\n              >\n                {/* Head: title, count (of the limit), total; it gives a shake when it refuses a card. */}\n                <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\" : \"\"}`}>\n                  {isCollapsed ? (\n                    <>\n                      <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}`}>\n                        <UnfoldHorizontal className=\"size-3.5\" strokeWidth={1.75} />\n                      </button>\n                      <span className=\"text-[12px] tabular-nums text-muted-foreground\">\n                        <NumberRoll value={n} />\n                      </span>\n                      <span className=\"text-[13px] font-medium [writing-mode:vertical-rl]\">{column.title}</span>\n                    </>\n                  ) : (\n                    <>\n                      <span className=\"size-2 shrink-0 rounded-full\" style={{ background: `var(--chart-${tone})` }} aria-hidden=\"true\" />\n                      <h3 className=\"min-w-0 truncate text-[13.5px] font-medium tracking-[-0.01em]\">{column.title}</h3>\n                      <span\n                        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\"}`}\n                        style={{ boxShadow: \"inset 0 0 0 1px var(--border)\" }}\n                      >\n                        <NumberRoll value={n} />\n                        {column.limit !== undefined && <span>/{column.limit}</span>}\n                      </span>\n                      {value && (\n                        <span className=\"ml-auto truncate text-[12px] tabular-nums text-muted-foreground\">\n                          <NumberRoll value={total(column.id)} format={valueFormat} locales={locales} duration={800} />\n                        </span>\n                      )}\n                      <button\n                        type=\"button\"\n                        aria-label={`Collapse ${column.title}`}\n                        aria-expanded\n                        onClick={() => setCollapsed((s) => new Set([...s, column.id]))}\n                        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}`}\n                      >\n                        <FoldHorizontal className=\"size-3.5\" strokeWidth={1.75} />\n                      </button>\n                    </>\n                  )}\n                </header>\n\n                {!isCollapsed && (\n                  <div className=\"grid transition-[grid-template-rows] duration-300\" style={{ gridTemplateRows: showNote ? \"1fr\" : \"0fr\", transitionTimingFunction: EASE }}>\n                    <div className=\"min-h-0 overflow-hidden\">\n                      <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\"}`} />\n                    </div>\n                  </div>\n                )}\n\n                <ol\n                  data-column-list\n                  data-collapsed={isCollapsed || undefined}\n                  aria-label={column.title}\n                  className={`relative flex min-h-12 flex-col gap-2 px-2 pb-2 ${isCollapsed ? \"hidden\" : \"\"}`}\n                >\n                  {list.map((card) => {\n                    const lifted = keyLift?.id === card.id;\n                    const shownColumn = colOf(over?.card === card.id && !over.refused ? over.column : card.column);\n                    const position = list.indexOf(card) + 1;\n                    const amount = value?.(card);\n                    return (\n                      <li\n                        key={card.id}\n                        data-card-id={card.id}\n                        tabIndex={0}\n                        aria-roledescription=\"draggable card\"\n                        aria-label={`${cardLabel(card)}, ${column.title}, ${position} of ${list.length}${amount != null ? `, ${fmt.format(amount)}` : \"\"}`}\n                        aria-describedby={`${uid}-keys`}\n                        aria-pressed={lifted || undefined}\n                        onKeyDown={(e) => onCardKey(e, card)}\n                        onBlur={() => {\n                          // A carried card moving column is a new element for a moment: only a real blur drops the carry.\n                          if (keyLift?.id !== card.id) return;\n                          requestAnimationFrame(() => !boardRef.current?.contains(document.activeElement) && setKeyLift(null));\n                        }}\n                        className={`group/card relative cursor-grab select-none rounded-[12px] outline-none data-[lifted]:cursor-grabbing ${FOCUS}`}\n                      >\n                        <div\n                          className={`rounded-[12px] bg-background p-3 transition-[box-shadow] duration-200 ${\n                            lifted\n                              ? \"shadow-[inset_0_0_0_1.5px_var(--ring)]\"\n                              : \"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)]\"\n                          }`}\n                        >\n                          {renderCard(card, { column: shownColumn, lifted })}\n                          <div className=\"mt-2.5 flex items-center justify-between gap-2\">\n                            <Chip column={shownColumn} reduced={reduced} />\n                            {amount != null && <span className=\"text-[12px] tabular-nums text-muted-foreground\">{fmt.format(amount)}</span>}\n                          </div>\n                        </div>\n                      </li>\n                    );\n                  })}\n                </ol>\n\n                {/* \"+ Add …\" grows into a card you type into, and folds back. */}\n                {onAdd && !isCollapsed && (\n                  <div className=\"px-2 pb-2\">\n                    <div\n                      className=\"grid rounded-[12px] transition-[background-color,box-shadow] duration-300\"\n                      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\" }}\n                    >\n                      <button\n                        type=\"button\"\n                        onClick={() => setAdding(column.id)}\n                        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}`}\n                        style={{ opacity: adding === column.id ? 0 : 1, pointerEvents: adding === column.id ? \"none\" : undefined, transition: reduced ? \"none\" : `opacity 200ms ${EASE}` }}\n                        tabIndex={adding === column.id ? -1 : 0}\n                      >\n                        <Plus className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n                        Add {addLabel}\n                      </button>\n                      <div\n                        className=\"col-start-1 row-start-1 grid transition-[grid-template-rows] duration-[380ms]\"\n                        style={{ gridTemplateRows: adding === column.id ? \"1fr\" : \"0fr\", transitionTimingFunction: EASE, pointerEvents: adding === column.id ? undefined : \"none\" }}\n                      >\n                        <div className=\"min-h-0 overflow-hidden\">\n                          {adding === column.id && (\n                            <input\n                              autoFocus\n                              placeholder={`New ${addLabel}`}\n                              aria-label={`New ${addLabel} in ${column.title}`}\n                              onKeyDown={(e) => {\n                                if (e.key === \"Enter\") add(column, e.currentTarget.value);\n                                if (e.key === \"Escape\") setAdding(null);\n                              }}\n                              onBlur={(e) => add(column, e.currentTarget.value)}\n                              className=\"h-[60px] w-full bg-transparent px-3 text-[13px] outline-none placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]\"\n                            />\n                          )}\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                )}\n              </section>\n            );\n          })}\n        </div>\n      </div>\n\n      <p id={`${uid}-how`} className=\"sr-only\">\n        Drag a card to another column or place. On a touch screen, hold it first.\n      </p>\n      <p id={`${uid}-keys`} className=\"sr-only\">\n        Space picks the card up, arrow keys carry it within and across columns, Space drops it, Escape puts it back.\n      </p>\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </p>\n    </div>\n  );\n}\n"},{"path":"registry/use-board-drag.ts","type":"registry:component","content":"// Hairline UI · kanban-board · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * BOARD DRAG: a card you pick up and carry across columns\n *\n *   lift      after 4px (or a hold on touch) the card itself\n *             follows the pointer exactly, a touch larger, and\n *             tilts with how fast you're carrying it sideways,\n *             settling upright when you stop\n *   gaps      the column under it eases open a gap at the exact\n *             slot while the one it left closes its own; all\n *             transforms, nothing re-laid out per move\n *   refuse    a column that won't take the card (a limit, a\n *             rule) opens no gap; let go and it springs home\n *   scroll    near the board's sides it scrolls sideways, near\n *             the window's top or bottom the page scrolls\n *\n * The hook reports the column and slot only when they change;\n * the component re-renders for those (the card's status chip,\n * the column totals) and runs its own FLIP on drop. Elements:\n * data-column-id (a column), data-column-list (its list),\n * data-card-id (a card's list item).\n * ───────────────────────────────────────────────────────── */\n\nexport type BoardOver = { card: string; column: string; index: number; refused: string | null };\n\ntype Options = {\n  board: React.RefObject<HTMLElement | null>;\n  /** Why a card can't go in a column, or null. */\n  refuse: (card: string, column: string) => string | null;\n  onOver: (over: BoardOver | null) => void;\n  /** Dropped on a slot. The card's tilt and lift are on its element still; FLIP from there. */\n  onDrop: (card: string, column: string, index: number) => void;\n  /** Dropped where it can't go (and which column refused it), or not moved: it's sprung home already. */\n  onCancel: (card: string, refused: string | null, column: string | null) => void;\n  reduced: boolean;\n  disabled?: boolean;\n};\n\ntype ColumnBox = { id: string; left: number; right: number; listTop: number; list: HTMLElement; collapsed: boolean; cards: { id: string; el: HTMLElement; top: number; height: number }[] };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst GAP = 8;\nconst HOLD = 300;\nconst EDGE = 48;\n\nexport function useBoardDrag(o: Options) {\n  const opts = React.useRef(o);\n  opts.current = o;\n  const drag = React.useRef<{\n    id: string;\n    pointerId: number;\n    pointerType: string;\n    el: HTMLElement;\n    from: string;\n    fromIndex: number;\n    x0: number;\n    y0: number;\n    x: number;\n    y: number;\n    scroll0: number;\n    page0: number;\n    gap: number;\n    columns: ColumnBox[];\n    over: { column: string; index: number; refused: string | null } | null;\n    active: boolean;\n    timer: number;\n    vx: number;\n    lastX: number;\n    lastT: number;\n    tilt: number;\n  } | null>(null);\n  const loop = React.useRef(0);\n\n  const write = () => {\n    const d = drag.current;\n    if (!d) return;\n    const dx = d.x - d.x0 + ((opts.current.board.current?.scrollLeft ?? 0) - d.scroll0);\n    const dy = d.y - d.y0 + (window.scrollY - d.page0);\n    d.el.style.transform = `translate3d(${dx}px, ${dy}px, 0) rotate(${d.tilt}deg) scale(1.03)`;\n  };\n\n  /** One loop while carrying: eases the tilt toward the carrying speed and back to upright when still. */\n  const run = () => {\n    cancelAnimationFrame(loop.current);\n    const tick = () => {\n      const d = drag.current;\n      if (!d?.active) return;\n      const now = performance.now();\n      if (now - d.lastT > 60) d.vx *= 0.8;\n      const target = opts.current.reduced ? 0 : Math.max(-5, Math.min(5, d.vx * 3.2));\n      d.tilt += (target - d.tilt) * 0.18;\n      write();\n      autoScroll();\n      loop.current = requestAnimationFrame(tick);\n    };\n    loop.current = requestAnimationFrame(tick);\n  };\n\n  const measure = (skip: string): ColumnBox[] => {\n    const board = opts.current.board.current;\n    if (!board) return [];\n    return [...board.querySelectorAll<HTMLElement>(\"[data-column-id]\")].map((col) => {\n      const r = col.getBoundingClientRect();\n      const list = col.querySelector<HTMLElement>(\"[data-column-list]\")!;\n      const lr = list.getBoundingClientRect();\n      return {\n        id: col.dataset.columnId!,\n        left: r.left,\n        right: r.right,\n        listTop: lr.top,\n        list,\n        collapsed: list.hasAttribute(\"data-collapsed\"),\n        cards: [...list.querySelectorAll<HTMLElement>(\":scope > [data-card-id]\")]\n          .filter((el) => el.dataset.cardId !== skip)\n          .map((el) => ({ id: el.dataset.cardId!, el, top: el.offsetTop, height: el.offsetHeight })),\n      };\n    });\n  };\n\n  /** Which column and slot the card's centre is over (screen positions, adjusted for any scrolling since pickup). */\n  const locate = () => {\n    const d = drag.current!;\n    const sx = (opts.current.board.current?.scrollLeft ?? 0) - d.scroll0;\n    const sy = window.scrollY - d.page0;\n    const cols = d.columns;\n    let col = cols.find((c) => d.x >= c.left - sx && d.x <= c.right - sx);\n    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]);\n    // The carried card's centre (its laid-out centre plus how far it's been carried), in the target list's coordinates.\n    const home = d.columns.find((c) => c.id === d.from)!;\n    const cardCentre = d.el.offsetTop + d.el.offsetHeight / 2 + (d.y - d.y0) + sy + (home.listTop - col.listTop);\n    // A collapsed column shows no cards: it takes the card at its end.\n    if (col.collapsed) return { column: col.id, index: col.cards.length };\n    let index = 0;\n    for (const c of col.cards) if (cardCentre > c.top + c.height / 2) index++;\n    return { column: col.id, index };\n  };\n\n  const shift = (el: HTMLElement, y: number) => {\n    el.style.transition = opts.current.reduced ? \"none\" : `transform 240ms ${EASE}`;\n    el.style.transform = y ? `translate3d(0, ${y}px, 0)` : \"\";\n  };\n\n  /** Open the gap where the card would land and close the one it left. */\n  const arrange = () => {\n    const d = drag.current!;\n    const { column, index } = locate();\n    const refused = column === d.from ? null : opts.current.refuse(d.id, column);\n    if (d.over && d.over.column === column && d.over.index === index && d.over.refused === refused) return;\n    if ((!d.over || d.over.column !== column) && refused) {\n      // Entering a column that won't take it: its head says no once.\n      const head = d.columns.find((c) => c.id === column)?.list.parentElement?.querySelector<HTMLElement>(\"[data-column-head]\");\n      if (head && !opts.current.reduced)\n        head.animate([{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }], { duration: 380, easing: \"ease-out\" });\n    }\n    d.over = { column, index, refused };\n    for (const c of d.columns) {\n      const lands = !refused && c.id === column;\n      const left = c.id === d.from;\n      c.cards.forEach((card, i) => {\n        let y = 0;\n        if (left && !(lands && column === d.from)) {\n          // The column it left closes up behind it.\n          if (i >= d.fromIndex) y = -d.gap;\n        }\n        if (lands) {\n          if (column === d.from) {\n            // Same column: the cards between the old and new slot make way.\n            if (index > d.fromIndex && i >= d.fromIndex && i < index) y = -d.gap;\n            if (index < d.fromIndex && i >= index && i < d.fromIndex) y = d.gap;\n          } else if (i >= index) y = d.gap;\n        }\n        if (card.el.style.transform !== (y ? `translate3d(0, ${y}px, 0)` : \"\")) shift(card.el, y);\n      });\n      // The column it would land in grows by a card; the one it left keeps its height until the drop.\n      c.list.style.transition = opts.current.reduced ? \"none\" : `padding-bottom 240ms ${EASE}`;\n      c.list.style.paddingBottom = lands && column !== d.from ? `${d.gap}px` : \"\";\n    }\n    opts.current.onOver({ card: d.id, column, index: refused ? -1 : index, refused });\n  };\n\n  const autoScroll = () => {\n    const d = drag.current;\n    const board = opts.current.board.current;\n    if (!d?.active || !board) return;\n    const r = board.getBoundingClientRect();\n    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;\n    const upDown = d.y < EDGE ? -(EDGE - d.y) / EDGE : d.y > window.innerHeight - EDGE ? (d.y - (window.innerHeight - EDGE)) / EDGE : 0;\n    let moved = false;\n    if (sideways) {\n      const before = board.scrollLeft;\n      board.scrollLeft += sideways * 14;\n      moved ||= board.scrollLeft !== before;\n    }\n    if (upDown) {\n      const before = window.scrollY;\n      window.scrollBy(0, upDown * 12);\n      moved ||= window.scrollY !== before;\n    }\n    if (moved) arrange();\n  };\n\n  /** Puts everything back where it was laid out (the card springs home unless asked not to). */\n  const release = (home: boolean) => {\n    const d = drag.current;\n    if (!d) return;\n    for (const c of d.columns) {\n      c.cards.forEach((card) => shift(card.el, 0));\n      c.list.style.paddingBottom = \"\";\n    }\n    if (home) {\n      const from = d.el.style.transform;\n      d.el.style.transform = \"\";\n      if (from && !opts.current.reduced) d.el.animate([{ transform: from }, { transform: \"none\" }], { duration: 460, easing: THROW });\n    }\n    d.el.style.zIndex = \"\";\n    d.el.style.willChange = \"\";\n    d.el.removeAttribute(\"data-lifted\");\n  };\n\n  const activate = () => {\n    const d = drag.current;\n    if (!d || d.active) return;\n    d.active = true;\n    if (d.pointerType === \"touch\") navigator.vibrate?.(8);\n    d.columns = measure(d.id);\n    d.gap = d.el.offsetHeight + GAP;\n    d.el.style.transition = \"none\";\n    d.el.style.zIndex = \"50\";\n    d.el.style.willChange = \"transform\";\n    // Snapping would pull every small auto-scroll step straight back; it's off while a card is carried.\n    const board = opts.current.board.current;\n    if (board) board.style.scrollSnapType = \"none\";\n    d.el.setAttribute(\"data-lifted\", \"\");\n    arrange();\n    run();\n  };\n\n  const end = (dropped: boolean) => {\n    const d = drag.current;\n    if (!d) return;\n    window.clearTimeout(d.timer);\n    cancelAnimationFrame(loop.current);\n    const board = opts.current.board.current;\n    if (board) board.style.scrollSnapType = \"\";\n    if (!d.active) {\n      drag.current = null;\n      return;\n    }\n    const over = d.over;\n    const moved = over && (over.column !== d.from || over.index !== d.fromIndex);\n    if (dropped && over && !over.refused && moved) {\n      // The component re-lays the board out and runs the FLIP from where everything is now.\n      opts.current.onDrop(d.id, over.column, over.index);\n      drag.current = null;\n      opts.current.onOver(null);\n      return;\n    }\n    release(true);\n    drag.current = null;\n    opts.current.onOver(null);\n    opts.current.onCancel(d.id, dropped && over?.refused ? over.refused : null, over?.column ?? null);\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLElement>) => {\n    if (opts.current.disabled || drag.current || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n    const target = e.target as HTMLElement;\n    if (target.closest(\"button, input, a, textarea, [data-no-drag]\")) return;\n    const el = target.closest<HTMLElement>(\"[data-card-id]\");\n    if (!el) return;\n    const column = el.closest<HTMLElement>(\"[data-column-id]\")!.dataset.columnId!;\n    const siblings = [...el.parentElement!.querySelectorAll<HTMLElement>(\":scope > [data-card-id]\")];\n    drag.current = {\n      id: el.dataset.cardId!,\n      pointerId: e.pointerId,\n      pointerType: e.pointerType,\n      el,\n      from: column,\n      fromIndex: siblings.indexOf(el),\n      x0: e.clientX,\n      y0: e.clientY,\n      x: e.clientX,\n      y: e.clientY,\n      scroll0: opts.current.board.current?.scrollLeft ?? 0,\n      page0: window.scrollY,\n      gap: 0,\n      columns: [],\n      over: null,\n      active: false,\n      timer: 0,\n      vx: 0,\n      lastX: e.clientX,\n      lastT: performance.now(),\n      tilt: 0,\n    };\n    if (e.pointerType === \"touch\") {\n      drag.current.timer = window.setTimeout(activate, HOLD);\n    } else {\n      e.currentTarget.setPointerCapture(e.pointerId);\n    }\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {\n    const d = drag.current;\n    if (!d || d.pointerId !== e.pointerId) return;\n    if (!d.active) {\n      const moved = Math.hypot(e.clientX - d.x0, e.clientY - d.y0);\n      if (d.pointerType === \"touch\") {\n        if (moved > 8) end(false);\n        return;\n      }\n      if (moved < 4) return;\n      activate();\n    }\n    const now = performance.now();\n    const dt = Math.max(1, now - d.lastT);\n    d.vx = d.vx * 0.6 + ((e.clientX - d.lastX) / dt) * 0.4;\n    d.lastX = e.clientX;\n    d.lastT = now;\n    d.x = e.clientX;\n    d.y = e.clientY;\n    arrange();\n  };\n\n  const onPointerUp = (e: React.PointerEvent<HTMLElement>) => {\n    if (drag.current?.pointerId === e.pointerId) end(true);\n  };\n  const onPointerCancel = (e: React.PointerEvent<HTMLElement>) => {\n    if (drag.current?.pointerId === e.pointerId) end(false);\n  };\n\n  // A held card on touch: the page mustn't scroll under it, and no context menu.\n  React.useEffect(() => {\n    const el = o.board.current;\n    if (!el) return;\n    const touchMove = (e: TouchEvent) => {\n      if (drag.current?.active && e.cancelable) e.preventDefault();\n    };\n    const menu = (e: Event) => {\n      if (drag.current) e.preventDefault();\n    };\n    el.addEventListener(\"touchmove\", touchMove, { passive: false });\n    el.addEventListener(\"contextmenu\", menu);\n    return () => {\n      el.removeEventListener(\"touchmove\", touchMove);\n      el.removeEventListener(\"contextmenu\", menu);\n    };\n  }, [o.board]);\n\n  React.useEffect(() => () => cancelAnimationFrame(loop.current), []);\n\n  return {\n    handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel },\n    /** Where every card is on screen right now (lifted and shifted ones included), for a FLIP after the drop. */\n    snapshot: () => {\n      const out = new Map<string, { x: number; y: number; transform: string }>();\n      opts.current.board.current?.querySelectorAll<HTMLElement>(\"[data-card-id]\").forEach((el) => {\n        const r = el.getBoundingClientRect();\n        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 : \"\" });\n      });\n      return out;\n    },\n    /** Clears what a drag left on the elements (shifts, the lift, list padding) so the new layout can be measured. */\n    clear: () => {\n      opts.current.board.current?.querySelectorAll<HTMLElement>(\"[data-card-id]\").forEach((el) => {\n        el.style.transition = \"none\";\n        el.style.transform = \"\";\n        el.style.zIndex = \"\";\n        el.style.willChange = \"\";\n        el.removeAttribute(\"data-lifted\");\n      });\n      opts.current.board.current?.querySelectorAll<HTMLElement>(\"[data-column-list]\").forEach((el) => {\n        el.style.transition = \"none\";\n        el.style.paddingBottom = \"\";\n      });\n    },\n  };\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/kanban-board. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}