Skip to main content

ns-ui

Fax Line Slip

A document-preview / attachment-thumbnail loading state reproducing a Group 3 fax handshake and page transmission: two waveform-rendered handshake tones, then the page builds in as horizontal scan lines top to bottom, with one deliberate paper-slip shear partway down the frame before the lines resume true.

Use when a document preview, attachment thumbnail, or file-resolving panel needs a connecting/loading state sourced from a real fax transmission mechanic — audible-register handshake tones rendered as a waveform, then content arriving as a top-to-bottom scan-line build with a genuine mechanical paper-slip fault baked into one band. Pick eink-waveform-ghost instead when the idle motion should read as a settled display continuously self-refreshing (per-cell electrophoretic waveform replays with shoot-through overshoot) rather than a one-shot connect-then-arrive sequence with a start and a hold. Pick skeleton-develop instead when the panel is a generic loading placeholder for arbitrary declared content blocks with no print/scan sourcing and no mechanical-fault detail.

Install

npx shadcn add https://design.helpmarq.com/r/fax-line-slip.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/fax-line-slip/component.tsx
"use client";

import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";

// ---------------------------------------------------------------------------
// FaxLineSlip — a document-preview / attachment-thumbnail loading state
// reproducing a Group 3 fax handshake and page transmission, not a generic
// spinner or shimmer. Source: ITU-T T.30 handshake tones (calling machine
// sends a pulsed CNG tone, the answering machine holds a CED tone) followed
// by the page transmitting as sequential horizontal scan lines at the
// negotiated modem rate, plus the documented thermal-fax mechanical fault
// where the paper momentarily slips through the feed rollers mid-page,
// shearing a thin band of the received image sideways before lines resume
// true.
//
// One cycle, four phases, drawn onto a single persistent canvas that is
// never cleared mid-build (a real received page is composed, not repainted):
//
// 1. Handshake (2200ms): a waveform trace occupies the top ~15% of the
//    frame. A tight-period sine burst stands in for the 1100Hz CNG tone
//    (500ms), a silent 500ms gap, then a tighter-period burst stands in for
//    the 2100Hz CED tone (1200ms) — 500+500+1200 = 2200ms exactly, drawn at
//    human-followable pace because it already is one (not sped up).
// 2. Scan build: one scan line commits every 45ms (~22 lines/sec — real G3
//    at 9600bps is ~5-6 lines/sec, but rendering 220 lines at that real rate
//    would take ~40s, unbearably slow for a resting loop, so this is
//    compressed for pace, not decoupled for alias-avoidance). Line count is
//    derived from the container's own smaller dimension (one line per ~2px
//    of height, capped at 220) so the build reads at card scale. At a
//    randomized point 55-75% down the frame, one 3-line band commits with a
//    hard 14-22px horizontal offset baked permanently into those three
//    lines — the paper-slip artifact — then the very next line resumes at
//    zero offset with no easing, because a real slip is a sudden mechanical
//    discontinuity, not a glitch that repeats.
// 3. Hold (1800ms): the fully composed page sits still.
// 4. Reset (200ms): the frame does not fade — it clears top-to-bottom in one
//    wipe, mirroring a fresh page feeding in, then the handshake replays
//    with a freshly reseeded page and a freshly randomized slip position.
//
// The "document" content itself is a procedural stand-in for scanned text —
// deterministic hash-derived horizontal strokes with word-like gaps, grouped
// into text lines with occasional paragraph breaks — reseeded every cycle so
// no two received pages look identical. `--ns-accent` never appears; ink is
// `--foreground` over a `--background` field, read live via
// getComputedStyle + a MutationObserver on the theme class, matching the dark
// marks on light stock a real thermal print reads (dark theme inverts the
// relationship rather than swapping which token is which).
// ---------------------------------------------------------------------------

export interface FaxLineSlipProps {
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** inline styles merged onto the root element */
  style?: CSSProperties;
  /** sr-only status text describing the loading state */
  label?: string;
}

type RGB = [number, number, number];

function parseColor(raw: string): RGB | null {
  const s = raw.trim();
  if (s.startsWith("#")) {
    let hex = s.slice(1);
    if (hex.length === 3) hex = hex.split("").map((c) => c + c).join("");
    if (hex.length < 6) return null;
    const r = parseInt(hex.slice(0, 2), 16);
    const g = parseInt(hex.slice(2, 4), 16);
    const b = parseInt(hex.slice(4, 6), 16);
    return Number.isNaN(r + g + b) ? null : [r, g, b];
  }
  const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}

function hash01(x: number, y: number): number {
  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;
  return n - Math.floor(n);
}

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;
  };
}

// -- handshake timing: 500ms CNG-stand-in burst, 500ms silence, 1200ms
// CED-stand-in burst — total 2200ms, real ITU-T T.30 pace, not compressed. --
const BURST1_MS = 500;
const GAP_MS = 500;
const BURST2_MS = 1200;
const HANDSHAKE_MS = BURST1_MS + GAP_MS + BURST2_MS; // 2200ms
const BURST1_CYCLES = 15; // tight-period trace standing in for 1100Hz
const BURST2_CYCLES = 28; // tighter-period trace standing in for 2100Hz

// -- scan build: one committed line every 45ms, capped at 220 for card scale --
const LINE_INTERVAL_MS = 45;
const MAX_LINES = 220;
const MIN_LINES = 30;
const SLIP_BAND_LINES = 3;
const SLIP_MIN_PX = 14;
const SLIP_MAX_PX = 22;
const SLIP_RANGE_START = 0.55; // fraction down the frame the slip may start
const SLIP_RANGE_SPAN = 0.2; // 0.55 - 0.75

const HOLD_MS = 1800;
const WIPE_MS = 200;

export function FaxLineSlip({ className = "", style, label = "Connecting" }: FaxLineSlipProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

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

    // -- token-derived ink/paper: read at mount, re-derived on theme change --
    let fg: RGB = [0, 0, 0];
    let bg: RGB = [255, 255, 255];
    let fgCSS = "rgb(0,0,0)";
    let bgCSS = "rgb(255,255,255)";
    const derive = () => {
      const cs = getComputedStyle(document.documentElement);
      fg = parseColor(cs.getPropertyValue("--foreground")) ?? fg;
      bg = parseColor(cs.getPropertyValue("--background")) ?? bg;
      fgCSS = `rgb(${fg[0]},${fg[1]},${fg[2]})`;
      bgCSS = `rgb(${bg[0]},${bg[1]},${bg[2]})`;
    };
    derive();

    let w = 0;
    let h = 0;
    let dpr = 1;
    let lineCount = MIN_LINES;
    let rowH = 1;
    let letterCell = 3;
    let lineSpacing = 7;
    let marginPx = 6;

    let seed = 0;
    let slipStartLine = -1;
    let slipOffsetPx = 0;
    let cycleBase = 0;
    let cycleIndex = 0;
    let lastLineDrawn = -1;
    let scanCleared = false;

    let raf = 0;
    let visible = true;

    // -- one procedurally-baked text line's metadata: blank (paragraph gap)
    // or a right margin fraction, deterministic per lineIndex+seed so every
    // raster row belonging to that text line agrees on its shape. ----------
    const lineMeta = (lineIndex: number) => {
      const blankRoll = hash01(lineIndex * 1.7 + seed, 3.1);
      if (blankRoll < 0.12) return { blank: true, widthFrac: 0 };
      const widthFrac = 0.45 + hash01(lineIndex * 2.3 + seed, 7.7) * 0.45;
      return { blank: false, widthFrac };
    };

    // -- commit one raster row of the document's baked content, offset in x
    // by xOffsetPx when this row falls inside the current cycle's slip band. --
    const drawDocumentRow = (y0: number, y1: number, xOffsetPx: number) => {
      const midY = (y0 + y1) / 2;
      const lineIndex = Math.floor(midY / lineSpacing);
      const meta = lineMeta(lineIndex);
      if (meta.blank) return;
      const bandFrac = (midY - lineIndex * lineSpacing) / lineSpacing;
      if (bandFrac > 0.5) return; // gap between text lines
      const usableW = w - marginPx * 2;
      const rightEdge = marginPx + meta.widthFrac * usableW;
      ctx.fillStyle = fgCSS;
      for (let x = marginPx; x < rightEdge; x += letterCell) {
        const bucket = Math.floor(x / letterCell);
        const v = hash01(lineIndex * 5.13 + seed, bucket * 3.71 + seed);
        if (v > 0.34) {
          ctx.fillRect(x + xOffsetPx, y0, letterCell * 0.82, Math.max(1, y1 - y0));
        }
      }
    };

    const clearCanvas = () => {
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.fillStyle = bgCSS;
      ctx.fillRect(0, 0, w, h);
    };

    const commitLine = (lineIdx: number) => {
      const y0 = lineIdx * rowH;
      const y1 = y0 + rowH;
      const inSlip = lineIdx >= slipStartLine && lineIdx < slipStartLine + SLIP_BAND_LINES;
      drawDocumentRow(y0, y1, inSlip ? slipOffsetPx : 0);
    };

    const drawWaveform = (t: number) => {
      const bandH = h * 0.15;
      ctx.fillStyle = bgCSS;
      ctx.fillRect(0, 0, w, bandH);
      const midY = bandH * 0.5;
      const amp = bandH * 0.32;
      ctx.strokeStyle = fgCSS;
      ctx.lineWidth = Math.max(1, Math.min(w, h) / 220);

      const drawBurst = (revealFrac: number, cycles: number) => {
        if (revealFrac <= 0) return;
        const revealW = w * revealFrac;
        const steps = Math.max(2, Math.floor(revealW / 2));
        ctx.beginPath();
        for (let i = 0; i <= steps; i++) {
          const x = (i / steps) * revealW;
          const phase = (x / w) * cycles * Math.PI * 2;
          const y = midY + Math.sin(phase) * amp;
          if (i === 0) ctx.moveTo(x, y);
          else ctx.lineTo(x, y);
        }
        ctx.stroke();
      };

      if (t < BURST1_MS) {
        drawBurst(t / BURST1_MS, BURST1_CYCLES);
      } else if (t < BURST1_MS + GAP_MS) {
        // silent gap — band stays clear
      } else {
        const t2 = t - BURST1_MS - GAP_MS;
        drawBurst(Math.min(1, t2 / BURST2_MS), BURST2_CYCLES);
      }
    };

    const drawWipe = (frac: number) => {
      ctx.fillStyle = bgCSS;
      ctx.fillRect(0, 0, w, h * frac);
    };

    const reseedCycle = () => {
      seed = cycleIndex * 811.7;
      const rand = mulberry32(0x9e3779b9 ^ (cycleIndex + 1));
      slipStartLine = Math.max(
        0,
        Math.min(lineCount - SLIP_BAND_LINES, Math.floor(lineCount * (SLIP_RANGE_START + rand() * SLIP_RANGE_SPAN)))
      );
      const mag = SLIP_MIN_PX + rand() * (SLIP_MAX_PX - SLIP_MIN_PX);
      slipOffsetPx = rand() < 0.5 ? mag : -mag;
      lastLineDrawn = -1;
      scanCleared = false;
    };

    const totalCycleMs = () => HANDSHAKE_MS + lineCount * LINE_INTERVAL_MS + HOLD_MS + WIPE_MS;

    const drawStaticFrame = () => {
      // reduced-motion freeze: the fully-scanned page at rest, slip baked in
      reseedCycle();
      clearCanvas();
      for (let i = 0; i < lineCount; i++) commitLine(i);
    };

    const loop = (now: number) => {
      let cycleTime = now - cycleBase;
      const total = totalCycleMs();
      if (cycleTime >= total) {
        const laps = Math.floor(cycleTime / total);
        cycleBase += laps * total;
        cycleIndex += laps;
        reseedCycle();
        clearCanvas();
        cycleTime = now - cycleBase;
      }

      const scanMs = lineCount * LINE_INTERVAL_MS;
      if (cycleTime < HANDSHAKE_MS) {
        drawWaveform(cycleTime);
      } else if (cycleTime < HANDSHAKE_MS + scanMs) {
        if (!scanCleared) {
          clearCanvas();
          scanCleared = true;
        }
        const scanElapsed = cycleTime - HANDSHAKE_MS;
        const targetLine = Math.min(lineCount - 1, Math.floor(scanElapsed / LINE_INTERVAL_MS));
        while (lastLineDrawn < targetLine) {
          lastLineDrawn++;
          commitLine(lastLineDrawn);
        }
      } else if (cycleTime < HANDSHAKE_MS + scanMs + HOLD_MS) {
        if (!scanCleared) {
          clearCanvas();
          scanCleared = true;
        }
        while (lastLineDrawn < lineCount - 1) {
          lastLineDrawn++;
          commitLine(lastLineDrawn);
        }
      } else {
        const wipeT = cycleTime - HANDSHAKE_MS - scanMs - HOLD_MS;
        drawWipe(Math.min(1, wipeT / WIPE_MS));
      }

      if (!reduced && visible) raf = requestAnimationFrame(loop);
      else raf = 0;
    };

    const wake = () => {
      if (raf === 0 && !reduced && visible) raf = requestAnimationFrame(loop);
    };

    const resize = () => {
      const rect = root.getBoundingClientRect();
      w = rect.width;
      h = rect.height;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));

      // scan-line resolution derived from the container's smaller dimension
      const minDim = Math.min(w, h);
      lineCount = Math.min(MAX_LINES, Math.max(MIN_LINES, Math.round(h / 2)));
      rowH = h / lineCount;
      letterCell = Math.min(5, Math.max(2, minDim / 70));
      lineSpacing = Math.min(9, Math.max(5, minDim / 28));
      marginPx = Math.max(4, w * 0.05);

      cycleBase = performance.now();
      cycleIndex = 0;
      reseedCycle();

      if (reduced) {
        drawStaticFrame();
      } else {
        clearCanvas();
        wake();
      }
    };

    resize();

    const ro = new ResizeObserver(resize);
    ro.observe(root);

    const mo = new MutationObserver(() => {
      derive();
      if (reduced) drawStaticFrame();
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    const onReducedChange = () => {
      reduced = mq.matches;
      if (reduced) {
        cancelAnimationFrame(raf);
        raf = 0;
        drawStaticFrame();
      } else {
        cycleBase = performance.now();
        cycleIndex = 0;
        reseedCycle();
        clearCanvas();
        wake();
      }
    };
    mq.addEventListener("change", onReducedChange);

    const onVisibility = () => {
      visible = document.visibilityState === "visible";
      if (visible) wake();
      else {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    };
    document.addEventListener("visibilitychange", onVisibility);

    const io = new IntersectionObserver((entries) => {
      const intersecting = entries[0]?.isIntersecting ?? true;
      visible = intersecting && document.visibilityState === "visible";
      if (visible) wake();
      else {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    });
    io.observe(root);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      mo.disconnect();
      io.disconnect();
      mq.removeEventListener("change", onReducedChange);
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  return (
    <div
      ref={rootRef}
      role="status"
      aria-busy="true"
      className={`ns-fls relative h-full w-full overflow-hidden bg-background ${className}`}
      style={style}
    >
      <span className="sr-only">{label}</span>
      <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full" />
    </div>
  );
}

FaxLineSlip.displayName = "FaxLineSlip";

export default FaxLineSlip;
Build spec

Build <FaxLineSlip label? className? style?>, a self-contained canvas-rendered loading/connecting state for a document preview or attachment thumbnail, sourced from the ITU-T T.30 Group 3 fax handshake and page-transmission sequence plus the documented thermal-fax paper-slip mechanical fault, not an invented spinner texture. Root is `relative h-full w-full overflow-hidden bg-background` with `role="status" aria-busy="true"`, an sr-only status label (default 'Connecting'), and an absolutely positioned `aria-hidden` canvas filling the host (`w-full h-full`, DPR-capped at 2, backing store sized off `getBoundingClientRect`). One cycle runs four phases on a SINGLE canvas that is never cleared mid-build (content accumulates, like a page actually being received), driven by elapsed time via `performance.now() - cycleBase`, never frame count: 1. Handshake, fixed 2200ms (`BURST1_MS=500, GAP_MS=500, BURST2_MS=1200`): a waveform trace occupies the top 15% of the frame height. First 500ms draws a tight-period sine trace (15 cycles across the container width) standing in for the 1100Hz CNG tone, revealed left-to-right as `revealFrac = t/500` (redraw the band's background then stroke the partial trace every frame — the trace visibly builds, it does not appear instantly). Next 500ms the band is silent (cleared to background, nothing drawn). Final 1200ms draws a tighter-period trace (28 cycles) standing in for the 2100Hz CED tone, same left-to-right reveal logic. This handshake plays at its real, human-followable pace — it is the ONE audible-register detail rendered visually and is deliberately not sped up. 2. Scan build: once the handshake ends, clear the whole canvas to `--background` once, then commit exactly one scan line every 45ms (`LINE_INTERVAL_MS=45`, ~22 lines/sec — real G3 at 9600bps is ~5-6 lines/sec, but rendering 220 lines at that real rate would take ~40s, too slow for a resting loop, so this is compressed for pace, not decoupled for alias-avoidance). Line count is derived from the container's own smaller dimension: `lineCount = clamp(round(height/2), 30, 220)`, each line's raster row height `rowH = height/lineCount`. Track a `lastLineDrawn` cursor and, each frame, advance it up to `floor(scanElapsed/45)` clamped to `lineCount-1`, drawing each newly-committed row exactly once and leaving already-drawn rows untouched (this is what makes the build read as content arriving, not a repaint). Each row's content is procedurally baked 'scanned text', deterministic per `(lineIndex, seed)` so every raster row belonging to the same text line agrees: `lineSpacing = clamp(minDim/28, 5, 9)` px groups raster rows into text lines; a per-line hash (`hash01(lineIndex*1.7+seed, 3.1) < 0.12`) marks ~12% of lines as blank paragraph gaps; surviving lines get a right-margin `widthFrac` in [0.45, 0.9] from a second per-line hash; within a line's top half band (`bandFrac <= 0.5`, giving each text line visible gaps between it and the next), fill `letterCell`-wide buckets (`letterCell = clamp(minDim/70, 2, 5)`) left-to-right from a 5% left margin to the right edge, each bucket inked via `hash01(lineIndex*5.13+seed, bucket*3.71+seed) > 0.34` — word-like dashes, not a solid bar. `seed` is reseeded every cycle (`seed = cycleIndex*811.7`) so no two received pages match. Paper slip: once per cycle, at reseed time, pick `slipStartLine = clamp(floor(lineCount*(0.55+rand()*0.20)), 0, lineCount-3)` and `slipOffsetPx = ±(14 + rand()*8)` from a per-cycle seeded RNG. The 3 rows in `[slipStartLine, slipStartLine+3)` are drawn with their x positions shifted by `slipOffsetPx`; every other row draws at zero offset. Because each row is committed once, in order, with no easing between rows, the shift appears as a hard, instantaneous discontinuity exactly when the scan front reaches that band, and the very next committed row snaps back to true — never repeating within the cycle. 3. Hold, fixed 1800ms: the fully composed page sits static, no redraw needed (canvas already holds the final pixels). 4. Reset, fixed 200ms: the frame does not fade — each frame fill a `--background` rect from y=0 to `height * min(1, wipeElapsed/200)`, wiping the page away top to bottom like a fresh page feeding in. At the end of this phase the cycle wraps: reseed (`seed`, new slip position/offset), clear the canvas, and the handshake replays. Colour: `--foreground` ink over a `--background` field, both read via `getComputedStyle(document.documentElement)` at mount and re-derived on a `MutationObserver` watching `documentElement`'s class (no colour literal anywhere except the pre-first-read JS fallback pair) — dark marks on light stock in light theme, and dark theme inverts which token paints which without swapping the token names. `--ns-accent` never appears anywhere in the canvas. Verify at card scale in light theme specifically that the paper-slip band's edge stays legible — it needs the same value contrast as every other row, only a position shift, so check it isn't reading as imperceptible against the already-lower light-theme base contrast. The rAF loop is paused via `document.visibilitychange` and an `IntersectionObserver` (both gate a `wake()` that only restarts the loop if not reduced-motion and currently visible), and a `ResizeObserver` triggers a full `resize()` that recomputes geometry, restarts `cycleBase`, reseeds, and clears the canvas (a resized panel gets a freshly regenerated page, not a stretched old one). `prefers-reduced-motion: reduce` never starts the rAF loop at all: it reseeds once, clears, and synchronously commits every line via the same `commitLine` path used by the live build, freezing on the fully-scanned page WITH the paper-slip artifact baked into its band — the most information-carrying frame, showing both the completed content and the fault that gives the component its identity. No interaction — this is a passive connecting state. On unmount: cancel the rAF, disconnect the ResizeObserver/MutationObserver/IntersectionObserver, remove the visibilitychange listener and the reduced-motion media query listener. No dependencies.

Props

PropTypeDefaultDescription
className?stringextra classes merged onto the rendered root element
style?CSSPropertiesinline styles merged onto the root element
label?string"Connecting"sr-only status text describing the loading state