{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-flap",
  "title": "Split-flap board",
  "description": "A station departure board: each character is a pair of flaps that falls to the next value, column by column. The text is the truth and changes are announced once per row. For deploy boards, queues and live status.",
  "dependencies": [
    "cn"
  ],
  "registryDependencies": [
    "https://bevel-ui.vercel.app/r/bevel.json"
  ],
  "files": [
    {
      "path": "registry/bevel/ui/split-flap.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"cn\"\n\ntype SplitFlapContextValue = { changed: (row: string) => void }\n\nconst SplitFlapContext = React.createContext<SplitFlapContextValue | null>(null)\nconst SplitFlapRowContext = React.createContext<string>(\"\")\n\nfunction toMilliseconds(value: string, fallback: number) {\n  const n = parseFloat(value)\n  if (Number.isNaN(n)) return fallback\n  return value.trim().endsWith(\"ms\") ? n : n * 1000\n}\n\n/**\n * A split-flap status board. The text is the truth; the flaps only show\n * it. Changes are gathered and announced once, by row, not letter by\n * letter. Give it an aria-label.\n */\nfunction SplitFlap({ className, children, ...props }: React.ComponentProps<\"div\">) {\n  const [message, setMessage] = React.useState(\"\")\n  const pending = React.useRef(new Set<string>())\n  const timer = React.useRef<number>(undefined)\n  const root = React.useRef<HTMLDivElement>(null)\n\n  const changed = React.useCallback((row: string) => {\n    pending.current.add(row)\n    if (timer.current !== undefined) return\n    const wait = root.current\n      ? toMilliseconds(getComputedStyle(root.current).getPropertyValue(\"--split-flap-announce-after\"), 1200)\n      : 1200\n    timer.current = window.setTimeout(() => {\n      setMessage(`Updated: ${[...pending.current].join(\", \")}.`)\n      pending.current.clear()\n      timer.current = undefined\n    }, wait)\n  }, [])\n  React.useEffect(() => () => window.clearTimeout(timer.current), [])\n\n  return (\n    <SplitFlapContext value={{ changed }}>\n      <div\n        ref={root}\n        role=\"group\"\n        data-slot=\"split-flap\"\n        className={cn(\"bevel-metal bevel-object max-w-full overflow-x-auto rounded-(--bevel-radius-md) p-3\", className)}\n        {...props}\n      >\n        <ul className=\"flex w-max flex-col gap-1.5\">{children}</ul>\n        <p role=\"status\" className=\"sr-only\">\n          {message}\n        </p>\n      </div>\n    </SplitFlapContext>\n  )\n}\n\ntype SplitFlapRowProps = React.ComponentProps<\"li\"> & {\n  /** How this row is announced when it changes: “BA 117”. */\n  name: string\n}\n\n/** One line of the board. */\nfunction SplitFlapRow({ className, name, ...props }: SplitFlapRowProps) {\n  return (\n    <SplitFlapRowContext value={name}>\n      <li data-slot=\"split-flap-row\" className={cn(\"flex items-center gap-3\", className)} {...props} />\n    </SplitFlapRowContext>\n  )\n}\n\ntype SplitFlapTextProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  value: string\n  /** Number of flaps. Shorter text is padded; longer is cut. */\n  length: number\n  tone?: \"white\" | \"amber\"\n}\n\n/** A run of flaps showing one value. Each character flips when it changes, left to right. */\nfunction SplitFlapText({ className, value, length, tone = \"white\", ...props }: SplitFlapTextProps) {\n  const context = React.useContext(SplitFlapContext)\n  const row = React.useContext(SplitFlapRowContext)\n  const text = value.toUpperCase().slice(0, length).padEnd(length, \" \")\n  const [shown, setShown] = React.useState({ text, previous: text, flips: 0 })\n  if (shown.text !== text) setShown({ text, previous: shown.text, flips: shown.flips + 1 })\n\n  const announced = React.useRef(text)\n  React.useEffect(() => {\n    if (announced.current === text) return\n    announced.current = text\n    context?.changed(row)\n  }, [text, row, context])\n\n  return (\n    <span\n      data-slot=\"split-flap-text\"\n      data-tone={tone}\n      className={cn(\n        \"flex gap-px font-mono font-bold text-(--split-flap-foreground) data-[tone=amber]:text-(--split-flap-amber)\",\n        className\n      )}\n      {...props}\n    >\n      <span className=\"sr-only\">{value}</span>\n      {text.split(\"\").map((char, i) => (\n        <SplitFlapCell\n          key={i}\n          char={char}\n          previous={shown.previous[i] ?? \" \"}\n          flip={shown.previous[i] !== char ? shown.flips : 0}\n          index={i}\n        />\n      ))}\n    </span>\n  )\n}\n\nfunction Half({ char, half, className, style }: { char: string; half: \"top\" | \"bottom\"; className?: string; style?: React.CSSProperties }) {\n  return (\n    <span\n      className={cn(\n        \"absolute inset-x-0 h-1/2 overflow-hidden backface-hidden\",\n        half === \"top\" ? \"top-0 rounded-t-(--bevel-radius-xs) bg-(--split-flap-flap)\" : \"bottom-0 rounded-b-(--bevel-radius-xs) bg-(--split-flap-flap-lower)\",\n        className\n      )}\n      style={style}\n    >\n      <span\n        className={cn(\n          \"absolute inset-x-0 flex h-(--split-flap-cell-height) items-center justify-center\",\n          half === \"top\" ? \"top-0\" : \"bottom-0\"\n        )}\n      >\n        {char}\n      </span>\n    </span>\n  )\n}\n\n/** One character: static halves, plus a pair of flaps that fall when it changes. */\nfunction SplitFlapCell({ char, previous, flip, index }: { char: string; previous: string; flip: number; index: number }) {\n  const delay = { \"--split-flap-delay\": `calc(var(--bevel-stagger) * ${index})` } as React.CSSProperties\n  const flipping = flip > 0\n  return (\n    <span\n      aria-hidden\n      data-slot=\"split-flap-cell\"\n      className=\"relative block h-(--split-flap-cell-height) w-(--split-flap-cell-width) shrink-0 perspective-(--bevel-perspective)\"\n    >\n      <Half char={char} half=\"top\" />\n      {/* Underneath the falling flap, the old lower half shows until the new one lands. */}\n      <Half char={flipping ? previous : char} half=\"bottom\" className=\"motion-reduce:hidden\" />\n      <Half char={char} half=\"bottom\" className=\"hidden motion-reduce:block\" />\n      {flipping && (\n        <React.Fragment key={flip}>\n          <Half char={previous} half=\"top\" className=\"split-flap-top z-10 motion-reduce:hidden\" style={delay} />\n          <Half char={char} half=\"bottom\" className=\"split-flap-bottom z-10 motion-reduce:hidden\" style={delay} />\n          {/* Reduced motion: no flaps, the new character fades in. */}\n          <span className=\"split-flap-fade absolute inset-0 hidden motion-reduce:block\">\n            <Half char={char} half=\"top\" />\n            <Half char={char} half=\"bottom\" />\n          </span>\n        </React.Fragment>\n      )}\n      <span className=\"absolute inset-x-0 top-1/2 z-20 h-px -translate-y-1/2 bg-(--split-flap-hinge)\" />\n    </span>\n  )\n}\n\nexport { SplitFlap, SplitFlapRow, SplitFlapText }\nexport type { SplitFlapRowProps, SplitFlapTextProps }\n",
      "type": "registry:ui"
    }
  ],
  "css": {
    ":root": {
      "--split-flap-housing": "oklch(0.22 0.01 260)",
      "--split-flap-housing-edge": "oklch(0.14 0.01 260)",
      "--split-flap-flap": "oklch(0.2 0.005 260)",
      "--split-flap-flap-lower": "oklch(0.17 0.005 260)",
      "--split-flap-hinge": "oklch(0.08 0 0)",
      "--split-flap-foreground": "oklch(0.95 0.02 90)",
      "--split-flap-amber": "oklch(0.84 0.16 80)",
      "--split-flap-cell-width": "1.15em",
      "--split-flap-cell-height": "1.6em",
      "--split-flap-delay": "0s",
      "--split-flap-announce-after": "1200ms"
    },
    "[data-slot=\"split-flap\"]": {
      "--bevel-material": "var(--split-flap-housing)",
      "--bevel-material-edge": "var(--split-flap-housing-edge)"
    },
    "@keyframes split-flap-top": {
      "to": {
        "transform": "rotateX(-90deg)"
      }
    },
    "@keyframes split-flap-bottom": {
      "from": {
        "transform": "rotateX(90deg)"
      }
    },
    "@keyframes split-flap-fade": {
      "from": {
        "opacity": "0"
      }
    },
    "@utility split-flap-top": {
      "transform-origin": "bottom",
      "animation": "split-flap-top calc(var(--bevel-duration-small) / 2) var(--bevel-ease-in-out) both",
      "animation-delay": "var(--split-flap-delay)"
    },
    "@utility split-flap-bottom": {
      "transform-origin": "top",
      "animation": "split-flap-bottom calc(var(--bevel-duration-small) / 2) var(--bevel-ease-out) both",
      "animation-delay": "calc(var(--split-flap-delay) + var(--bevel-duration-small) / 2)"
    },
    "@utility split-flap-fade": {
      "animation": "split-flap-fade var(--bevel-duration-small) var(--bevel-ease-out) both"
    }
  },
  "categories": [
    "records"
  ],
  "type": "registry:ui"
}