Skip to main content

ns-ui

Ticker Teleprinter

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

Usage

registry/core/ticker-teleprinter/demo.tsx
"use client";

import { RollCrawl } from "./component";

const ITEMS = [
  "SIGNAL ACQUIRED",
  "GRID STABLE",
  "RELAY 04 ONLINE",
  "NO ANOMALIES",
  "STANDBY",
];

export default function RollCrawlDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / ticker-teleprinter
      </p>
      <div data-ns-roll-focus className="w-full max-w-xl rounded-md border border-border bg-surface px-4 py-3">
        <RollCrawl items={ITEMS} className="text-sm" />
      </div>
      <p className="max-w-md text-center text-xs text-ns-muted">
        Hover to freeze the tape on a cell boundary; each incoming character
        churns through a few glyphs before it settles.
      </p>
    </div>
  );
}

Component

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;
  /** extra classes merged onto the rendered root element */
  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>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/ticker-teleprinter.tsx, so point the import there.

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.

Install

npx shadcn add https://design.helpmarq.com/r/ticker-teleprinter.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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.

Props

PropTypeDefaultDescription
itemsstring[]—items rendered in order, joined by `separator` and looped
separator?string"•"glyph placed between items, including at the wrap seam
beatMs?number110ms per cell advance; also the flicker settle budget is 3x this
className?string—extra classes merged onto the rendered root element