{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "SplitFlapText-TS-CSS",
	"title": "SplitFlapText",
	"description": "Mechanical split-flap departure board that clacks through to each new phrase.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "SplitFlapText.css",
			"target": "@components/SplitFlapText.css",
			"content": ".split-flap-text {\n  display: inline-flex;\n  align-items: center;\n  gap: var(--split-flap-gap, 6px);\n  color: var(--split-flap-text-color, #f8fafc);\n  font-family: 'SFMono-Regular', 'Roboto Mono', 'Cascadia Code', 'Liberation Mono', Menlo, monospace;\n  font-size: var(--split-flap-font-size, 52px);\n  font-weight: 760;\n  line-height: 1;\n  letter-spacing: 0.035em;\n  white-space: pre;\n  user-select: none;\n  font-variant-numeric: tabular-nums;\n}\n\n.split-flap-text__tile {\n  position: relative;\n  width: 0.78em;\n  height: 1.08em;\n  overflow: hidden;\n  border-radius: var(--split-flap-radius, 8px);\n  background:\n    radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.16), transparent 44%),\n    linear-gradient(\n      180deg,\n      color-mix(in srgb, var(--split-flap-tile-color, #111827) 82%, white),\n      var(--split-flap-tile-color, #111827)\n    );\n  box-shadow:\n    0 0.035em 0.08em rgba(255, 255, 255, 0.08) inset,\n    0 -0.05em 0.1em rgba(0, 0, 0, 0.38) inset,\n    0 0.16em 0.38em rgba(0, 0, 0, 0.28);\n  perspective: 520px;\n  transform-style: preserve-3d;\n  isolation: isolate;\n}\n\n.split-flap-text__tile::before {\n  content: '';\n  position: absolute;\n  z-index: 8;\n  top: calc(50% - 0.5px);\n  left: 0;\n  width: 100%;\n  height: 1px;\n  background: linear-gradient(\n    90deg,\n    transparent,\n    rgba(255, 255, 255, 0.18) 18%,\n    rgba(0, 0, 0, 0.64) 50%,\n    rgba(255, 255, 255, 0.14) 82%,\n    transparent\n  );\n  box-shadow:\n    0 -1px 0 rgba(255, 255, 255, 0.08),\n    0 1px 0 rgba(0, 0, 0, 0.5);\n  pointer-events: none;\n}\n\n.split-flap-text__tile::after {\n  content: '';\n  position: absolute;\n  inset: 0;\n  z-index: 9;\n  border: 1px solid rgba(255, 255, 255, 0.08);\n  border-radius: inherit;\n  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;\n  pointer-events: none;\n}\n\n.split-flap-text__half,\n.split-flap-text__flap {\n  position: absolute;\n  left: 0;\n  width: 100%;\n  height: 50%;\n  overflow: hidden;\n  background:\n    linear-gradient(180deg, rgba(255, 255, 255, 0.07), transparent 34%), var(--split-flap-tile-color, #111827);\n  backface-visibility: hidden;\n}\n\n.split-flap-text__half--top,\n.split-flap-text__flap--front {\n  top: 0;\n}\n\n.split-flap-text__half--bottom,\n.split-flap-text__flap--back {\n  bottom: 0;\n}\n\n.split-flap-text__half--bottom,\n.split-flap-text__flap--back {\n  background:\n    linear-gradient(0deg, rgba(255, 255, 255, 0.06), transparent 38%),\n    color-mix(in srgb, var(--split-flap-tile-color, #111827) 92%, black);\n}\n\n.split-flap-text__char {\n  position: absolute;\n  left: 0;\n  width: 100%;\n  height: 200%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--split-flap-text-color, #f8fafc);\n  text-shadow:\n    0 0.025em 0 rgba(255, 255, 255, 0.16),\n    0 0.09em 0.16em rgba(0, 0, 0, 0.42);\n}\n\n.split-flap-text__half--top .split-flap-text__char,\n.split-flap-text__flap--front .split-flap-text__char {\n  top: 0;\n}\n\n.split-flap-text__half--bottom .split-flap-text__char,\n.split-flap-text__flap--back .split-flap-text__char {\n  bottom: 0;\n}\n\n.split-flap-text__flap {\n  z-index: 6;\n  will-change: transform, filter;\n  transform-style: preserve-3d;\n}\n\n.split-flap-text__flap--front {\n  transform-origin: center bottom;\n  animation: split-flap-front var(--split-flap-flip-duration, 0.12s) cubic-bezier(0.23, 1, 0.32, 1) both;\n}\n\n.split-flap-text__flap--back {\n  transform-origin: center top;\n  transform: rotateX(90deg);\n  animation: split-flap-back var(--split-flap-flip-duration, 0.12s) cubic-bezier(0.23, 1, 0.32, 1) both;\n}\n\n@keyframes split-flap-front {\n  0% {\n    transform: rotateX(0deg);\n    filter: brightness(1.08);\n  }\n\n  100% {\n    transform: rotateX(-90deg);\n    filter: brightness(0.52);\n  }\n}\n\n@keyframes split-flap-back {\n  0%,\n  45% {\n    transform: rotateX(90deg);\n    filter: brightness(0.58);\n  }\n\n  100% {\n    transform: rotateX(0deg);\n    filter: brightness(1);\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .split-flap-text__flap {\n    animation: none !important;\n  }\n}\n"
		},
		{
			"type": "registry:component",
			"path": "SplitFlapText.tsx",
			"content": "import { CSSProperties, HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react';\nimport './SplitFlapText.css';\n\ntype TileState = {\n  current: string;\n  next: string;\n  flipping: boolean;\n  tick: number;\n};\n\ntype AnimationPlan = {\n  index: number;\n  from: string;\n  target: string;\n  sequence: string[];\n  start: number;\n  step: number;\n  done: boolean;\n};\n\ntype TileUpdate = {\n  index: number;\n  current: string;\n  next: string;\n  done: boolean;\n};\n\nexport interface SplitFlapTextProps extends HTMLAttributes<HTMLDivElement> {\n  words?: string[];\n  text?: string;\n  flipDuration?: number;\n  stagger?: number;\n  cycleDelay?: number;\n  charset?: 'alpha' | 'alphanumeric' | 'numeric' | (string & {});\n  flipsPerChar?: number;\n  tileColor?: string;\n  textColor?: string;\n  tileRadius?: number | string;\n  gap?: number | string;\n  fontSize?: number | string;\n  loop?: boolean;\n  padTo?: number;\n}\n\nconst DEFAULT_WORDS = ['LAUNCH READY', 'SYNC ONLINE', 'SIGNAL LIVE'];\n\nconst CHARSETS: Record<string, string> = {\n  alpha: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',\n  alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',\n  numeric: '0123456789'\n};\n\nconst toCssUnit = (value: number | string) => (typeof value === 'number' ? `${value}px` : value);\n\nconst resolveCharset = (charset: SplitFlapTextProps['charset']) => {\n  if (charset && CHARSETS[charset]) return CHARSETS[charset];\n  return typeof charset === 'string' && charset.length > 0 ? charset : CHARSETS.alphanumeric;\n};\n\nconst normalizePhrase = (phrase: string, width: number) => {\n  const safe = String(phrase ?? '');\n  return safe.padEnd(width, ' ').slice(0, width);\n};\n\nconst createTiles = (phrase: string): TileState[] =>\n  phrase.split('').map(char => ({\n    current: char,\n    next: char,\n    flipping: false,\n    tick: 0\n  }));\n\nconst sampleChar = (charset: string) => charset.charAt(Math.floor(Math.random() * charset.length)) || ' ';\n\nconst buildSequence = (target: string, flips: number, charset: string) => {\n  const steps: string[] = [];\n  for (let i = 0; i < flips; i += 1) {\n    steps.push(sampleChar(charset));\n  }\n  steps.push(target);\n  return steps;\n};\n\nconst usePrefersReducedMotion = () => {\n  const [prefersReduced, setPrefersReduced] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === 'undefined' || !window.matchMedia) return;\n\n    const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');\n    const handleChange = () => setPrefersReduced(mediaQuery.matches);\n\n    handleChange();\n    mediaQuery.addEventListener('change', handleChange);\n\n    return () => mediaQuery.removeEventListener('change', handleChange);\n  }, []);\n\n  return prefersReduced;\n};\n\nconst SplitFlapText = ({\n  words = ['LAUNCH READY', 'SYNC ONLINE', 'SIGNAL LIVE'],\n  text,\n  flipDuration = 0.12,\n  stagger = 0.06,\n  cycleDelay = 2400,\n  charset = 'alphanumeric',\n  flipsPerChar = 8,\n  tileColor = '#111827',\n  textColor = '#f8fafc',\n  tileRadius = 8,\n  gap = 6,\n  fontSize = 52,\n  loop = true,\n  padTo = 12,\n  className = '',\n  style = {},\n  ...props\n}: SplitFlapTextProps) => {\n  const prefersReducedMotion = usePrefersReducedMotion();\n  const rafRef = useRef<number | null>(null);\n  const cycleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const currentTextRef = useRef('');\n\n  const sourceWords = Array.isArray(words) && words.length > 0 ? words : DEFAULT_WORDS;\n  const phrasesKey = typeof text === 'string' ? text : sourceWords.map(word => String(word ?? '')).join('\\u001f');\n  const phrases = useMemo(() => phrasesKey.split('\\u001f'), [phrasesKey]);\n\n  const width = useMemo(() => {\n    const longest = phrases.reduce((max, phrase) => Math.max(max, phrase.length), 1);\n    return Math.max(1, Math.ceil(Number(padTo) || 0), longest);\n  }, [padTo, phrases]);\n\n  const normalizedPhrases = useMemo(() => phrases.map(phrase => normalizePhrase(phrase, width)), [phrases, width]);\n\n  const [tiles, setTiles] = useState<TileState[]>(() => createTiles(normalizedPhrases[0] || ''));\n\n  useEffect(() => {\n    const clearAnimation = () => {\n      if (rafRef.current) {\n        cancelAnimationFrame(rafRef.current);\n        rafRef.current = null;\n      }\n\n      if (cycleTimerRef.current) {\n        clearTimeout(cycleTimerRef.current);\n        cycleTimerRef.current = null;\n      }\n    };\n\n    clearAnimation();\n\n    const firstPhrase = normalizedPhrases[0] || '';\n    currentTextRef.current = firstPhrase;\n    setTiles(createTiles(firstPhrase));\n\n    if (normalizedPhrases.length <= 1 || typeof window === 'undefined') {\n      return clearAnimation;\n    }\n\n    let phraseIndex = 0;\n    let cancelled = false;\n\n    const safeFlipMs = Math.max(40, (Number(flipDuration) || 0.12) * 1000);\n    const safeStaggerMs = Math.max(0, (Number(stagger) || 0) * 1000);\n    const safeCycleDelay = Math.max(400, Number(cycleDelay) || 2400);\n    const safeFlips = Math.max(0, Math.floor(Number(flipsPerChar) || 0));\n    const activeCharset = resolveCharset(charset);\n\n    const animateTo = (targetPhrase: string) => {\n      if (prefersReducedMotion) {\n        currentTextRef.current = targetPhrase;\n        setTiles(createTiles(targetPhrase));\n        return 0;\n      }\n\n      const fromPhrase = normalizePhrase(currentTextRef.current, width);\n      const targetChars = targetPhrase.split('');\n\n      const plans = targetChars\n        .map<AnimationPlan | null>((targetChar, index) => {\n          const fromChar = fromPhrase[index] || ' ';\n          if (fromChar === targetChar) return null;\n\n          return {\n            index,\n            from: fromChar,\n            target: targetChar,\n            sequence: buildSequence(targetChar, safeFlips, activeCharset),\n            start: index * safeStaggerMs,\n            step: -1,\n            done: false\n          };\n        })\n        .filter((plan): plan is AnimationPlan => plan !== null);\n\n      if (!plans.length) {\n        currentTextRef.current = targetPhrase;\n        setTiles(createTiles(targetPhrase));\n        return 0;\n      }\n\n      const totalDuration = plans.reduce(\n        (max, plan) => Math.max(max, plan.start + plan.sequence.length * safeFlipMs),\n        0\n      );\n      const startedAt = performance.now();\n\n      const updateTiles = (updates: TileUpdate[]) => {\n        setTiles(previous => {\n          const nextTiles = [...previous];\n          updates.forEach(update => {\n            const tile = nextTiles[update.index];\n            if (!tile) return;\n\n            nextTiles[update.index] = {\n              current: update.current,\n              next: update.next,\n              flipping: !update.done,\n              tick: tile.tick + 1\n            };\n          });\n          return nextTiles;\n        });\n      };\n\n      const tick = (now: number) => {\n        if (cancelled) return;\n\n        const elapsed = now - startedAt;\n        const updates: TileUpdate[] = [];\n        let shouldContinue = false;\n\n        plans.forEach(plan => {\n          const localElapsed = elapsed - plan.start;\n\n          if (localElapsed < 0) {\n            shouldContinue = true;\n            return;\n          }\n\n          const step = Math.floor(localElapsed / safeFlipMs);\n\n          if (step < plan.sequence.length) {\n            shouldContinue = true;\n\n            if (step !== plan.step) {\n              plan.step = step;\n              updates.push({\n                index: plan.index,\n                current: step === 0 ? plan.from : plan.sequence[step - 1],\n                next: plan.sequence[step],\n                done: false\n              });\n            }\n          } else if (!plan.done) {\n            plan.done = true;\n            updates.push({\n              index: plan.index,\n              current: plan.target,\n              next: plan.target,\n              done: true\n            });\n          }\n        });\n\n        if (updates.length > 0) updateTiles(updates);\n\n        if (shouldContinue) {\n          rafRef.current = requestAnimationFrame(tick);\n        } else {\n          currentTextRef.current = targetPhrase;\n          rafRef.current = null;\n        }\n      };\n\n      rafRef.current = requestAnimationFrame(tick);\n      return totalDuration;\n    };\n\n    const scheduleNext = (delay: number) => {\n      cycleTimerRef.current = window.setTimeout(() => {\n        if (cancelled) return;\n\n        const nextIndex = phraseIndex + 1;\n\n        if (nextIndex >= normalizedPhrases.length && !loop) return;\n\n        phraseIndex = nextIndex % normalizedPhrases.length;\n        const animationDuration = animateTo(normalizedPhrases[phraseIndex]);\n        scheduleNext(safeCycleDelay + animationDuration);\n      }, delay);\n    };\n\n    scheduleNext(safeCycleDelay);\n\n    return () => {\n      cancelled = true;\n      clearAnimation();\n    };\n  }, [normalizedPhrases, width, loop, cycleDelay, flipDuration, stagger, flipsPerChar, charset, prefersReducedMotion]);\n\n  const settledText = tiles\n    .map(tile => tile.current)\n    .join('')\n    .trimEnd();\n  const componentStyle: CSSProperties & Record<string, string | number | undefined> = {\n    '--split-flap-tile-color': tileColor,\n    '--split-flap-text-color': textColor,\n    '--split-flap-radius': toCssUnit(tileRadius),\n    '--split-flap-gap': toCssUnit(gap),\n    '--split-flap-font-size': toCssUnit(fontSize),\n    '--split-flap-flip-duration': `${Math.max(0.04, Number(flipDuration) || 0.12)}s`,\n    ...style\n  };\n\n  return (\n    <div\n      className={`split-flap-text ${className}`.trim()}\n      style={componentStyle}\n      role=\"text\"\n      aria-label={settledText || undefined}\n      {...props}\n    >\n      {tiles.map((tile, index) => (\n        <span className=\"split-flap-text__tile\" aria-hidden=\"true\" key={`${index}-${tiles.length}`}>\n          <span className=\"split-flap-text__half split-flap-text__half--top\">\n            <span className=\"split-flap-text__char\">{tile.current === ' ' ? '\\u00A0' : tile.current}</span>\n          </span>\n          <span className=\"split-flap-text__half split-flap-text__half--bottom\">\n            <span className=\"split-flap-text__char\">{tile.flipping ? tile.next : tile.current}</span>\n          </span>\n\n          {tile.flipping && (\n            <>\n              <span className=\"split-flap-text__flap split-flap-text__flap--front\" key={`front-${index}-${tile.tick}`}>\n                <span className=\"split-flap-text__char\">{tile.current === ' ' ? '\\u00A0' : tile.current}</span>\n              </span>\n              <span className=\"split-flap-text__flap split-flap-text__flap--back\" key={`back-${index}-${tile.tick}`}>\n                <span className=\"split-flap-text__char\">{tile.next === ' ' ? '\\u00A0' : tile.next}</span>\n              </span>\n            </>\n          )}\n        </span>\n      ))}\n    </div>\n  );\n};\n\nexport default SplitFlapText;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}