{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "typewriter",
  "type": "registry:component",
  "title": "Typewriter",
  "description": "Types, deletes and retypes phrases with a human rhythm, pauses on punctuation and a blinking cursor.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Text"
  ],
  "docs": "Installed in components/furin/typewriter/. Docs, live controls and props table: https://furin.dev/components/typewriter/",
  "files": [
    {
      "path": "registry/typewriter/Typewriter.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport \"./Typewriter.css\";\n\nexport interface TypewriterProps {\n  /** Frases separadas por \"|\" (alternativa a `phrases`). */\n  children?: string;\n  /** Lista de frases que se escriben una tras otra. */\n  phrases?: string[];\n  /** Texto fijo que precede a la frase que se escribe. */\n  prefix?: string;\n  /** Tamaño de la letra, en px. */\n  size?: number;\n  /** Milisegundos por letra al escribir (el ritmo humano varía alrededor de este valor). */\n  speed?: number;\n  /** Milisegundos por letra al borrar. */\n  deleteSpeed?: number;\n  /** Milisegundos que descansa la frase completa antes de borrarse. */\n  hold?: number;\n  /** Probabilidad (0 a 0.2) de equivocarse de tecla, notarlo y corregir. */\n  mistakes?: number;\n  /** Repite las frases sin parar. */\n  loop?: boolean;\n  /** Forma del cursor. */\n  cursor?: \"bar\" | \"block\" | \"underscore\";\n  /** Color de la frase escrita. */\n  color?: string;\n  /** Color del cursor y del prefijo resaltado. */\n  accent?: string;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\nconst VECINAS: Record<string, string> = {\n  q: \"wa\", w: \"qes\", e: \"wrd\", r: \"etf\", t: \"ryg\", y: \"tuh\", u: \"yij\", i: \"uok\", o: \"ipl\", p: \"ol\",\n  a: \"qsz\", s: \"adw\", d: \"sfe\", f: \"dgr\", g: \"fht\", h: \"gjy\", j: \"hku\", k: \"jli\", l: \"kop\", z: \"asx\",\n  x: \"zc\", c: \"xv\", v: \"cb\", b: \"vn\", n: \"bm\", m: \"n\",\n};\n\n/** Una letra vecina en el teclado, respetando mayúsculas. */\nfunction tecla(ch: string): string {\n  const base = ch.toLowerCase();\n  const v = VECINAS[base];\n  if (!v) return ch;\n  const m = v[Math.floor(Math.random() * v.length)];\n  return ch === base ? m : m.toUpperCase();\n}\n\n/** Número casi gaussiano (−1 a 1), para que el ritmo no sea metronómico. */\nconst ruido = () => (Math.random() + Math.random() + Math.random()) / 1.5 - 1;\n\n/** Escribe, se equivoca (si se le pide), corrige, descansa, borra y reescribe frases con ritmo humano. */\nexport function Typewriter({\n  children = \"interfaces that breathe.|software with craft.|brands people remember.\",\n  phrases,\n  prefix = \"We create\",\n  size = 40,\n  speed = 62,\n  deleteSpeed = 30,\n  hold = 1700,\n  mistakes = 0.04,\n  loop = true,\n  cursor = \"bar\",\n  color = \"#F4F5F7\",\n  accent = \"#7CF5C6\",\n  className = \"\",\n  style,\n}: TypewriterProps) {\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const textoRef = useRef<HTMLSpanElement>(null);\n  const lista = (phrases && phrases.length ? phrases : String(children ?? \"\").split(\"|\")).map((s) => s.trim()).filter(Boolean);\n  const clave = lista.join(\"\\u0001\");\n  const cfg = useRef({ speed, deleteSpeed, hold, mistakes, loop });\n  cfg.current = { speed, deleteSpeed, hold, mistakes, loop };\n\n  useEffect(() => {\n    const root = rootRef.current, out = textoRef.current;\n    if (!root || !out || !lista.length) return;\n    const reducido = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const escribir = (s: string) => { out.textContent = s; };\n    out.textContent = \"\";\n    if (reducido) { escribir(lista[0]); root.dataset.estado = \"quieto\"; return; }\n\n    const S = { fase: \"escribe\" as \"escribe\" | \"descansa\" | \"borra\", p: 0, i: 0, mal: 0, visto: \"\", inicio: 0, firme: false };\n    let timer = 0, visible = true, muerto = false, dormido = false;\n    const espera = (ms: number) => { timer = window.setTimeout(tick, Math.max(0, ms)); };\n\n    // Retraso de cada tecla: ritmo base con jitter, pausas con la puntuación y algún titubeo.\n    const retraso = (ch: string, prev: string) => {\n      let d = cfg.current.speed * Math.exp(ruido() * 0.45);\n      if (ch === \" \") d *= 1.25;\n      if (/[,;:]/.test(prev)) d += 170 + Math.random() * 140;\n      if (/[.!?…]/.test(prev)) d += 320 + Math.random() * 200;\n      if (Math.random() < 0.035) d += 180 + Math.random() * 260;\n      return d;\n    };\n\n    function tick() {\n      timer = 0;\n      if (muerto) return;\n      if (!visible) { dormido = true; return; }\n      const frase = lista[S.p];\n      const c = cfg.current;\n      if (S.fase === \"escribe\") {\n        root!.dataset.estado = \"escribe\";\n        if (S.mal > 0) {\n          // Corrige: borra la tecla equivocada y retoma.\n          S.visto = S.visto.slice(0, -1); S.mal--; escribir(S.visto);\n          S.firme = true; // la tecla que sigue a una corrección sale bien\n          return espera(S.mal > 0 ? 55 : 130 + Math.random() * 90);\n        }\n        if (S.i >= frase.length) {\n          S.fase = \"descansa\";\n          return espera(0);\n        }\n        const ch = frase[S.i];\n        if (c.mistakes > 0 && !S.firme && S.i > 1 && /[a-záéíóúñ]/i.test(ch) && Math.random() < c.mistakes) {\n          S.visto += tecla(ch); S.mal = 1; escribir(S.visto);\n          return espera(260 + Math.random() * 280); // el momento de darse cuenta\n        }\n        S.firme = false;\n        S.visto += ch; S.i++; escribir(S.visto);\n        return espera(retraso(ch, frase[S.i - 2] ?? \"\"));\n      }\n      if (S.fase === \"descansa\") {\n        root!.dataset.estado = \"descansa\";\n        const ultima = S.p === lista.length - 1;\n        if (!c.loop && ultima) return; // se queda con la última frase escrita\n        if (lista.length === 1 && !c.loop) return;\n        S.fase = \"borra\"; S.inicio = S.visto.length;\n        return espera(c.hold);\n      }\n      // Borra: arranca con calma y acelera, como quien mantiene la tecla.\n      root!.dataset.estado = \"escribe\";\n      if (!S.visto.length) {\n        S.p = (S.p + 1) % lista.length; S.i = 0; S.fase = \"escribe\";\n        return espera(380 + Math.random() * 200);\n      }\n      S.visto = S.visto.slice(0, -1); escribir(S.visto);\n      const prog = 1 - S.visto.length / Math.max(1, S.inicio);\n      return espera(c.deleteSpeed * (1.5 - Math.min(1, prog * 2.2) * 0.9) * (0.85 + Math.random() * 0.3));\n    }\n\n    const io = new IntersectionObserver(([e]) => {\n      visible = e.isIntersecting;\n      if (visible && dormido && !timer) { dormido = false; espera(0); }\n    }, { rootMargin: \"40px\" });\n    io.observe(root);\n    espera(450);\n    return () => { muerto = true; clearTimeout(timer); io.disconnect(); };\n  }, [clave]);\n\n  const vars = { \"--furin-tw-size\": `${size}px`, \"--furin-tw-color\": color, \"--furin-tw-accent\": accent } as CSSProperties;\n  const total = lista.map((s) => `${prefix} ${s}`.trim()).join(\" \");\n\n  return (\n    <span ref={rootRef} className={`furin-typewriter ${className}`} data-cursor={cursor} data-estado=\"escribe\" style={{ ...vars, ...style }}>\n      <span className=\"furin-typewriter__sr\">{total}</span>\n      <span className=\"furin-typewriter__visible\" aria-hidden=\"true\">\n        {prefix ? <span className=\"furin-typewriter__prefijo\">{prefix}</span> : null}\n        <span className=\"furin-typewriter__linea\">\n          <span ref={textoRef} className=\"furin-typewriter__texto\" />\n          <span className=\"furin-typewriter__cursor\" />\n        </span>\n      </span>\n    </span>\n  );\n}\n\nexport default Typewriter;\n",
      "type": "registry:component",
      "target": "components/furin/typewriter/Typewriter.tsx"
    },
    {
      "path": "registry/typewriter/Typewriter.css",
      "content": ".furin-typewriter {\n  display: block;\n  width: min(100%, 31em);\n  box-sizing: border-box;\n  padding-inline: 0.7em;\n  font-family: \"Geist\", \"Inter\", \"SF Pro Display\", system-ui, -apple-system, \"Helvetica Neue\", Arial, sans-serif;\n  font-size: var(--furin-tw-size);\n  font-weight: 600;\n  line-height: 1.16;\n  letter-spacing: -0.028em;\n  color: var(--furin-tw-color);\n  text-align: left;\n  -webkit-font-smoothing: antialiased;\n}\n.furin-typewriter__visible { display: block; }\n.furin-typewriter__prefijo {\n  display: block;\n  margin-bottom: 0.18em;\n  font-size: 0.56em;\n  font-weight: 500;\n  letter-spacing: 0.01em;\n  color: color-mix(in srgb, var(--furin-tw-color) 46%, transparent);\n}\n/* Reserva dos líneas: así la caja no salta cuando la frase se envuelve o se borra. */\n.furin-typewriter__linea { display: block; min-height: 2.32em; }\n.furin-typewriter__texto { white-space: pre-wrap; overflow-wrap: anywhere; }\n.furin-typewriter__cursor {\n  display: inline-block;\n  width: 0.075em;\n  height: 0.96em;\n  margin-left: 0.07em;\n  vertical-align: -0.12em;\n  border-radius: 0.04em;\n  background: var(--furin-tw-accent);\n  box-shadow: 0 0 0.5em color-mix(in srgb, var(--furin-tw-accent) 70%, transparent);\n  animation: furin-typewriter-parpadeo 1.1s ease-in-out infinite;\n}\n.furin-typewriter[data-cursor=\"block\"] .furin-typewriter__cursor { width: 0.52em; border-radius: 0.06em; margin-left: 0.04em; opacity: 0.9; }\n.furin-typewriter[data-cursor=\"underscore\"] .furin-typewriter__cursor { width: 0.54em; height: 0.075em; vertical-align: -0.1em; border-radius: 0.04em; }\n/* Mientras escribe, el cursor se queda fijo, como en un teclado de verdad. */\n.furin-typewriter[data-estado=\"escribe\"] .furin-typewriter__cursor { animation: none; opacity: 1; }\n.furin-typewriter[data-estado=\"quieto\"] .furin-typewriter__cursor { animation: none; opacity: 0.9; }\n@keyframes furin-typewriter-parpadeo {\n  0%, 45% { opacity: 1; }\n  60%, 90% { opacity: 0.05; }\n  100% { opacity: 1; }\n}\n.furin-typewriter__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) { .furin-typewriter__cursor { animation: none; } }\n",
      "type": "registry:file",
      "target": "components/furin/typewriter/Typewriter.css"
    }
  ]
}
