{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "decipher-text",
  "type": "registry:component",
  "title": "Decipher Text",
  "description": "Text that appears by deciphering itself: random characters settle into place letter by letter.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Text"
  ],
  "docs": "Installed in components/furin/decipher-text/. Docs, live controls and props table: https://furin.dev/components/decipher-text/",
  "files": [
    {
      "path": "registry/decipher-text/DecipherText.tsx",
      "content": "\"use client\";\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport \"./DecipherText.css\";\n\nexport type DecipherDirection = \"left\" | \"right\" | \"center\" | \"random\";\nexport type DecipherTrigger = \"mount\" | \"cursor\" | \"both\";\n\nexport interface DecipherTextProps {\n  /** Texto final que se revela. */\n  text?: string;\n  /** Letras que se asientan por segundo. */\n  speed?: number;\n  /** Milisegundos de desorden antes de que se asiente la primera letra. */\n  delay?: number;\n  /** Alfabeto del que salen los caracteres aleatorios. */\n  scrambleChars?: string;\n  /** Por dónde empiezan a asentarse las letras. */\n  revealDirection?: DecipherDirection;\n  /** Cuándo se descifra: al montar, al pasar el cursor, o ambos. */\n  trigger?: DecipherTrigger;\n  /** Color de las letras ya asentadas (por defecto hereda el del texto). */\n  color?: string;\n  /** Color de las letras que aún se mezclan (por defecto, el mismo texto atenuado). */\n  scrambleColor?: string;\n  /** Clase CSS extra para el contenedor. */\n  className?: string;\n  /** Estilos en línea del contenedor (aquí va el tamaño y la fuente). */\n  style?: CSSProperties;\n}\n\nconst ALFABETO_POR_DEFECTO = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&*+<>/?!\";\n/** Cada cuántos milisegundos cambian los caracteres que aún no se asientan. */\nconst REFRESCO_MS = 45;\n\n/** Para cada posición del texto: en qué turno se asienta (0 = la primera en asentarse). */\nfunction calcularTurnos(letras: string[], direccion: DecipherDirection): number[] {\n  const posiciones: number[] = [];\n  letras.forEach((letra, i) => {\n    if (letra.trim() !== \"\") posiciones.push(i);\n  });\n\n  const centro = (letras.length - 1) / 2;\n  if (direccion === \"right\") posiciones.reverse();\n  if (direccion === \"center\") posiciones.sort((a, b) => Math.abs(a - centro) - Math.abs(b - centro));\n  if (direccion === \"random\") {\n    for (let i = posiciones.length - 1; i > 0; i--) {\n      const j = Math.floor(Math.random() * (i + 1));\n      [posiciones[i], posiciones[j]] = [posiciones[j], posiciones[i]];\n    }\n  }\n\n  const turnos: number[] = letras.map(() => -1); // los espacios nunca se mezclan\n  posiciones.forEach((posicion, turno) => {\n    turnos[posicion] = turno;\n  });\n  return turnos;\n}\n\nexport function DecipherText({\n  text = \"Decipher the message\",\n  speed = 16,\n  delay = 0,\n  scrambleChars = ALFABETO_POR_DEFECTO,\n  revealDirection = \"left\",\n  trigger = \"both\",\n  color,\n  scrambleColor,\n  className = \"\",\n  style,\n}: DecipherTextProps) {\n  // null = todo asentado; si no, un arreglo con el carácter aleatorio de cada\n  // posición (null en las letras que ya quedaron en su lugar).\n  const [mezcla, setMezcla] = useState<(string | null)[] | null>(null);\n  const cuadro = useRef(0);\n\n  const detener = useCallback(() => {\n    cancelAnimationFrame(cuadro.current);\n    cuadro.current = 0;\n  }, []);\n\n  const descifrar = useCallback(() => {\n    detener();\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n      setMezcla(null);\n      return;\n    }\n\n    const letras = Array.from(text);\n    const turnos = calcularTurnos(letras, revealDirection);\n    const alfabeto = Array.from(scrambleChars.length > 0 ? scrambleChars : ALFABETO_POR_DEFECTO);\n    const porLetra = 1000 / Math.max(speed, 0.1);\n    const mezclables = turnos.filter((turno) => turno >= 0).length;\n    const inicio = performance.now() + delay;\n    let ultimoRefresco = -Infinity;\n    let ultimasAsentadas = -1;\n\n    const aleatorio = () => alfabeto[Math.floor(Math.random() * alfabeto.length)];\n\n    const paso = (ahora: number) => {\n      const asentadas = Math.max(0, Math.floor((ahora - inicio) / porLetra));\n      if (asentadas >= mezclables) {\n        cuadro.current = 0;\n        setMezcla(null);\n        return;\n      }\n      // Solo se vuelve a dibujar cuando toca refrescar o se asentó una letra nueva.\n      if (ahora - ultimoRefresco >= REFRESCO_MS || asentadas !== ultimasAsentadas) {\n        ultimoRefresco = ahora;\n        ultimasAsentadas = asentadas;\n        setMezcla(turnos.map((turno) => (turno < 0 || turno < asentadas ? null : aleatorio())));\n      }\n      cuadro.current = requestAnimationFrame(paso);\n    };\n\n    // Primer cuadro ya mezclado para que no se vea el texto final antes de empezar.\n    setMezcla(turnos.map((turno) => (turno < 0 ? null : aleatorio())));\n    cuadro.current = requestAnimationFrame(paso);\n  }, [text, speed, delay, scrambleChars, revealDirection, detener]);\n\n  // useLayoutEffect para empezar mezclado antes del primer dibujo (React 19 no avisa en SSR).\n  useLayoutEffect(() => {\n    if (trigger !== \"cursor\") descifrar();\n    else setMezcla(null);\n    return detener;\n  }, [trigger, descifrar, detener]);\n\n  const alEntrarElCursor = () => {\n    if (trigger !== \"mount\" && cuadro.current === 0) descifrar();\n  };\n\n  // Se parte en palabras para que el texto largo pueda saltar de línea entre palabras.\n  const piezas = text.split(/(\\s+)/).filter((pieza) => pieza !== \"\");\n  let indice = 0;\n\n  const variables = {\n    \"--furin-decipher-text-color\": color,\n    \"--furin-decipher-text-mezcla\": scrambleColor,\n    ...style,\n  } as CSSProperties;\n\n  return (\n    <span className={`furin-decipher-text ${className}`.trim()} style={variables} onPointerEnter={alEntrarElCursor}>\n      <span className=\"furin-decipher-text__lectura\">{text}</span>\n      <span aria-hidden=\"true\">\n        {piezas.map((pieza, k) => {\n          const letras = Array.from(pieza);\n          const primera = indice;\n          indice += letras.length;\n          if (pieza.trim() === \"\") return pieza;\n          return (\n            <span key={k} className=\"furin-decipher-text__palabra\">\n              {letras.map((letra, j) => (\n                <span key={j} className=\"furin-decipher-text__letra\" data-mezcla={mezcla?.[primera + j] ?? undefined}>\n                  {letra}\n                </span>\n              ))}\n            </span>\n          );\n        })}\n      </span>\n    </span>\n  );\n}\n\nexport default DecipherText;\n",
      "type": "registry:component",
      "target": "components/furin/decipher-text/DecipherText.tsx"
    },
    {
      "path": "registry/decipher-text/DecipherText.css",
      "content": ".furin-decipher-text {\n  position: relative;\n  display: inline-block;\n  color: var(--furin-decipher-text-color, inherit);\n  /* Las ligaduras (fi, ff...) se rompen al cambiar letra por letra. */\n  font-variant-ligatures: none;\n}\n\n/* Copia invisible para lectores de pantalla: siempre dice el texto final. */\n.furin-decipher-text__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/* Una palabra nunca se parte a la mitad, aunque cada letra sea su propio elemento. */\n.furin-decipher-text__palabra {\n  white-space: nowrap;\n}\n\n.furin-decipher-text__letra {\n  position: relative;\n}\n\n/* Mientras se mezcla, la letra real se queda en el flujo (así el ancho no cambia)\n   pero invisible, y el carácter aleatorio se dibuja encima, centrado en su casilla. */\n.furin-decipher-text__letra[data-mezcla] {\n  -webkit-text-fill-color: transparent;\n}\n\n.furin-decipher-text__letra[data-mezcla]::after {\n  content: attr(data-mezcla);\n  position: absolute;\n  top: 0;\n  left: -1em;\n  right: -1em;\n  line-height: normal;\n  text-align: center;\n  white-space: pre;\n  pointer-events: none;\n  -webkit-text-fill-color: var(--furin-decipher-text-mezcla, color-mix(in srgb, currentColor 45%, transparent));\n}\n",
      "type": "registry:file",
      "target": "components/furin/decipher-text/DecipherText.css"
    }
  ]
}
