{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-flap",
  "title": "Split-Flap Board",
  "description": "A departures board for jobs. Each changed cell spins through glyphs, staggered by column and row.",
  "dependencies": [
    "animejs"
  ],
  "registryDependencies": [
    "https://spotcheckui.com/r/spotcheck-base.json",
    "https://spotcheckui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/spotcheck/components/spotcheck/split-flap.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { animate, type JSAnimation } from \"animejs\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotion } from \"@/registry/spotcheck/hooks/use-reduced-motion\"\n\nconst GLYPHS = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-\"\nconst FLIP_MS = 65\nconst COLUMN_STAGGER_MS = 22\nconst ROW_STAGGER_MS = 60\nconst MAX_EXTRA_SPINS = 4\n\nexport function padRow(row: string, width: number) {\n  return row.padEnd(width).slice(0, width)\n}\n\n/** The cells that differ between two boards. */\nexport function changedCells(from: string[], to: string[]) {\n  return to.flatMap((row, rowIndex) =>\n    Array.from(row).flatMap((char, column) =>\n      from[rowIndex]?.[column] === char ? [] : [{ row: rowIndex, column, char }],\n    ),\n  )\n}\n\nexport type FlapColumn = { label: string; start: number }\n\ntype SplitFlapProps = {\n  /** One string per row. Keep the number of rows the same between updates. */\n  rows: string[]\n  /** Characters per row. */\n  width?: number\n  /** Column headings, each starting at a character position. */\n  columns?: FlapColumn[]\n  /** How many characters at the end of each row take the accent. */\n  accentCharacters?: number\n  className?: string\n}\n\nexport function SplitFlap({ rows, width = 22, columns = [], accentCharacters = 0, className }: SplitFlapProps) {\n  const reduced = useReducedMotion()\n  const board = rows.map((row) => padRow(row, width))\n  const boardKey = board.join(\"\\n\")\n  const [firstBoard] = React.useState(board)\n  const shown = React.useRef(firstBoard)\n  const cells = React.useRef(new Map<string, HTMLSpanElement>())\n  const running = React.useRef<JSAnimation[]>([])\n\n  React.useEffect(() => {\n    const target = boardKey.split(\"\\n\")\n    const from = shown.current\n    shown.current = target\n\n    running.current.forEach((animation) => animation.cancel())\n    running.current = []\n    for (const [key, cell] of cells.current) {\n      const [row = 0, column = 0] = key.split(\":\").map(Number)\n      cell.textContent = from[row]?.[column] ?? \" \"\n      cell.style.transform = \"\"\n    }\n\n    for (const { row, column, char } of changedCells(from, target)) {\n      const cell = cells.current.get(`${row}:${column}`)\n      if (!cell) continue\n      if (reduced) {\n        cell.textContent = char\n        continue\n      }\n      const spins = 1 + ((column + row) % MAX_EXTRA_SPINS)\n      let flips = 0\n      running.current.push(\n        animate(cell, {\n          rotateX: [0, -90],\n          duration: FLIP_MS,\n          ease: \"inQuad\",\n          loop: spins * 2 + 1,\n          alternate: true,\n          delay: column * COLUMN_STAGGER_MS + row * ROW_STAGGER_MS,\n          onLoop: () => {\n            flips++\n            cell.textContent = flips >= spins * 2 ? char : (GLYPHS[Math.floor(Math.random() * GLYPHS.length)] ?? char)\n          },\n          onComplete: () => {\n            cell.textContent = char\n          },\n        }),\n      )\n    }\n  }, [boardKey, reduced])\n\n  React.useEffect(() => () => running.current.forEach((animation) => animation.cancel()), [])\n\n  const grid = { gridTemplateColumns: `repeat(${width}, 1.3em)` }\n\n  return (\n    <div\n      className={cn(\n        \"bg-ink text-paper spotcheck-focus overflow-x-auto p-3 font-(family-name:--f-mono) text-[clamp(13px,1.8vw,19px)] font-semibold\",\n        className,\n      )}\n      tabIndex={0}\n      role=\"region\"\n      aria-label=\"Departures board\"\n    >\n      <ul className=\"sr-only\">\n        {board.map((row, index) => (\n          <li key={index}>{row}</li>\n        ))}\n      </ul>\n      <div aria-hidden className=\"w-max\">\n        {columns.length > 0 && (\n          <div className=\"mb-2 grid gap-[3px]\" style={grid}>\n            {columns.map((column) => (\n              <span\n                key={column.label}\n                className=\"text-[10.5px] font-normal tracking-[.12em] whitespace-nowrap text-[color-mix(in_srgb,var(--paper)_70%,var(--ink))] uppercase\"\n                style={{ gridColumnStart: column.start + 1 }}\n              >\n                {column.label}\n              </span>\n            ))}\n          </div>\n        )}\n        {firstBoard.map((row, rowIndex) => (\n          <div key={rowIndex} className=\"mb-[3px] grid gap-[3px] perspective-[300px]\" style={grid}>\n            {Array.from(row, (char, column) => (\n              <span\n                key={column}\n                ref={(element) => {\n                  if (element) cells.current.set(`${rowIndex}:${column}`, element)\n                }}\n                className={cn(\n                  \"relative grid h-[1.75em] place-items-center bg-[color-mix(in_srgb,var(--paper)_10%,var(--ink))] whitespace-pre backface-hidden after:absolute after:inset-x-0 after:top-1/2 after:h-px after:bg-(--ink)\",\n                  column >= width - accentCharacters && \"text-accent-on-ink\",\n                )}\n              >\n                {char}\n              </span>\n            ))}\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "timelines"
  ],
  "type": "registry:component"
}