{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-flap",
  "type": "registry:component",
  "title": "Split-Flap Board",
  "description": "A board of flaps that flip in 3D, character by character, until the phrase forms, like at a train station.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Text"
  ],
  "docs": "Installed in components/furin/split-flap/. Docs, live controls and props table: https://furin.dev/components/split-flap/",
  "files": [
    {
      "path": "registry/split-flap/SplitFlap.tsx",
      "content": "\"use client\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport \"./SplitFlap.css\";\n\nexport interface SplitFlapProps {\n  /** Frases que el tablero muestra en ciclo: un arreglo, o un solo texto con las frases separadas por \"|\". */\n  phrases?: string[] | string;\n  /** Texto fijo: si lo pasas, el tablero deja de ciclar y muestra este (vacío = usar las frases). */\n  value?: string;\n  /** Celdas por fila; las frases largas saltan de línea entre palabras. */\n  columns?: number;\n  /** Milisegundos que cada frase se queda quieta antes de cambiar a la siguiente. */\n  interval?: number;\n  /** Milisegundos que dura cada volteo de paleta. */\n  flipSpeed?: number;\n  /** Alto de cada celda en px a tamaño completo (se encoge si el tablero no cabe en su contenedor). */\n  size?: number;\n  /** Separación entre celdas en px. */\n  gap?: number;\n  /** Color de las paletas. */\n  tileColor?: string;\n  /** Color de las letras. */\n  letterColor?: string;\n  /** Clase CSS extra para el contenedor. */\n  className?: string;\n  /** Estilos en línea del contenedor. */\n  style?: CSSProperties;\n}\n\n/** Piezas de una celda: dos mitades fijas y dos hojas que voltean sobre la bisagra del centro. */\ninterface Celda {\n  /** Carácter que se ve ahora (ya asentado) o el que dejó el último volteo. */\n  actual: string;\n  /** Hacia dónde va el volteo en curso. */\n  siguiente: string;\n  /** Caracteres que faltan por pasar, el último es el de destino. */\n  cola: string[];\n  volteando: boolean;\n  /** Avance del volteo en curso, de 0 a 1. */\n  progreso: number;\n  /** Segundos de espera antes de empezar a voltear (escalonan las celdas). */\n  espera: number;\n  fijaArriba: HTMLElement;\n  fijaAbajo: HTMLElement;\n  hojaCae: HTMLElement;\n  hojaSube: HTMLElement;\n  letras: HTMLElement[];\n}\n\n/** Lo que el efecto de las frases necesita del tablero. */\ninterface Control {\n  /** Pone los caracteres de destino de cada celda; si `instantaneo`, sin animar. */\n  mostrar: (destino: string[], instantaneo: boolean) => void;\n  /** Se llama cada vez que todas las celdas quedan quietas. */\n  alQuedarQuieto: (() => void) | null;\n  visible: boolean;\n}\n\n/** El orden del tambor: las paletas pasan por los caracteres de en medio, como las de verdad. */\nconst TAMBOR = Array.from(\" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ÁÉÍÓÚÜÑ.,:-/&!?¿¡'+()%#$@\");\n/** Tope de volteos por celda: así un cambio largo no tarda una eternidad. */\nconst MAX_VOLTEOS = 14;\nconst MAX_COLUMNAS = 40;\n\nconst FRASES_POR_DEFECTO = [\"Welcome to Furin\", \"The cursor is the wind\", \"Platform 3 at 18:45\"];\n\nfunction normalizarFrases(phrases: string[] | string): string[] {\n  const lista = Array.isArray(phrases) ? phrases : phrases.split(/\\||\\n/);\n  return lista.map((frase) => frase.trim()).filter(Boolean);\n}\n\n/** Parte el texto en filas de a lo más `columnas` caracteres, saltando entre palabras. */\nfunction acomodar(texto: string, columnas: number): string[][] {\n  const filas: string[][] = [];\n  let fila: string[] = [];\n  for (const palabra of texto.toLocaleUpperCase(\"es\").split(/\\s+/).filter(Boolean)) {\n    let resto = Array.from(palabra);\n    while (resto.length > columnas) {\n      // Palabra más larga que la fila: se parte.\n      if (fila.length) filas.push(fila);\n      filas.push(resto.slice(0, columnas));\n      fila = [];\n      resto = resto.slice(columnas);\n    }\n    if (fila.length === 0) fila = resto;\n    else if (fila.length + 1 + resto.length <= columnas) fila = [...fila, \" \", ...resto];\n    else {\n      filas.push(fila);\n      fila = resto;\n    }\n  }\n  if (fila.length) filas.push(fila);\n  return filas;\n}\n\n/** Una cuadrícula de filas × columnas con el texto centrado en cada fila; lo que sobra se queda en blanco. */\nfunction distribuir(texto: string, columnas: number, filas: number): string[] {\n  const destino: string[] = new Array(columnas * filas).fill(\" \");\n  acomodar(texto, columnas)\n    .slice(0, filas)\n    .forEach((fila, r) => {\n      const inicio = Math.floor((columnas - fila.length) / 2);\n      fila.forEach((letra, c) => {\n        destino[r * columnas + inicio + c] = letra;\n      });\n    });\n  return destino;\n}\n\n/** Los caracteres por los que pasa una celda para ir de `de` a `a` (el último es `a`). */\nfunction planear(de: string, a: string): string[] {\n  if (de === a) return [];\n  const desde = TAMBOR.indexOf(de);\n  const hasta = TAMBOR.indexOf(a);\n  if (desde < 0 || hasta < 0) return [a]; // un carácter fuera del tambor: un solo volteo directo\n  const pasos = (hasta - desde + TAMBOR.length) % TAMBOR.length;\n  const cuantos = Math.min(pasos, MAX_VOLTEOS);\n  return Array.from({ length: cuantos }, (_, k) => TAMBOR[(hasta - cuantos + 1 + k + TAMBOR.length) % TAMBOR.length]);\n}\n\nconst escribir = (letras: HTMLElement[], caracter: string) => {\n  for (const letra of letras) letra.textContent = caracter === \" \" ? \"\" : caracter;\n};\n\n/** SplitFlap de estación de tren: cada paleta voltea en 3D, pasando por los caracteres de en medio, hasta formar la frase. */\nexport function SplitFlap({\n  phrases = FRASES_POR_DEFECTO,\n  value,\n  columns = 12,\n  interval = 4200,\n  flipSpeed = 70,\n  size = 64,\n  gap = 5,\n  tileColor = \"#111111\",\n  letterColor = \"#FFFFFF\",\n  className = \"\",\n  style,\n}: SplitFlapProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const control = useRef<Control | null>(null);\n  const live = useRef({ interval, flipSpeed });\n  const [leyendo, setLeyendo] = useState(\"\");\n\n  const cols = Math.round(Math.min(MAX_COLUMNAS, Math.max(3, columns)));\n  const lista = useMemo(() => normalizarFrases(phrases), [phrases]);\n  const fija = value ? value : null;\n  const clave = fija ?? lista.join(\"\\u0000\");\n  const textos = fija ? [fija] : lista;\n  // Todas las frases comparten el mismo número de filas: el tablero no cambia de tamaño al cambiar de frase.\n  const rows = Math.max(1, ...textos.map((texto) => acomodar(texto, cols).length));\n  const cellCount = cols * rows;\n\n  useEffect(() => {\n    live.current = { interval, flipSpeed };\n  }, [interval, flipSpeed]);\n\n  // Motor del tablero: celdas, volteos y bucle de animación.\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const reducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n\n    const celdas: Celda[] = Array.from(root.querySelectorAll<HTMLElement>(\".furin-split-flap__celda\")).map((el) => {\n      const [fijaArriba, fijaAbajo, hojaCae, hojaSube] = Array.from(el.children) as HTMLElement[];\n      return {\n        actual: \" \",\n        siguiente: \" \",\n        cola: [],\n        volteando: false,\n        progreso: 0,\n        espera: 0,\n        fijaArriba,\n        fijaAbajo,\n        hojaCae,\n        hojaSube,\n        letras: [fijaArriba, fijaAbajo, hojaCae, hojaSube].map((mitad) => mitad.firstElementChild as HTMLElement),\n      };\n    });\n\n    let visible = true;\n    let frameId = 0;\n    let lastNow = 0;\n\n    /** Deja la celda quieta mostrando `actual`, con las dos hojas escondidas. */\n    const asentar = (celda: Celda) => {\n      escribir(celda.letras, celda.actual);\n      celda.hojaCae.style.visibility = \"hidden\";\n      celda.hojaSube.style.visibility = \"hidden\";\n      celda.fijaAbajo.style.removeProperty(\"--furin-split-flap-sombra\");\n      celda.volteando = false;\n      celda.progreso = 0;\n    };\n\n    /** Empieza un volteo hacia `siguiente`: la mitad de arriba cae mostrando lo viejo y revela lo nuevo. */\n    const empezarVolteo = (celda: Celda, siguiente: string) => {\n      celda.siguiente = siguiente;\n      escribir([celda.fijaArriba.firstElementChild as HTMLElement, celda.hojaSube.firstElementChild as HTMLElement], siguiente);\n      escribir([celda.fijaAbajo.firstElementChild as HTMLElement, celda.hojaCae.firstElementChild as HTMLElement], celda.actual);\n      celda.hojaCae.style.transform = \"rotateX(0deg)\";\n      celda.hojaCae.style.setProperty(\"--furin-split-flap-sombra\", \"0\");\n      celda.hojaSube.style.transform = \"rotateX(90deg)\";\n      celda.hojaCae.style.visibility = \"visible\";\n      celda.hojaSube.style.visibility = \"hidden\";\n      celda.volteando = true;\n      celda.progreso = 0;\n    };\n\n    const pintar = (celda: Celda) => {\n      const p = celda.progreso;\n      if (p < 0.5) {\n        // Primera mitad: la hoja de arriba cae hacia el frente (acelera, como por gravedad).\n        const t = p * 2;\n        celda.hojaCae.style.transform = `rotateX(${(-90 * t * t).toFixed(1)}deg)`;\n        celda.hojaCae.style.setProperty(\"--furin-split-flap-sombra\", (t * 0.55).toFixed(2));\n        celda.fijaAbajo.style.setProperty(\"--furin-split-flap-sombra\", (t * 0.4).toFixed(2));\n      } else {\n        // Segunda mitad: la hoja de abajo, con la letra nueva, termina de caer y frena al asentarse.\n        if (celda.hojaCae.style.visibility !== \"hidden\") {\n          celda.hojaCae.style.visibility = \"hidden\";\n          celda.hojaSube.style.visibility = \"visible\";\n          celda.fijaAbajo.style.removeProperty(\"--furin-split-flap-sombra\");\n        }\n        const t = (p - 0.5) * 2;\n        const frenado = 1 - (1 - t) * (1 - t);\n        celda.hojaSube.style.transform = `rotateX(${(90 * (1 - frenado)).toFixed(1)}deg)`;\n        celda.hojaSube.style.setProperty(\"--furin-split-flap-sombra\", ((1 - frenado) * 0.5).toFixed(2));\n      }\n    };\n\n    /** Avanza una celda `dt` segundos. Devuelve false cuando ya quedó quieta. */\n    const avanzar = (celda: Celda, dt: number): boolean => {\n      if (celda.espera > 0) {\n        celda.espera -= dt;\n        if (celda.espera > 0) return true;\n        dt = -celda.espera;\n        celda.espera = 0;\n      }\n      if (!celda.volteando) {\n        if (celda.cola.length === 0) return false;\n        empezarVolteo(celda, celda.cola.shift()!);\n      }\n      celda.progreso += dt / (Math.max(10, live.current.flipSpeed) / 1000);\n      while (celda.progreso >= 1) {\n        celda.actual = celda.siguiente;\n        if (celda.cola.length === 0) {\n          asentar(celda);\n          return false;\n        }\n        const resto = celda.progreso - 1;\n        empezarVolteo(celda, celda.cola.shift()!);\n        celda.progreso = resto;\n      }\n      pintar(celda);\n      return true;\n    };\n\n    const frame = (now: number) => {\n      frameId = requestAnimationFrame(frame);\n      const dt = Math.min((now - lastNow) / 1000, 0.05);\n      lastNow = now;\n      let busy = false;\n      for (const celda of celdas) if (avanzar(celda, dt)) busy = true;\n      if (!busy) {\n        stop();\n        api.alQuedarQuieto?.();\n      }\n    };\n    const start = () => {\n      if (frameId || !visible) return;\n      lastNow = performance.now();\n      frameId = requestAnimationFrame(frame);\n    };\n    const stop = () => {\n      cancelAnimationFrame(frameId);\n      frameId = 0;\n    };\n\n    const api: Control = {\n      visible: true,\n      alQuedarQuieto: null,\n      mostrar: (destino, instantaneo) => {\n        celdas.forEach((celda, i) => {\n          const meta = destino[i] ?? \" \";\n          if (instantaneo || reducedMotion.matches) {\n            celda.cola = [];\n            celda.actual = celda.siguiente = meta;\n            asentar(celda);\n            return;\n          }\n          // Si ya va a medio volteo, termina ese y de ahí sigue hacia el nuevo destino.\n          celda.cola = planear(celda.volteando ? celda.siguiente : celda.actual, meta);\n          if (!celda.volteando && celda.cola.length) {\n            // Las celdas arrancan una tras otra, de izquierda a derecha, con un poco de azar.\n            const columna = i % cols;\n            const fila = Math.floor(i / cols);\n            celda.espera = columna * 0.02 + fila * 0.1 + Math.random() * 0.06;\n          }\n        });\n        if (instantaneo || reducedMotion.matches) {\n          stop();\n          api.alQuedarQuieto?.();\n        } else start();\n      },\n    };\n    control.current = api;\n    celdas.forEach(asentar); // arranca en blanco: la primera frase entra volteando\n\n    const visibilityObserver = new IntersectionObserver(([entry]) => {\n      visible = api.visible = entry.isIntersecting;\n      if (!visible) return stop();\n      if (celdas.some((celda) => celda.volteando || celda.cola.length)) start();\n      else api.alQuedarQuieto?.();\n    });\n    visibilityObserver.observe(root);\n\n    return () => {\n      stop();\n      visibilityObserver.disconnect();\n      control.current = null;\n    };\n  }, [cellCount, cols]);\n\n  // Qué frase se ve: una fija (value) o un ciclo por la lista.\n  useEffect(() => {\n    const motor = control.current;\n    if (!motor) return;\n    const cambio = (texto: string) => {\n      setLeyendo(texto);\n      motor.mostrar(distribuir(texto, cols, rows), false);\n    };\n\n    if (fija) {\n      motor.alQuedarQuieto = null;\n      cambio(fija);\n      return () => {\n        motor.alQuedarQuieto = null;\n      };\n    }\n\n    let indice = 0;\n    let temporizador = 0;\n    const siguiente = () => {\n      temporizador = 0;\n      if (!motor.visible) return; // fuera de pantalla no avanza; al volver, alQuedarQuieto lo retoma\n      indice = (indice + 1) % lista.length;\n      cambio(lista[indice]);\n    };\n    motor.alQuedarQuieto =\n      lista.length > 1\n        ? () => {\n            if (temporizador) return;\n            temporizador = window.setTimeout(siguiente, Math.max(300, live.current.interval));\n          }\n        : null;\n    cambio(lista[0] ?? \"\");\n    return () => {\n      clearTimeout(temporizador);\n      motor.alQuedarQuieto = null;\n    };\n  }, [clave, cols, rows, cellCount]);\n\n  const vars = {\n    \"--furin-split-flap-columnas\": cols,\n    \"--furin-split-flap-tamano\": `${size}px`,\n    \"--furin-split-flap-hueco\": `${gap}px`,\n    \"--furin-split-flap-ficha\": tileColor,\n    \"--furin-split-flap-letra\": letterColor,\n  } as CSSProperties;\n\n  const celdas = Array.from({ length: cellCount }, (_, i) => (\n    <span key={i} className=\"furin-split-flap__celda\" aria-hidden=\"true\">\n      <span className=\"furin-split-flap__mitad furin-split-flap__mitad--arriba\">\n        <span className=\"furin-split-flap__letra\" />\n      </span>\n      <span className=\"furin-split-flap__mitad furin-split-flap__mitad--abajo\">\n        <span className=\"furin-split-flap__letra\" />\n      </span>\n      <span className=\"furin-split-flap__mitad furin-split-flap__mitad--arriba furin-split-flap__hoja-cae\">\n        <span className=\"furin-split-flap__letra\" />\n      </span>\n      <span className=\"furin-split-flap__mitad furin-split-flap__mitad--abajo furin-split-flap__hoja-sube\">\n        <span className=\"furin-split-flap__letra\" />\n      </span>\n    </span>\n  ));\n\n  return (\n    <div ref={rootRef} className={`furin-split-flap ${className}`.trim()} style={{ ...vars, ...style }}>\n      {/* Texto real para lectores de pantalla: las celdas de abajo se ocultan. */}\n      <span className=\"furin-split-flap__lectura\" aria-live={fija ? \"polite\" : \"off\"}>\n        {leyendo || fija || lista[0] || \"\"}\n      </span>\n      {celdas}\n    </div>\n  );\n}\n\nexport default SplitFlap;\n",
      "type": "registry:component",
      "target": "components/furin/split-flap/SplitFlap.tsx"
    },
    {
      "path": "registry/split-flap/SplitFlap.css",
      "content": ".furin-split-flap {\n  --furin-split-flap-columnas: 12;\n  --furin-split-flap-tamano: 64px;\n  --furin-split-flap-hueco: 5px;\n  --furin-split-flap-ficha: #111111;\n  --furin-split-flap-letra: #ffffff;\n  /* Cada celda es 1 de ancho por 1.4 de alto. */\n  --furin-split-flap-ancho: calc(var(--furin-split-flap-tamano) / 1.4);\n\n  position: relative;\n  box-sizing: border-box;\n  display: grid;\n  grid-template-columns: repeat(var(--furin-split-flap-columnas), minmax(0, 1fr));\n  gap: var(--furin-split-flap-hueco);\n  /* A tamaño completo mide lo que suman sus celdas; si no cabe en su contenedor, se encoge entero. */\n  width: min(\n    100%,\n    calc(\n      var(--furin-split-flap-columnas) * var(--furin-split-flap-ancho) + (var(--furin-split-flap-columnas) - 1) * var(--furin-split-flap-hueco)\n    )\n  );\n  /* Contenedor de consultas: la letra de cada celda se mide contra el ancho del tablero. */\n  container-type: inline-size;\n  user-select: none;\n}\n\n/* Copia invisible para lectores de pantalla: siempre dice la frase completa. */\n.furin-split-flap__lectura {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  overflow: hidden;\n  clip-path: inset(50%);\n  white-space: nowrap;\n}\n\n.furin-split-flap__celda {\n  position: relative;\n  display: block;\n  aspect-ratio: 1 / 1.4;\n  /* Ancho de la celda × 1.26: la letra ocupa cerca de dos tercios de la altura. */\n  font-size: calc((100cqw - (var(--furin-split-flap-columnas) - 1) * var(--furin-split-flap-hueco)) / var(--furin-split-flap-columnas) * 1.26);\n  font-family: ui-monospace, \"SF Mono\", \"JetBrains Mono\", Menlo, Consolas, monospace;\n  font-weight: 700;\n  font-variant-numeric: tabular-nums;\n  line-height: 1;\n  color: var(--furin-split-flap-letra);\n  border-radius: 0.14em;\n  box-shadow:\n    0 0.06em 0.14em rgba(0, 0, 0, 0.45),\n    0 0 0 1px rgba(255, 255, 255, 0.07);\n  perspective: 4.5em;\n}\n\n/* La bisagra: una línea oscura en el centro, con un hilo de luz debajo. */\n.furin-split-flap__celda::after {\n  content: \"\";\n  position: absolute;\n  z-index: 5;\n  left: 0;\n  right: 0;\n  top: 50%;\n  height: max(1px, 0.035em);\n  margin-top: calc(max(1px, 0.035em) / -2);\n  background: rgba(0, 0, 0, 0.7);\n  box-shadow: 0 max(1px, 0.02em) 0 rgba(255, 255, 255, 0.07);\n}\n\n.furin-split-flap__mitad {\n  position: absolute;\n  left: 0;\n  right: 0;\n  height: 50%;\n  overflow: hidden;\n  background: var(--furin-split-flap-ficha);\n}\n\n/* La mitad de arriba recibe más luz. */\n.furin-split-flap__mitad--arriba {\n  top: 0;\n  border-radius: 0.14em 0.14em 0 0;\n  background: color-mix(in srgb, var(--furin-split-flap-ficha), #ffffff 7%);\n  transform-origin: 50% 100%;\n}\n\n.furin-split-flap__mitad--abajo {\n  bottom: 0;\n  border-radius: 0 0 0.14em 0.14em;\n  transform-origin: 50% 0;\n}\n\n/* Sombra que sube mientras la hoja cae (la controla el motor con --furin-split-flap-sombra). */\n.furin-split-flap__mitad::after {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  background: #000000;\n  opacity: var(--furin-split-flap-sombra, 0);\n  pointer-events: none;\n}\n\n/* La letra mide la celda entera; cada mitad enseña la suya. */\n.furin-split-flap__letra {\n  position: absolute;\n  left: 0;\n  right: 0;\n  height: 200%;\n  display: grid;\n  place-items: center;\n  padding-bottom: 0.03em;\n}\n\n.furin-split-flap__mitad--arriba > .furin-split-flap__letra {\n  top: 0;\n}\n\n.furin-split-flap__mitad--abajo > .furin-split-flap__letra {\n  bottom: 0;\n}\n\n/* Las hojas voltean encima de las mitades fijas; el motor las muestra solo mientras giran. */\n.furin-split-flap__hoja-cae,\n.furin-split-flap__hoja-sube {\n  visibility: hidden;\n  backface-visibility: hidden;\n}\n\n.furin-split-flap__hoja-cae {\n  z-index: 3;\n}\n\n.furin-split-flap__hoja-sube {\n  z-index: 4;\n  transform: rotateX(90deg);\n}\n",
      "type": "registry:file",
      "target": "components/furin/split-flap/SplitFlap.css"
    }
  ]
}
