{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ticker-teleprinter",
  "title": "Ticker Teleprinter",
  "description": "Character-quantised teleprinter crawl: a fixed row of monospace cells advances one full cell per beat via content substitution, never a subpixel slide.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/ticker-teleprinter/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// RollCrawl — a teleprinter-register crawl. Unlike a CSS marquee, the strip\n// never slides: a fixed row of monospace cells exists at fixed pixel\n// positions, and every `beatMs` the ENTIRE row is re-rendered one tape-index\n// further along by rewriting each cell's textContent — the crawl is content\n// substitution, not translation, which is what makes the motion read as\n// character-quantised rather than a subpixel slide. A cell's distance from\n// the right edge (offsetFromRight = cellCount - 1 - i) is fixed per DOM\n// position, and doubles as that position's \"age\": the rightmost cell is\n// always the just-arrived one, so it flickers through a short run of random\n// glyphs on every beat, while positions old enough (offsetFromRight >=\n// FLICKER_STEPS) always show the real, settled tape character. Direct-DOM\n// rAF only — no React state on the hot path.\n// ---------------------------------------------------------------------------\n\nconst FLICKER_STEPS = 3;\nconst FLICKER_CHARSET = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&@*+=-_/\\\\|\";\n\nexport interface RollCrawlProps {\n  /** items rendered in order, joined by `separator` and looped */\n  items: string[];\n  /** glyph placed between items, including at the wrap seam */\n  separator?: string;\n  /** ms per cell advance; also the flicker settle budget is 3x this */\n  beatMs?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function RollCrawl({\n  items,\n  separator = \"•\",\n  beatMs = 110,\n  className = \"\",\n}: RollCrawlProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const rowRef = useRef<HTMLDivElement>(null);\n  const probeRef = useRef<HTMLSpanElement>(null);\n\n  const tape = useMemo(() => {\n    if (items.length === 0) return \"\";\n    return `${items.join(` ${separator} `)} ${separator} `;\n  }, [items, separator]);\n\n  const label = useMemo(() => items.join(\", \"), [items]);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    const row = rowRef.current;\n    const probe = probeRef.current;\n    if (!container || !row || !probe || tape.length === 0) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    let cellWidth = probe.getBoundingClientRect().width || 10;\n    let cells: HTMLSpanElement[] = [];\n    let leadIndex = 0;\n    let beatAcc = 0;\n    let paused = false;\n    let raf = 0;\n    let last = 0;\n\n    const charAt = (absIndex: number) =>\n      tape[((absIndex % tape.length) + tape.length) % tape.length] ?? \" \";\n\n    const rebuild = () => {\n      cellWidth = probe.getBoundingClientRect().width || cellWidth;\n      const w = container.getBoundingClientRect().width;\n      const count = Math.max(1, Math.floor(w / cellWidth));\n      row.textContent = \"\";\n      cells = [];\n      for (let i = 0; i < count; i++) {\n        const span = document.createElement(\"span\");\n        span.setAttribute(\"aria-hidden\", \"true\");\n        span.style.display = \"inline-block\";\n        span.style.width = `${cellWidth}px`;\n        span.style.textAlign = \"center\";\n        span.style.flexShrink = \"0\";\n        row.appendChild(span);\n        cells.push(span);\n      }\n      forceSettle();\n    };\n\n    // writes the real, settled character to every cell regardless of its\n    // flicker age — used for the initial paint and for a clean, non-mid-beat\n    // hover pause.\n    const forceSettle = () => {\n      for (let i = 0; i < cells.length; i++) {\n        const offsetFromRight = cells.length - 1 - i;\n        cells[i]!.textContent = charAt(leadIndex - offsetFromRight);\n      }\n    };\n\n    // one beat: advance the tape by one cell, settle positions old enough to\n    // have cleared the flicker window, and re-roll the still-new ones.\n    const applyBeat = () => {\n      leadIndex++;\n      for (let i = 0; i < cells.length; i++) {\n        const offsetFromRight = cells.length - 1 - i;\n        if (offsetFromRight >= FLICKER_STEPS) {\n          cells[i]!.textContent = charAt(leadIndex - offsetFromRight);\n        } else {\n          cells[i]!.textContent =\n            FLICKER_CHARSET[Math.floor(Math.random() * FLICKER_CHARSET.length)]!;\n        }\n      }\n    };\n\n    // between beats: re-roll only the cells still inside the flicker window,\n    // so a freshly-arrived character visibly churns through 2-3 glyphs.\n    const applyFlicker = () => {\n      for (let i = 0; i < cells.length; i++) {\n        const offsetFromRight = cells.length - 1 - i;\n        if (offsetFromRight < FLICKER_STEPS) {\n          cells[i]!.textContent =\n            FLICKER_CHARSET[Math.floor(Math.random() * FLICKER_CHARSET.length)]!;\n        }\n      }\n    };\n\n    rebuild();\n\n    if (reduced) {\n      const ro = new ResizeObserver(rebuild);\n      ro.observe(container);\n      return () => ro.disconnect();\n    }\n\n    let flickerAcc = 0;\n    const flickerInterval = Math.max(30, beatMs / 2.4);\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min(64, now - last) : 16;\n      last = now;\n      if (!paused) {\n        beatAcc += dt;\n        flickerAcc += dt;\n        if (beatAcc >= beatMs) {\n          beatAcc -= beatMs;\n          flickerAcc = 0;\n          applyBeat();\n        } else if (flickerAcc >= flickerInterval) {\n          flickerAcc -= flickerInterval;\n          applyFlicker();\n        }\n      }\n      raf = requestAnimationFrame(loop);\n    };\n    raf = requestAnimationFrame(loop);\n\n    const onEnter = () => {\n      paused = true;\n      forceSettle(); // land cleanly on the current cell boundary, no frozen flicker\n    };\n    const onLeave = () => {\n      paused = false;\n      beatAcc = 0;\n      flickerAcc = 0;\n    };\n    container.addEventListener(\"pointerenter\", onEnter);\n    container.addEventListener(\"pointerleave\", onLeave);\n    container.addEventListener(\"focusin\", onEnter);\n    container.addEventListener(\"focusout\", onLeave);\n\n    const ro = new ResizeObserver(rebuild);\n    ro.observe(container);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      container.removeEventListener(\"pointerenter\", onEnter);\n      container.removeEventListener(\"pointerleave\", onLeave);\n      container.removeEventListener(\"focusin\", onEnter);\n      container.removeEventListener(\"focusout\", onLeave);\n    };\n  }, [tape, beatMs]);\n\n  return (\n    <div\n      ref={containerRef}\n      role=\"text\"\n      aria-label={label}\n      className={`relative overflow-hidden whitespace-nowrap font-mono text-foreground ${className}`}\n    >\n      <span\n        ref={probeRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute -left-[9999px] top-0\"\n        style={{ visibility: \"hidden\", whiteSpace: \"pre\" }}\n      >\n        0\n      </span>\n      <div ref={rowRef} className=\"flex\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/ticker-teleprinter.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "ticker",
      "marquee",
      "ascii",
      "mono",
      "teleprinter"
    ],
    "instruction": "Build <RollCrawl items separator? beatMs? className?> where `items: string[]` is joined with ` ${separator} ` (default separator \"•\") and a trailing ` ${separator} ` appended so the loop seam reads identically to every other item boundary. IMPLEMENTATION: the row is NOT translated. A fixed set of DOM cell spans exists at fixed positions (count derived from container width divided by a measured 1-character cell width, recomputed via ResizeObserver), and every `beatMs` (default 110) the whole row is rewritten by walking the tape string one index further — this content substitution, not a CSS transform, is what makes the motion character-quantised rather than a sliding marquee. A cell's distance from the right edge is fixed per DOM position and doubles as that position's age since the character now shown there arrived: the rightmost cell is always the just-arrived character and is scrambled to a random glyph from a noise charset on every beat and again on an intermediate flicker tick roughly 2.4x per beat, so an incoming character visibly churns through 2-3 glyphs; once a position's age reaches 3 beats it always shows the true tape character. HOVER: pointerenter (and focus-within, via focusin/focusout) sets a paused flag that stops the beat/flicker timers, but also force-settles every cell to its true character immediately — this is what makes the pause land cleanly on a cell boundary instead of freezing mid-flicker on a scrambled glyph. Accessible name is the plain comma-joined item list on a role=\"text\" wrapper; every visual cell is aria-hidden. prefers-reduced-motion renders one static settled frame (no timers, no listeners beyond a resize observer) rather than a crash or a blank strip. Direct-DOM rAF loop, canceled on unmount; zero React state on the animation path."
  },
  "type": "registry:ui"
}