ns-ui
Scroll Fine Register
A divider/footer band whose streaming motion is built from a real tilemap-PPU scroll: a fine register sweeps 0..cellPx-1 sub-pixel offsets across 480ms before a coarse register ticks a tile over, with both registers exposed as a live numeric coarse/fine readout beside the strip.
Use when a divider or footer band that needs a subtle, mechanically-legible 'content is streaming past' cue built from a fixed hardware register cadence with a live coarse/fine numeric readout — not a text feed. Pick marquee-ticker-glyph instead if the strip needs to carry real scrollable text content the visitor can grab and scrub, or ticker-teleprinter for a character-quantised typewriter crawl of live copy; this component has no text payload, no drag, and no scrub — only a fixed 480ms fine-register sweep gating a coarse tile counter.
Install
npx shadcn add https://design.helpmarq.com /r/scroll-fine-register.jsonSource
registry/core/scroll-fine-register/component.tsx"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// ScrollFineRegister — a divider/footer band whose "content is streaming
// past" cue is built from the two cooperating registers a tilemap PPU
// (NES/SNES-class hardware) actually scrolls with, not a plain translateX
// tween. A COARSE register steps the visible window one whole tile at a
// time by swapping which tile glyph is addressed; a FINE register sweeps
// 0..cellPx-1 sub-pixel offsets within the current tile before the coarse
// register ticks over. What reads as smooth pixel-by-pixel motion is a
// sawtooth (fine ramping up then snapping to 0) gating a stepped counter
// (coarse incrementing once per sawtooth cycle) — and both registers are
// exposed as a live numeric readout so the mechanism is legible, not just
// its resulting motion.
//
// One totalSteps integer (in px) is the single source of truth: fine =
// totalSteps % cellPx, coarse = floor(totalSteps / cellPx) % tileCount. A
// fixed-rate accumulator ticks totalSteps by 1 every (480ms / cellPx) —
// so a full tile's worth of fine steps always takes exactly 480ms,
// regardless of how many discrete px steps cellPx works out to.
//
// tileCount (24-40) is the COARSE WRAP PERIOD — the pattern of tile
// glyphs repeats every tileCount cells and totalSteps wraps at
// tileCount*cellPx. That is deliberately kept separate from how many
// cells are actually rendered: renderCount = ceil(width/cellPx) +
// tileCount cells are painted (glyph = pattern[j % tileCount]), a
// full period wider than the viewport, so the strip never runs dry as
// totalSteps sweeps across an entire wrap cycle — a doubled-strip trick
// only covers a translate range up to one strip width, this covers the
// whole period.
//
// The glyph sequence itself is not a short repeating ramp: a fixed-seed
// mulberry32 PRNG draws `tileCount` glyph choices once per build, so the
// visual texture's true repeat period is the full tileCount*cellPx wrap
// (11.5-19.2s), not some short arithmetic cycle — otherwise the strip
// would loop every couple of seconds while only the readout kept counting.
//
// A single marker — an absolutely positioned overlay, not a strip cell —
// tracks `MARKER_LEAD` tiles ahead of the live coarse register at screen
// x = MARKER_LEAD*cellPx - fine, so it visibly slides left by cellPx px
// over the 480ms sweep and snaps back at the next coarse tick: the
// sawtooth made literally visible on the one thing a viewer's eye
// follows. Drawn as a full-height --foreground bracket (border-left +
// border-right only, cellPx wide) so it stays legible on top of every
// glyph underneath it, including the solid block.
//
// Geometry is derived from the container's smaller dimension (almost
// always height, for a horizontal band): cellPx = clamp(round(minDim/24),
// 8, 16). Translation is written directly to the track's transform and
// the readout's textContent every tick — no React state on the hot path.
// Pure DOM, zero colour literals: ink is `text-foreground` /
// `text-ns-muted`; `border-border` is used only for the band's own
// dividing rules, never as the marker's paint.
// ---------------------------------------------------------------------------
export interface ScrollFineRegisterProps {
/** extra classes merged onto the rendered root element */
className?: string;
}
const MIN_CELL = 8;
const MAX_CELL = 16;
const CELL_DIVISOR = 24;
const SWEEP_MS = 480; // one full fine-register sweep (0..cellPx-1) = one coarse tick
const MIN_TILES = 24;
const MAX_TILES = 40;
const MARKER_LEAD = 4; // marker sits this many tiles ahead of the coarse index
const FROZEN_COARSE_FALLBACK = 12; // reduced-motion freeze target: mid-strip, tile-aligned
// A small "tile ROM" restricted to shading blocks — these are solid
// rectangles in every monospace face, so they read cleanly as tile
// pixels even at an 8px cell where a glyph with descenders/gaps would not.
const GLYPHS = "░▒▓█";
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** One glyph choice per tile index, 0..tileCount-1 — the true visual
* repeat period, not a short arithmetic ramp. */
function buildPattern(tileCount: number): string[] {
const rand = mulberry32(0x5f3a11);
const pattern: string[] = [];
for (let i = 0; i < tileCount; i++) {
pattern.push(GLYPHS[Math.floor(rand() * GLYPHS.length)]!);
}
return pattern;
}
export function ScrollFineRegister({ className = "" }: ScrollFineRegisterProps) {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const markerRef = useRef<HTMLDivElement>(null);
const probeRef = useRef<HTMLSpanElement>(null);
const coarseRef = useRef<HTMLSpanElement>(null);
const fineRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const marker = markerRef.current;
const probe = probeRef.current;
if (!root || !track || !marker || !probe) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let disposed = false;
let cellPx = MIN_CELL;
let tileCount = MIN_TILES; // coarse wrap period, in tiles
let renderCount = MIN_TILES; // cells actually painted, >= period + viewport
let stepMs = SWEEP_MS / cellPx;
let totalSteps = 0; // px units; fine = totalSteps % cellPx, coarse = floor(totalSteps/cellPx) % tileCount
let acc = 0;
let last = 0;
let raf = 0;
let visible = true;
let pattern: string[] = [];
const paint = () => {
const fine = totalSteps % cellPx;
const coarse = Math.floor(totalSteps / cellPx) % tileCount;
track.style.transform = `translate3d(${-totalSteps}px, 0, 0)`;
marker.style.width = `${cellPx}px`;
marker.style.transform = `translate3d(${MARKER_LEAD * cellPx - fine}px, 0, 0)`;
marker.style.visibility = "visible";
if (coarseRef.current) coarseRef.current.textContent = String(coarse).padStart(2, "0");
if (fineRef.current) fineRef.current.textContent = String(fine);
};
// -- (re)build the tile strip for the current geometry. renderCount is
// one full coarse period wider than the viewport so every reachable
// translate offset within a period still fully covers the track. ------
const build = () => {
const rect = root.getBoundingClientRect();
const minDim = Math.min(rect.width, rect.height);
cellPx = Math.min(MAX_CELL, Math.max(MIN_CELL, Math.round(minDim / CELL_DIVISOR)));
stepMs = SWEEP_MS / cellPx;
const visibleTiles = Math.ceil(rect.width / Math.max(1, cellPx));
tileCount = Math.min(MAX_TILES, Math.max(MIN_TILES, visibleTiles));
renderCount = visibleTiles + tileCount + MARKER_LEAD + 1;
pattern = buildPattern(tileCount);
// measure the real glyph advance at this cellPx (monospace advance
// is well under 1em) and scale the font so a glyph's rendered width
// fills the cell — otherwise adjacent tiles read as separated bars,
// not a contiguous tilemap.
probe.style.fontSize = `${cellPx}px`;
const advance = probe.getBoundingClientRect().width || cellPx * 0.6;
const glyphFontPx = Math.max(6, Math.round(cellPx * Math.min(2.5, cellPx / advance)));
track.textContent = "";
for (let j = 0; j < renderCount; j++) {
const span = document.createElement("span");
span.setAttribute("aria-hidden", "true");
span.style.display = "inline-flex";
span.style.alignItems = "center";
span.style.justifyContent = "center";
span.style.width = `${cellPx}px`;
span.style.height = `${cellPx}px`;
span.style.overflow = "hidden";
span.style.fontSize = `${glyphFontPx}px`;
span.style.lineHeight = "1";
span.style.flexShrink = "0";
span.style.boxSizing = "border-box";
span.className = "text-foreground";
span.textContent = pattern[j % tileCount]!;
track.appendChild(span);
}
totalSteps = ((totalSteps % (tileCount * cellPx)) + tileCount * cellPx) % (tileCount * cellPx);
};
const freeze = () => {
const frozenCoarse = Math.min(tileCount - 1, FROZEN_COARSE_FALLBACK);
totalSteps = frozenCoarse * cellPx; // fine === 0 exactly — a tile boundary flush with the viewport edge
paint();
};
const loop = (now: number) => {
raf = 0;
if (!visible) return;
if (last === 0) last = now;
const dt = Math.min(100, now - last);
last = now;
acc += dt;
while (acc >= stepMs) {
acc -= stepMs;
totalSteps += 1;
if (totalSteps >= tileCount * cellPx) totalSteps -= tileCount * cellPx;
}
paint();
raf = requestAnimationFrame(loop);
};
build();
if (reduced) {
freeze();
} else {
// start a few steps into the sweep so t0 already reads as
// "mid-motion", not a just-ticked-over frame.
totalSteps = 3;
last = 0;
raf = requestAnimationFrame(loop);
}
let resizeTimer = 0;
const onResize = () => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
if (disposed) return;
build();
if (reduced) freeze();
else paint();
}, 120);
};
const ro = new ResizeObserver(onResize);
ro.observe(root);
const io = new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (visible && !reduced && !raf) {
last = 0;
raf = requestAnimationFrame(loop);
}
});
io.observe(root);
document.fonts?.ready?.then(() => {
if (!disposed) onResize();
});
return () => {
disposed = true;
cancelAnimationFrame(raf);
window.clearTimeout(resizeTimer);
ro.disconnect();
io.disconnect();
};
}, []);
return (
<div
ref={rootRef}
className={`flex h-12 items-stretch overflow-hidden border-y border-border bg-background ${className}`}
>
<div className="relative min-w-0 flex-1 overflow-hidden">
<span
ref={probeRef}
aria-hidden="true"
className="pointer-events-none absolute -left-[9999px] top-0 font-mono"
style={{ visibility: "hidden", whiteSpace: "pre" }}
>
█
</span>
<div ref={trackRef} className="flex h-full items-center font-mono will-change-transform" />
<div
ref={markerRef}
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 border-x border-foreground will-change-transform"
style={{ visibility: "hidden" }}
/>
</div>
<div className="flex shrink-0 items-center gap-2 border-l border-border px-4 font-mono text-[11px] tabular-nums text-foreground">
<span className="text-ns-muted">C</span>
<span ref={coarseRef}>00</span>
<span className="text-ns-muted">F</span>
<span ref={fineRef}>0</span>
</div>
</div>
);
}
Build spec
A horizontal band with no props beyond `className`, split into a flex-1 overflow-hidden tile strip and a shrink-0 numeric readout separated by a `border-border` rule (that rule, plus the band's own `border-y`, are the only uses of `--border` — always a structural separator, never the motion's paint). The strip is built entirely by direct DOM manipulation inside a single effect: on mount (and on every ResizeObserver-driven resize, debounced 120ms), `cellPx = clamp(round(minDim/24), 8, 16)` is derived from the container's smaller dimension (minDim = min(rect.width, rect.height), almost always height for a horizontal band). `tileCount = clamp(ceil(rect.width/cellPx), 24, 40)` is the COARSE WRAP PERIOD — `totalSteps` wraps at `tileCount*cellPx` — and a fixed-seed `mulberry32(0x5f3a11)` PRNG draws one glyph choice per tile index into a `tileCount`-length `pattern` array once per build, so the strip's true visual repeat period is the full wrap (11.5-19.2s at cellPx 8-16), not a short arithmetic ramp. `renderCount = visibleTiles + tileCount + MARKER_LEAD + 1` cells are actually painted (glyph at index `j` = `pattern[j % tileCount]`), one full coarse period wider than the viewport so the strip fully covers the container at every reachable translate offset within a wrap cycle, not just near t=0. Each cell's `fontSize` is not simply `cellPx`: a hidden probe span (same technique as marquee-ticker-glyph) measures the real monospace glyph advance at `cellPx`, and every cell's font is scaled by `cellPx / advance` (capped at 2.5x) so the glyph's rendered width fills the cell instead of leaving gaps between tiles; each cell also gets `overflow: hidden` to clip the resulting taller glyph. One integer, `totalSteps` (in px), is the entire state: `fine = totalSteps % cellPx` and `coarse = floor(totalSteps/cellPx) % tileCount`. A rAF loop accumulates elapsed ms and ticks `totalSteps` by 1 every `480/cellPx` ms — so a full fine sweep across a tile always takes exactly 480ms regardless of cellPx — wrapping `totalSteps` from `tileCount*cellPx` back to 0 the instant one full coarse period has scrolled past (landing on content pixel-identical to the wrap target, since the glyph pattern already repeats with that period). Every tick writes `track.style.transform = translate3d(-totalSteps px, 0, 0)` directly, and writes `coarse`/`fine` as plain zero-padded/unpadded text into two `tabular-nums` readout spans — no React state on the hot path. The marker is not a strip cell: it is a separate, absolutely-positioned full-band-height overlay div (`border-x border-foreground`, `cellPx` wide) drawn on top of the strip so it stays legible over any glyph beneath it, including a solid block. Every tick its transform is set to `translate3d(MARKER_LEAD*cellPx - fine, 0, 0)` (MARKER_LEAD = 4 tiles ahead of the live coarse register), so it visibly slides left by `cellPx` px over the 480ms sweep and snaps back to its start position the instant coarse ticks over — the sawtooth made literally visible on the one thing a viewer's eye is meant to track, tying the numeric coarse tick directly to a visual snap. The marker starts `visibility: hidden` in JSX and is flipped to `visible` on the first `paint()` call, so there is no flash of an unpositioned overlay at x=0 between mount and the first frame. `prefers-reduced-motion: reduce` skips the rAF loop entirely and calls `freeze()` once, which sets `totalSteps = min(tileCount-1, 12) * cellPx` — fine exactly 0, coarse fixed at a mid-strip tile — the one instant a tile boundary sits flush with the viewport's left edge, the most structured resting frame. At t0 (non-reduced) `totalSteps` starts at 3, a few px into the first sweep, so the component reads as already in motion rather than freshly booted; over 5s (~10 sweep cycles at cellPx=8, 480ms each) the coarse counter visibly advances roughly 10 and the strip has scrolled several tile widths, with fine cycling through a different phase than t0 at every checkpoint. An IntersectionObserver stops/resumes the rAF loop when the band scrolls off/on screen; a ResizeObserver rebuilds strip geometry (and the reduced-motion freeze target) whenever the band's own box changes size. No interaction: the strip does not accept drag/scrub, since the mechanic is a fixed-rate hardware register cadence, not a carousel. Ink is `text-foreground` / `text-ns-muted` for glyphs and readout, the `border-foreground` utility for the marker overlay, `border-border` only for the band's dividing rules — zero colour literals, zero `--ns-accent` use, both themes read identically. Pure DOM, zero dependencies.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| className? | string | — | extra classes merged onto the rendered root element |