{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-flap",
  "title": "Split Flap",
  "description": "A split-flap board: when the value changes, only the characters that changed flip, like a departure board.",
  "registryDependencies": [
    "@corsair-ui/utils",
    "@corsair-ui/use-in-view",
    "@corsair-ui/use-media-query"
  ],
  "files": [
    {
      "path": "registry/default/ui/split-flap.tsx",
      "content": "\"use client\";\n\nimport { useMemo, useState, type ComponentProps, type CSSProperties, type Ref } from \"react\";\n\nimport { useInView } from \"@/registry/default/hooks/use-in-view\";\nimport { useMediaQuery } from \"@/registry/default/hooks/use-media-query\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\nfunction mergeRefs<T>(...refs: (Ref<T> | undefined)[]) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    }\n  };\n}\n\n// One splitter for the module: user-perceived characters, so emoji and\n// accents stay whole wherever the runtime can tell them apart.\nconst characterSplitter =\n  typeof Intl === \"object\" && typeof Intl.Segmenter === \"function\"\n    ? new Intl.Segmenter(undefined, { granularity: \"grapheme\" })\n    : null;\n\nfunction graphemes(text: string) {\n  if (!characterSplitter) return [...text];\n  const pieces: string[] = [];\n  for (const { segment } of characterSplitter.segment(text)) pieces.push(segment);\n  return pieces;\n}\n\ntype SplitFlapTag = \"span\" | \"div\" | \"p\" | \"time\";\n\ninterface SplitFlapProps extends Omit<ComponentProps<\"span\">, \"children\" | \"ref\"> {\n  /** The text on the board. Each character is a cell. */\n  value: string;\n  ref?: Ref<HTMLElement>;\n  as?: SplitFlapTag;\n  /** With `as=\"time\"`: the machine-readable value. */\n  dateTime?: string;\n  /**\n   * Number of cells to reserve. Shorter values are padded at the start with\n   * `pad`; longer ones are shown in full, with the extra cells.\n   */\n  length?: number;\n  /** The character that fills the reserved cells. A space shows as a blank cell. */\n  pad?: string;\n  /**\n   * The characters a cell steps through, in order and wrapping, on its way\n   * from the old character to the new one, one flip per step. A character that\n   * is not in the cycle flips straight to the new one.\n   */\n  cycle?: string;\n  /** Which end flips first. */\n  from?: \"first\" | \"last\";\n  /** Time between the cells that flip, in ms. */\n  stagger?: number;\n  /** How long one flip takes, both halves together, in ms. */\n  duration?: number;\n  /** Distance to the viewer, in px: lower values give a deeper fold. */\n  perspective?: number;\n  /** Classes for every cell, such as `bg-card rounded-sm px-1` for tiles. */\n  cellClassName?: string;\n  /** A thin line across the middle of each cell, where the flaps meet. */\n  divider?: boolean;\n  /** \"polite\" announces each new value to screen readers. */\n  live?: \"off\" | \"polite\";\n}\n\ninterface Flip {\n  /** Remounts the flaps when a cell starts over. */\n  key: number;\n  /** The characters the cell shows on its way, old first, new last. */\n  sequence: string[];\n  /** The flip in progress: from `sequence[step]` to `sequence[step + 1]`. */\n  step: number;\n  /** Wait before the first flip, in ms. */\n  delay: number;\n}\n\ninterface Board {\n  signature: string;\n  cells: string[];\n  flips: (Flip | undefined)[];\n  nextKey: number;\n}\n\nfunction toCells(value: string, length: number | undefined, pad: string) {\n  const cells = graphemes(value);\n  if (length === undefined || cells.length >= length) return cells;\n  const filler = graphemes(pad)[0] ?? \" \";\n  return [...Array<string>(length - cells.length).fill(filler), ...cells];\n}\n\n// The way from one character to the next: straight there, or through the cycle.\nfunction route(from: string, to: string, cycle: string[]) {\n  const start = cycle.indexOf(from);\n  const end = cycle.indexOf(to);\n  if (start < 0 || end < 0) return [from, to];\n  const sequence = [from];\n  for (let index = start; index !== end && sequence.length <= cycle.length;) {\n    index = (index + 1) % cycle.length;\n    sequence.push(cycle[index] ?? to);\n  }\n  return sequence;\n}\n\nfunction flipTo(\n  board: Board,\n  cells: string[],\n  signature: string,\n  { cycle, from, stagger }: { cycle: string[]; from: \"first\" | \"last\"; stagger: number }\n): Board {\n  let nextKey = board.nextKey;\n  const flips: (Flip | undefined)[] = [];\n  const changed: number[] = [];\n  cells.forEach((character, index) => {\n    const previous = board.cells[index];\n    const running = board.flips[index];\n    if (character === previous) {\n      flips[index] = running;\n      return;\n    }\n    // A cell caught mid-flip starts over from the character it was revealing.\n    const shown = running ? (running.sequence[running.step + 1] ?? previous) : previous;\n    const start = shown ?? \" \";\n    if (start === character) return;\n    changed.push(index);\n    flips[index] = { key: nextKey++, sequence: route(start, character, cycle), step: 0, delay: 0 };\n  });\n  if (from === \"last\") changed.reverse();\n  changed.forEach((index, rank) => {\n    const flip = flips[index];\n    if (flip) flip.delay = rank * stagger;\n  });\n  return { signature, cells, flips, nextKey };\n}\n\nconst blank = (character: string) => (character === \" \" ? \"\\u00a0\" : character);\n\ninterface HalfProps extends ComponentProps<\"span\"> {\n  character: string;\n  half: \"top\" | \"bottom\";\n}\n\n// Half a cell: clipped to its height, with the whole glyph inside lined up\n// so only its own half shows. It takes the cell's background, so tiles hide\n// what is behind a flap.\nfunction Half({ character, half, className, ...props }: HalfProps) {\n  return (\n    <span\n      data-half={half}\n      className={cn(\n        \"absolute inset-x-0 h-1/2 overflow-hidden [border-radius:inherit] [background:inherit]\",\n        half === \"top\" ? \"top-0\" : \"bottom-0\",\n        className\n      )}\n      {...props}\n    >\n      <span\n        className={cn(\n          \"absolute inset-x-0 flex h-[200%] items-center justify-center\",\n          half === \"top\" ? \"top-0\" : \"bottom-0\"\n        )}\n      >\n        {blank(character)}\n      </span>\n    </span>\n  );\n}\n\n/**\n * A split-flap board, like the departure boards in stations and airports.\n * When `value` changes, only the cells whose character changed flip: the top\n * half of the old character falls away and the bottom half of the new one\n * drops into place, one cell after another, optionally stepping through\n * `cycle` on the way. The server HTML already shows the value, the first\n * render never animates, and changes only flip while the board is on screen\n * and motion is allowed; otherwise the characters swap in place. Cells are\n * `1ch` wide and a fixed height, so nothing around them moves. Screen\n * readers get the value in one piece.\n *\n * @example\n * <SplitFlap value={minute} length={3} stagger={60} cellClassName=\"bg-card rounded-sm px-1\" />\n */\nfunction SplitFlap({\n  value,\n  as: Tag = \"span\",\n  length,\n  pad = \" \",\n  cycle,\n  from = \"first\",\n  stagger = 40,\n  duration = 300,\n  perspective = 400,\n  cellClassName,\n  divider = false,\n  live = \"off\",\n  className,\n  style,\n  ref,\n  ...props\n}: SplitFlapProps) {\n  const [observe, inView] = useInView<HTMLElement>();\n  const reduced = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  const mergedRef = useMemo(() => mergeRefs(ref, observe), [ref, observe]);\n\n  const cells = useMemo(() => toCells(value, length, pad), [value, length, pad]);\n  const steps = useMemo(() => (cycle ? graphemes(cycle) : []), [cycle]);\n  const signature = cells.join(\"\\u0000\");\n\n  const [board, setBoard] = useState<Board>(() => ({\n    signature,\n    cells,\n    flips: [],\n    nextKey: 0,\n  }));\n\n  // Follow the value during render, so the new characters and their flaps\n  // reach the screen in the same paint.\n  if (board.signature !== signature) {\n    setBoard(\n      inView && !reduced\n        ? flipTo(board, cells, signature, { cycle: steps, from, stagger })\n        : { signature, cells, flips: [], nextKey: board.nextKey }\n    );\n  } else if (reduced && board.flips.some(Boolean)) {\n    setBoard({ ...board, flips: [] });\n  }\n\n  const flipping = board.flips.some(Boolean);\n  const half = duration / 2;\n\n  // The bottom flap lands last: take the next step, or settle.\n  const advance = (index: number, key: number) =>\n    setBoard((current) => {\n      const flip = current.flips[index];\n      if (!flip || flip.key !== key) return current;\n      const flips = current.flips.slice();\n      flips[index] =\n        flip.step + 2 < flip.sequence.length\n          ? { ...flip, step: flip.step + 1, delay: 0 }\n          : undefined;\n      return { ...current, flips };\n    });\n\n  return (\n    <Tag\n      ref={mergedRef as Ref<never>}\n      data-slot=\"split-flap\"\n      data-state={flipping ? \"flipping\" : \"idle\"}\n      className={cn(\"inline-flex\", className)}\n      style={{ \"--split-flap-perspective\": `${perspective}px`, ...style } as CSSProperties}\n      {...props}\n    >\n      <span\n        className=\"sr-only\"\n        aria-live={live === \"polite\" ? \"polite\" : undefined}\n        aria-atomic={live === \"polite\" ? \"true\" : undefined}\n      >\n        {value}\n      </span>\n      {board.cells.map((character, index) => {\n        const flip = board.flips[index];\n        const old = flip?.sequence[flip.step] ?? character;\n        const next = flip?.sequence[flip.step + 1] ?? character;\n        return (\n          <span\n            key={index}\n            aria-hidden=\"true\"\n            data-slot=\"split-flap-cell\"\n            data-flipping={flip ? \"\" : undefined}\n            className={cn(\n              \"relative box-content inline-block h-[1.2em] w-[1ch] text-center leading-[1.2em] tabular-nums\",\n              cellClassName\n            )}\n          >\n            <span data-slot=\"split-flap-character\" className={cn(flip && \"invisible\")}>\n              {blank(character)}\n            </span>\n            {flip ? (\n              <>\n                <Half data-slot=\"split-flap-half\" half=\"top\" character={next} />\n                <Half data-slot=\"split-flap-half\" half=\"bottom\" character={old} />\n                <Half\n                  key={`top-${flip.key}-${flip.step}`}\n                  data-slot=\"split-flap-flap\"\n                  half=\"top\"\n                  character={old}\n                  className=\"motion-safe:animate-split-flap-top origin-bottom [backface-visibility:hidden]\"\n                  style={{ animationDuration: `${half}ms`, animationDelay: `${flip.delay}ms` }}\n                />\n                <Half\n                  key={`bottom-${flip.key}-${flip.step}`}\n                  data-slot=\"split-flap-flap\"\n                  half=\"bottom\"\n                  character={next}\n                  className=\"motion-safe:animate-split-flap-bottom origin-top [backface-visibility:hidden]\"\n                  style={{\n                    animationDuration: `${half}ms`,\n                    animationDelay: `${flip.delay + half}ms`,\n                  }}\n                  onAnimationEnd={() => advance(index, flip.key)}\n                />\n              </>\n            ) : null}\n            {divider ? (\n              <span\n                data-slot=\"split-flap-divider\"\n                className=\"bg-border pointer-events-none absolute inset-x-0 top-1/2 h-px -translate-y-1/2\"\n              />\n            ) : null}\n          </span>\n        );\n      })}\n    </Tag>\n  );\n}\n\nexport { SplitFlap, type SplitFlapProps };\n",
      "type": "registry:ui"
    }
  ],
  "tailwind": {
    "config": {
      "theme": {
        "extend": {
          "keyframes": {
            "split-flap-top": {
              "from": {
                "transform": "perspective(var(--split-flap-perspective, 400px)) rotateX(0deg)"
              },
              "to": {
                "transform": "perspective(var(--split-flap-perspective, 400px)) rotateX(-90deg)"
              }
            },
            "split-flap-bottom": {
              "from": {
                "transform": "perspective(var(--split-flap-perspective, 400px)) rotateX(90deg)"
              },
              "to": {
                "transform": "perspective(var(--split-flap-perspective, 400px)) rotateX(0deg)"
              }
            }
          },
          "animation": {
            "split-flap-top": "split-flap-top 150ms ease-in both",
            "split-flap-bottom": "split-flap-bottom 150ms ease-out both"
          }
        }
      }
    }
  },
  "cssVars": {
    "theme": {
      "animate-split-flap-top": "split-flap-top 150ms ease-in both",
      "animate-split-flap-bottom": "split-flap-bottom 150ms ease-out both"
    }
  },
  "css": {
    "@keyframes split-flap-top": {
      "from": {
        "transform": "perspective(var(--split-flap-perspective, 400px)) rotateX(0deg)"
      },
      "to": {
        "transform": "perspective(var(--split-flap-perspective, 400px)) rotateX(-90deg)"
      }
    },
    "@keyframes split-flap-bottom": {
      "from": {
        "transform": "perspective(var(--split-flap-perspective, 400px)) rotateX(90deg)"
      },
      "to": {
        "transform": "perspective(var(--split-flap-perspective, 400px)) rotateX(0deg)"
      }
    }
  },
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}