ns-ui
Fiche Step Repeat
A thumbnail grid that populates like a microfiche step-and-repeat camera: one cell exposed per raster step with a brief flash, then an index strip types in once the sheet is complete.
Use when a thumbnail/gallery grid whose populate-in moment should read as a discrete, ordered mechanical sequence rather than a synchronous fade or a masonry settle; pick gallery-ascii-gradient-orientation instead when the tiles themselves are generated procedural content rather than real thumbnails populating in over time, or grid-magnetic-lattice when the grid's identity is ambient cursor reactivity rather than a one-time fill sequence.
Install
npx shadcn add https://design.helpmarq.com /r/fiche-step-repeat.jsonSource
registry/core/fiche-step-repeat/component.tsx"use client";
import { useEffect, useId, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// FicheStepRepeat — a thumbnail grid that populates the way a COM (computer
// output microfilm) step-and-repeat camera exposed a microfiche sheet: one
// frame at a time, strict raster order (left-to-right, then down a row), a
// short exposure flash on arrival, and a typed index strip once the sheet is
// complete. The mechanical step between frames is genuinely the slow part —
// the flash itself is brief — so growth is paced at one cell per STEP_MS,
// with an explicit FLASH_UP/FLASH_DOWN envelope on top of every arrival
// rather than a plain fade-in (the real process is a discrete exposure, not
// a dissolve).
//
// Grid geometry: cols/rows are derived from the container's own width and
// height divided by a ~64px target cell, clamped to a 4x3 minimum and a
// 7x5 maximum — the closest card-scale abstraction of a real fiche's
// row/column raster (full sheets run 7x14 or denser; this keeps a card
// legible). One rAF loop drives the whole cycle from a single elapsed-time
// clock (grow -> type -> hold -> reset), touching the DOM directly per
// cell rather than through React state, since redrawing 12-35 tiny cells
// through render on every animation frame would be wasted work.
// ---------------------------------------------------------------------------
const STEP_MS = 850; // one raster step: mechanical pitch between exposures
const FLASH_UP_MS = 120; // exposure flash ramp to peak
const FLASH_DOWN_MS = 280; // flash relaxes back to the cell's rest luminance
const FLASH_MS = FLASH_UP_MS + FLASH_DOWN_MS;
const TYPE_MS = 600; // index strip typing window
const TYPE_CHAR_MS = 17; // ~one character reveal every 17ms
const HOLD_MS = 1400; // full-sheet hold once the index strip finishes
const RESET_MS = 400; // raster-order step back to blank
const FLASH_AMP_DARK = 0.12; // +12% luminance over settled value (dark theme)
const FLASH_AMP_LIGHT = 0.08; // less headroom against near-white thumbnails
const TARGET_CELL_PX = 64;
const MIN_COLS = 4;
const MAX_COLS = 7;
const MIN_ROWS = 3;
const MAX_ROWS = 5;
export interface FicheFrame {
/** thumbnail image URL for this raster position */
src: string;
/** accessible description of the frame */
alt: string;
}
export interface FicheStepRepeatProps {
/** real thumbnails placed in raster order; cells beyond the supplied count
* render the built-in placeholder silhouette */
images?: FicheFrame[];
/** reduction ratio printed on the index strip, e.g. 24 for "24x" */
reductionRatio?: number;
/** extra classes merged onto the root element */
className?: string;
}
function FrameSilhouette() {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-2/5 w-2/5 text-ns-muted opacity-40"
>
<rect x="2" y="2" width="20" height="20" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
<circle cx="8" cy="8.5" r="2" fill="none" stroke="currentColor" strokeWidth="1.2" />
<path
d="M3 17.5l5-5a1.4 1.4 0 0 1 2 0l4.5 4.5 1.5-1.5a1.4 1.4 0 0 1 2 0L21 18"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function FicheStepRepeat({ images = [], reductionRatio = 24, className = "" }: FicheStepRepeatProps) {
const rootRef = useRef<HTMLDivElement>(null);
const cellRefs = useRef<(HTMLDivElement | null)[]>([]);
const headerRef = useRef<HTMLParagraphElement>(null);
const [grid, setGrid] = useState({ cols: MAX_COLS, rows: MAX_ROWS });
const headerId = useId();
const cellCount = grid.cols * grid.rows;
const label = `${reductionRatio}× · ${cellCount} FRAMES`;
// Derive the raster grid from the container's own dimensions — the
// smaller axis governs how coarse a "still reads at card scale" grid is,
// but each axis gets its own count so a wide card doesn't get squeezed
// into a square.
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const measure = () => {
const rect = root.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) return;
const cols = Math.min(MAX_COLS, Math.max(MIN_COLS, Math.round(rect.width / TARGET_CELL_PX)));
const rows = Math.min(MAX_ROWS, Math.max(MIN_ROWS, Math.round(rect.height / TARGET_CELL_PX)));
setGrid((prev) => (prev.cols === cols && prev.rows === rows ? prev : { cols, rows }));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(root);
return () => ro.disconnect();
}, []);
useEffect(() => {
const header = headerRef.current;
if (!header) return;
const cells = cellRefs.current.slice(0, cellCount);
const growEnd = cellCount * STEP_MS;
const typeEnd = growEnd + TYPE_MS;
const holdEnd = typeEnd + HOLD_MS;
const totalCycle = holdEnd + RESET_MS;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Theme flip only ever swaps the `.dark` class — track it live so the
// flash amplitude reads correctly without a remount.
let flashAmp = document.documentElement.classList.contains("dark") ? FLASH_AMP_DARK : FLASH_AMP_LIGHT;
const mo = new MutationObserver(() => {
flashAmp = document.documentElement.classList.contains("dark") ? FLASH_AMP_DARK : FLASH_AMP_LIGHT;
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
const populated = new Array(cellCount).fill(false);
let lastFlashCell = -1;
let lastTypedChars = -1;
const setFilled = (i: number, filled: boolean) => {
if (populated[i] === filled) return;
populated[i] = filled;
const el = cells[i];
if (!el) return;
if (filled) el.dataset.filled = "true";
else delete el.dataset.filled;
};
const clearFlash = (i: number) => {
const el = cells[i];
if (el) el.style.removeProperty("filter");
};
const setHeaderText = (text: string) => {
if (lastTypedChars === text.length && header.textContent === text) return;
header.textContent = text;
lastTypedChars = text.length;
};
// Reduced motion: freeze on the full-sheet hold frame — every cell
// populated at rest, index strip fully typed, no flash, no loop.
if (reduced) {
for (let i = 0; i < cellCount; i++) setFilled(i, true);
setHeaderText(label);
return () => mo.disconnect();
}
let raf = 0;
let cycleStart = 0;
let visible = true;
const tick = (now: number) => {
if (!visible) return;
if (cycleStart === 0) cycleStart = now;
let elapsed = now - cycleStart;
if (elapsed >= totalCycle) {
cycleStart += totalCycle;
elapsed -= totalCycle;
}
if (elapsed < growEnd) {
const idx = Math.min(cellCount - 1, Math.floor(elapsed / STEP_MS));
for (let i = 0; i <= idx; i++) setFilled(i, true);
const localT = elapsed - idx * STEP_MS;
if (localT < FLASH_MS) {
const p = localT < FLASH_UP_MS ? localT / FLASH_UP_MS : 1 - (localT - FLASH_UP_MS) / FLASH_DOWN_MS;
const b = 1 + p * flashAmp;
const el = cells[idx];
if (el) el.style.filter = `brightness(${b.toFixed(3)})`;
lastFlashCell = idx;
} else if (lastFlashCell === idx) {
clearFlash(idx);
lastFlashCell = -1;
}
if (lastTypedChars !== 0) setHeaderText("");
} else if (elapsed < typeEnd) {
if (lastFlashCell !== -1) {
clearFlash(lastFlashCell);
lastFlashCell = -1;
}
const typed = Math.min(label.length, Math.floor(((elapsed - growEnd) / TYPE_MS) * label.length));
setHeaderText(label.slice(0, typed));
} else if (elapsed < holdEnd) {
setHeaderText(label);
} else {
if (lastTypedChars !== 0) setHeaderText("");
const t = elapsed - holdEnd;
const removed = Math.min(cellCount, Math.floor((t / RESET_MS) * cellCount));
for (let i = 0; i < removed; i++) setFilled(i, false);
}
raf = requestAnimationFrame(tick);
};
const io = new IntersectionObserver((entries) => {
const wasVisible = visible;
visible = entries[0]?.isIntersecting ?? true;
if (visible && !wasVisible) {
// resume without a discontinuity: nudge cycleStart so elapsed picks
// up exactly where it left off, then re-arm the loop.
cycleStart = 0;
if (!raf) raf = requestAnimationFrame((now) => {
cycleStart = now;
raf = requestAnimationFrame(tick);
});
}
});
io.observe(rootRef.current ?? header);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
io.disconnect();
mo.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cellCount, label]);
return (
<div
ref={rootRef}
role="list"
aria-labelledby={headerId}
className={`relative flex h-full w-full flex-col gap-2 ${className}`}
>
<p id={headerId} className="sr-only">
Thumbnail sheet, {label}
</p>
<p
ref={headerRef}
aria-hidden="true"
className="h-3.5 shrink-0 whitespace-nowrap font-mono text-[11px] tracking-widest text-ns-muted"
/>
<div
className="grid min-h-0 flex-1 gap-1.5"
style={{ gridTemplateColumns: `repeat(${grid.cols}, 1fr)`, gridTemplateRows: `repeat(${grid.rows}, 1fr)` }}
>
{Array.from({ length: cellCount }, (_, i) => {
const frame = images[i];
return (
<div
key={i}
role="listitem"
className="relative overflow-hidden rounded-[1px] border border-border"
>
<div
ref={(el) => {
cellRefs.current[i] = el;
}}
className="absolute inset-0 flex items-center justify-center bg-surface opacity-0 outline-none transition-opacity duration-0 data-[filled=true]:opacity-100"
style={{ willChange: "filter" }}
>
{frame ? (
<img
src={frame.src}
alt={frame.alt}
className="h-full w-full object-cover transition-[filter] duration-150 hover:brightness-105 focus-visible:brightness-105"
draggable={false}
/>
) : (
<FrameSilhouette />
)}
</div>
</div>
);
})}
</div>
</div>
);
}
Build spec
Build <FicheStepRepeat images? reductionRatio? className?> as a card-scale DOM grid (CSS grid, no canvas) modeling a COM (computer output microfilm) step-and-repeat camera exposing a fiche sheet. Grid geometry: cols = clamp(round(containerWidth / 64px), 4, 7), rows = clamp(round(containerHeight / 64px), 3, 5), measured via a ResizeObserver on the root and re-derived on every resize, giving the closest card-scale abstraction of a real 7x14 fiche raster. A single requestAnimationFrame loop, driven off one elapsed-time clock from cycle start, walks four phases in order and writes DOM directly per cell (no React state per frame — 12 to 35 tiny cells re-rendering through React every frame is wasted work): (1) GROW — cells populate strictly in raster order (row-major, left to right then down), one cell every 850ms; the instant a cell's turn arrives it snaps from zero-opacity/no-geometry to full geometry in the same frame (a real exposure, never a fade-in), and only its luminance animates on top via an inline CSS filter:brightness() ramp — 120ms up to a peak of settled-value+12% (dark theme) or +8% (light theme, less headroom against near-white thumbnails; the amplitude is read live off a MutationObserver on documentElement's class so a theme flip mid-loop takes effect without a remount), then 280ms relaxing back to brightness(1); (2) TYPE — once every cell has populated, a header string like "24× · 35 FRAMES" (reductionRatio × cellCount) types in left to right over a fixed 600ms window, monospace, character-by-character; (3) HOLD — the complete sheet (all cells populated at rest, full header text) holds for 1.4s; (4) RESET — the header clears instantly and cells blank out in the SAME raster order they filled in (not reversed, not faded), spread evenly across a fixed 400ms regardless of cell count, each individual cell's removal an instant snap — this mirrors nothing in the real process (which never runs backward) and exists purely so the loop's restart reads as "the same sequence, undone" instead of a jump cut. The whole cycle then repeats unbounded. Each cell is two nested elements: an outer box with a `border border-border` outline and no fill, always visible even when empty (a bare separator-token box, never treated as a fill or stroke color), and an inner content layer holding either a caller-supplied `<img>` (from the `images` prop, an array of {src, alt} placed in raster order; cells beyond the supplied count get the placeholder) or a built-in placeholder silhouette (a faint outlined SVG frame/mountain glyph in text-ns-muted at low opacity) — that inner layer is what actually snaps from opacity 0 to opacity 100 the frame its cell is populated, and what the JS-driven brightness filter targets during its flash window. Hover/focus on a real image cell brightens it slightly via a plain CSS transition on the image itself (luminance only, never --ns-accent, never mixed into the highlight) purely to confirm it's a real target — it must never restart, pause, or otherwise perturb the raster cadence, which runs autonomously regardless of pointer input. Root carries role="list" with a visually-hidden aria-labelledby summary naming the reduction ratio and frame count (read once, not re-announced per cell); each cell is role="listitem"; supplied images keep their own alt text, the built-in placeholder is aria-hidden since it's decorative filler, not content. On mount, resume from an IntersectionObserver: when the root re-enters the viewport the rAF clock is re-armed cleanly (cycleStart reset to the resuming frame) rather than fast-forwarding through a stale elapsed offscreen time. prefers-reduced-motion skips the whole loop and renders once, directly, on the full-sheet HOLD frame: every cell populated at rest brightness, header fully typed — the most information-dense, most structured frame, deliberately not t0's near-empty sheet. Cleans up the rAF handle, the ResizeObserver and the IntersectionObserver, and the MutationObserver on unmount. Zero color literals — every color comes from Tailwind utility classes bound to --border, --surface, --ns-muted and --foreground tokens; the only literal numeric values are geometry, timing and the brightness filter multiplier. Zero dependencies.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| images? | FicheFrame[] | [] | real thumbnails placed in raster order; cells beyond the supplied count render the built-in placeholder silhouette |
| reductionRatio? | number | 24 | reduction ratio printed on the index strip, e.g. 24 for "24x" |
| className? | string | — | extra classes merged onto the root element |