Ticker Teleprinter

Ticker

Character-quantised teleprinter crawl: a fixed row of monospace cells advances one full cell per beat via content substitution, never a subpixel slide.

Install
npx shadcn add https://design.helpmarq.com/r/ticker-teleprinter.json
Source
registry/core/ticker-teleprinter/component.tsx
"use client";

import { useEffect, useMemo, useRef } from "react";

// ---------------------------------------------------------------------------
// RollCrawl — a teleprinter-register crawl. Unlike a CSS marquee, the strip
// never slides: a fixed row of monospace cells exists at fixed pixel
// positions, and every `beatMs` the ENTIRE row is re-rendered one tape-index
// further along by rewriting each cell's textContent — the crawl is content
// substitution, not translation, which is what makes the motion read as
// character-quantised rather than a subpixel slide. A cell's distance from
// the right edge (offsetFromRight = cellCount - 1 - i) is fixed per DOM
// position, and doubles as that position's "age": the rightmost cell is
// always the just-arrived one, so it flickers through a short run of random
// glyphs on every beat, while positions old enough (offsetFromRight >=
// FLICKER_STEPS) always show the real, settled tape character. Direct-DOM
// rAF only — no React state on the hot path.
// ---------------------------------------------------------------------------

const FLICKER_STEPS = 3;
const FLICKER_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&@*+=-_/\\|";

export interface RollCrawlProps {
  /** items rendered in order, joined by `separator` and looped */
  items: string[];
  /** glyph placed between items, including at the wrap seam */
  separator?: string;
  /** ms per cell advance; also the flicker settle budget is 3x this */
  beatMs?: number;
  className?: string;
}

export function RollCrawl({
  items,
  separator = "•",
  beatMs = 110,
  className = "",
}: RollCrawlProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const rowRef = useRef<HTMLDivElement>(null);
  const probeRef = useRef<HTMLSpanElement>(null);

  const tape = useMemo(() => {
    if (items.length === 0) return "";
    return `${items.join(` ${separator} `)} ${separator} `;
  }, [items, separator]);

  const label = useMemo(() => items.join(", "), [items]);

  useEffect(() => {
    const container = containerRef.current;
    const row = rowRef.current;
    const probe = probeRef.current;
    if (!container || !row || !probe || tape.length === 0) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let cellWidth = probe.getBoundingClientRect().width || 10;
    let cells: HTMLSpanElement[] = [];
    let leadIndex = 0;
    let beatAcc = 0;
    let paused = false;
    let raf = 0;
    let last = 0;

    const charAt = (absIndex: number) =>
      tape[((absIndex % tape.length) + tape.length) % tape.length] ?? " ";

    const rebuild = () => {
      cellWidth = probe.getBoundingClientRect().width || cellWidth;
      const w = container.getBoundingClientRect().width;
      const count = Math.max(1, Math.floor(w / cellWidth));
      row.textContent = "";
      cells = [];
      for (let i = 0; i < count; i++) {
        const span = document.createElement("span");
        span.setAttribute("aria-hidden", "true");
        span.style.display = "inline-block";
        span.style.width = `${cellWidth}px`;
        span.style.textAlign = "center";
        span.style.flexShrink = "0";
        row.appendChild(span);
        cells.push(span);
      }
      forceSettle();
    };

    // writes the real, settled character to every cell regardless of its
    // flicker age — used for the initial paint and for a clean, non-mid-beat
    // hover pause.
    const forceSettle = () => {
      for (let i = 0; i < cells.length; i++) {
        const offsetFromRight = cells.length - 1 - i;
        cells[i]!.textContent = charAt(leadIndex - offsetFromRight);
      }
    };

    // one beat: advance the tape by one cell, settle positions old enough to
    // have cleared the flicker window, and re-roll the still-new ones.
    const applyBeat = () => {
      leadIndex++;
      for (let i = 0; i < cells.length; i++) {
        const offsetFromRight = cells.length - 1 - i;
        if (offsetFromRight >= FLICKER_STEPS) {
          cells[i]!.textContent = charAt(leadIndex - offsetFromRight);
        } else {
          cells[i]!.textContent =
            FLICKER_CHARSET[Math.floor(Math.random() * FLICKER_CHARSET.length)]!;
        }
      }
    };

    // between beats: re-roll only the cells still inside the flicker window,
    // so a freshly-arrived character visibly churns through 2-3 glyphs.
    const applyFlicker = () => {
      for (let i = 0; i < cells.length; i++) {
        const offsetFromRight = cells.length - 1 - i;
        if (offsetFromRight < FLICKER_STEPS) {
          cells[i]!.textContent =
            FLICKER_CHARSET[Math.floor(Math.random() * FLICKER_CHARSET.length)]!;
        }
      }
    };

    rebuild();

    if (reduced) {
      const ro = new ResizeObserver(rebuild);
      ro.observe(container);
      return () => ro.disconnect();
    }

    let flickerAcc = 0;
    const flickerInterval = Math.max(30, beatMs / 2.4);

    const loop = (now: number) => {
      const dt = last ? Math.min(64, now - last) : 16;
      last = now;
      if (!paused) {
        beatAcc += dt;
        flickerAcc += dt;
        if (beatAcc >= beatMs) {
          beatAcc -= beatMs;
          flickerAcc = 0;
          applyBeat();
        } else if (flickerAcc >= flickerInterval) {
          flickerAcc -= flickerInterval;
          applyFlicker();
        }
      }
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);

    const onEnter = () => {
      paused = true;
      forceSettle(); // land cleanly on the current cell boundary, no frozen flicker
    };
    const onLeave = () => {
      paused = false;
      beatAcc = 0;
      flickerAcc = 0;
    };
    container.addEventListener("pointerenter", onEnter);
    container.addEventListener("pointerleave", onLeave);
    container.addEventListener("focusin", onEnter);
    container.addEventListener("focusout", onLeave);

    const ro = new ResizeObserver(rebuild);
    ro.observe(container);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      container.removeEventListener("pointerenter", onEnter);
      container.removeEventListener("pointerleave", onLeave);
      container.removeEventListener("focusin", onEnter);
      container.removeEventListener("focusout", onLeave);
    };
  }, [tape, beatMs]);

  return (
    <div
      ref={containerRef}
      role="text"
      aria-label={label}
      className={`relative overflow-hidden whitespace-nowrap font-mono text-foreground ${className}`}
    >
      <span
        ref={probeRef}
        aria-hidden="true"
        className="pointer-events-none absolute -left-[9999px] top-0"
        style={{ visibility: "hidden", whiteSpace: "pre" }}
      >
        0
      </span>
      <div ref={rowRef} className="flex" />
    </div>
  );
}
Use when

a self-driving ambient status line where the mechanical, one-cell-at-a-time advance and per-character settle-in are themselves the point. Reach for ticker-tape-splice instead when the content is discrete data rows that need keyboard navigation and a visible append/splice moment — ticker-teleprinter has no roving focus and is meant to be looked at, not tabbed through.

Build spec

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.

Tags
tickermarqueeasciimonoteleprinter