Skip to main content

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.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.

Source
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

PropTypeDefaultDescription
images?FicheFrame[][]real thumbnails placed in raster order; cells beyond the supplied count render the built-in placeholder silhouette
reductionRatio?number24reduction ratio printed on the index strip, e.g. 24 for "24x"
className?stringextra classes merged onto the root element