Skip to main content

ns-ui

Profilometer Trace

A section divider rendered as a live contact profilometer readout: a fixed stylus near the right edge while the measured surface's roughness-plus-waviness trace scrolls continuously beneath it, right to left, at a constant real-time rate, peaks and valleys passing under the tip forever.

Usage

registry/core/profilometer-trace/demo.tsx
"use client";

import { ProfilometerTrace } from "./component";

export default function ProfilometerTraceDemo() {
  return (
    <div className="flex min-h-screen w-full flex-col items-center justify-center gap-10 bg-background px-8 py-10">
      <div className="w-full max-w-3xl">
        <h2 className="mb-3 font-mono text-sm text-foreground">Section one</h2>
        <p className="mb-8 max-w-prose font-mono text-xs text-ns-muted">
          A contact profilometer stylus fixed near the right edge, the
          measured surface's roughness-plus-waviness trace scrolling
          continuously beneath it, right to left, forever.
        </p>
        <ProfilometerTrace />
      </div>
      <div className="w-full max-w-3xl">
        <h2 className="mt-8 font-mono text-sm text-foreground">Section two</h2>
      </div>
    </div>
  );
}

Component

registry/core/profilometer-trace/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// ProfilometerTrace — a section divider rendered as a live contact
// profilometer readout: a diamond stylus dragged across a surface at
// constant speed, its vertical deflection recorded as a trace that
// decomposes into roughness (short-wavelength) and waviness (longer-
// wavelength) components, per the standard ISO 4287 / ASME B46.1
// roughness-measurement cutoff-filter convention.
//
// A fixed stylus sits near the right edge of the band. The measured
// surface's trace scrolls continuously beneath it, right to left, at a
// constant real-time rate — old excursions exit the left edge, new ones
// enter at the right, forever. The trace itself is NOT a ring buffer of
// discrete samples pushed/shifted every tick: it is a closed-form function
// of world-space x (continuous, seeded, deterministic) sampled fresh at
// whatever scroll offset the clock has reached. That makes a wrap seam
// structurally impossible rather than something to avoid by bookkeeping —
// there is no wrap, no buffer edge, just a sliding window over an infinite
// deterministic field.
//
// height(x) = 0.6 * valueNoise(x, 18px) + 0.4 * valueNoise(x, 54px)   -- roughness, 2 octaves
//           + 0.4 * sin(2*PI*x / 240px)                               -- waviness, 0.4x roughness amplitude
// normalized to peak deflection = 0.35 * band height.
//
// Scroll offset advances by realSpeed * dt every frame (dt = clamped real
// elapsed ms, never raw frame count) so the rate is identical at 30Hz or
// 144Hz displays. Hovering the band eases realSpeed toward 40% of base
// (a 60% slowdown, never a full stop) and eases back to 100% over 400ms
// after the pointer leaves — a global rate change, not a per-x warp, so
// the trace never tears or shows two speeds across one continuous curve.
// ---------------------------------------------------------------------------

const SPEED_PX_S = 24; // base scroll speed, px/s
const SAMPLE_PITCH = 4; // px between polyline vertices (visual resolution, not a buffer)
const STYLUS_X_FRAC = 0.82; // stylus fixed position, fraction of band width
const PEAK_RATIO = 0.35; // peak deflection = PEAK_RATIO * band height
const ROUGH_WL_A = 18; // roughness octave A wavelength, px
const ROUGH_WL_B = 54; // roughness octave B wavelength, px
const WAVE_WL = 240; // waviness wavelength, px
const WAVE_AMP_RATIO = 0.4; // waviness amplitude, relative to roughness amplitude
const ROUGH_WEIGHT_A = 0.6;
const ROUGH_WEIGHT_B = 0.4;
const RAW_ENVELOPE = ROUGH_WEIGHT_A + ROUGH_WEIGHT_B + WAVE_AMP_RATIO; // normalizer

const HOVER_SLOWDOWN = 0.4; // minimum speed multiplier under the pointer
const HOVER_ENGAGE_MS = 150; // ease-in time constant
const HOVER_RELEASE_MS = 400; // ease-out time constant, per spec

const SEED = 0x9e3779b9;

function hash1(i: number): number {
  let t = (i ^ SEED) >>> 0;
  t = Math.imul(t ^ (t >>> 15), t | 1);
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  const u = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  return u * 2 - 1; // [-1, 1]
}

function smooth(t: number): number {
  return t * t * (3 - 2 * t);
}

// deterministic 1D value noise: hashed lattice at `wavelength` spacing,
// smoothstep-interpolated so it is continuous (and therefore seamless at
// any sampling offset) rather than a discrete buffer.
function valueNoise(x: number, wavelength: number): number {
  const p = x / wavelength;
  const i0 = Math.floor(p);
  const f = smooth(p - i0);
  const a = hash1(i0);
  const b = hash1(i0 + 1);
  return a + (b - a) * f;
}

function traceHeight(worldX: number): number {
  const rough =
    ROUGH_WEIGHT_A * valueNoise(worldX, ROUGH_WL_A) +
    ROUGH_WEIGHT_B * valueNoise(worldX, ROUGH_WL_B);
  const wave = WAVE_AMP_RATIO * Math.sin((2 * Math.PI * worldX) / WAVE_WL);
  return (rough + wave) / RAW_ENVELOPE; // normalized to roughly [-1, 1]
}

export interface ProfilometerTraceProps {
  /** band height in px (divider scale). Default 56. */
  height?: number;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function ProfilometerTrace({
  height = 56,
  className = "",
}: ProfilometerTraceProps) {
  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 reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    // -- token-derived ink: nothing paints until the first successful read --
    let fg = "";
    let mu = "";
    let ready = false;
    const derive = () => {
      const cs = getComputedStyle(document.documentElement);
      const nfg = cs.getPropertyValue("--foreground").trim();
      const nmu = cs.getPropertyValue("--ns-muted").trim();
      if (nfg) fg = nfg;
      if (nmu) mu = nmu;
      ready = fg.length > 0 && mu.length > 0;
    };
    derive();

    let w = 0;
    let h = 0;
    let dpr = 1;
    let visible = true;
    let raf = 0;
    let last = 0;

    let scrollX = 0; // world-space offset of the left edge of the visible band
    let speedMult = 1; // current speed multiplier, eased toward hoverTarget
    let hoverTarget = 1;

    const draw = () => {
      if (!ready || w <= 0 || h <= 0) return;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);

      const midY = h / 2;
      const amp = PEAK_RATIO * h;

      // zero-line reference: a subordinate --ns-muted hairline, never a
      // --border stroke (--border is a separator token, ~1.1:1 contrast in
      // light theme, and would make the trace-vs-baseline read fail exactly
      // where the spec demands separation).
      ctx.strokeStyle = mu;
      ctx.globalAlpha = 0.35;
      ctx.lineWidth = 1;
      ctx.beginPath();
      const zy = Math.round(midY) + 0.5;
      ctx.moveTo(0, zy);
      ctx.lineTo(w, zy);
      ctx.stroke();
      ctx.globalAlpha = 1;

      // trace polyline, sampled at SAMPLE_PITCH from the continuous field —
      // no buffer, no wrap, no seam.
      ctx.strokeStyle = fg;
      ctx.lineWidth = 1.25;
      ctx.lineJoin = "round";
      ctx.lineCap = "round";
      ctx.beginPath();
      let started = false;
      let stylusY = midY;
      const stylusScreenX = w * STYLUS_X_FRAC;
      for (let sx = 0; sx <= w; sx += SAMPLE_PITCH) {
        const worldX = scrollX + sx;
        const y = midY - traceHeight(worldX) * amp;
        if (!started) {
          ctx.moveTo(sx, y);
          started = true;
        } else {
          ctx.lineTo(sx, y);
        }
        if (sx <= stylusScreenX && sx + SAMPLE_PITCH > stylusScreenX) {
          stylusY = y;
        }
      }
      ctx.stroke();

      // stylus: a fixed vertical stroke whose length matches the trace
      // height directly beneath it (the "drop-off" read), plus a tip dot.
      const stylusWorldX = scrollX + stylusScreenX;
      stylusY = midY - traceHeight(stylusWorldX) * amp;
      ctx.strokeStyle = fg;
      ctx.globalAlpha = 0.85;
      ctx.lineWidth = 1.25;
      ctx.beginPath();
      ctx.moveTo(Math.round(stylusScreenX) + 0.5, midY);
      ctx.lineTo(Math.round(stylusScreenX) + 0.5, stylusY);
      ctx.stroke();
      ctx.globalAlpha = 1;
      ctx.fillStyle = fg;
      ctx.beginPath();
      ctx.arc(stylusScreenX, stylusY, 2, 0, Math.PI * 2);
      ctx.fill();
    };

    const loop = (now: number) => {
      raf = 0;
      if (!visible) return;
      const dt = Math.min(50, last === 0 ? 16.7 : now - last);
      last = now;
      const engageMs = hoverTarget < speedMult ? HOVER_ENGAGE_MS : HOVER_RELEASE_MS;
      speedMult += (hoverTarget - speedMult) * Math.min(1, dt / engageMs);
      scrollX += (SPEED_PX_S / 1000) * dt * speedMult;
      draw();
      raf = requestAnimationFrame(loop);
    };
    const wake = () => {
      if (raf === 0 && visible && !reduced) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    // -- reduced motion: find the deterministic phase where the stylus sits
    // directly over the deepest valley in the visible band, freeze there,
    // never start rAF. Named FREEZE_PHASE = deep-valley-lock. -------------
    const freezeAtDeepValley = () => {
      if (w <= 0) return;
      const stylusScreenX = w * STYLUS_X_FRAC;
      const searchSpan = WAVE_WL * 4; // several waviness periods, deterministic
      let bestOffset = 0;
      let bestVal = Infinity;
      for (let off = 0; off < searchSpan; off += 1) {
        const v = traceHeight(off + stylusScreenX);
        if (v < bestVal) {
          bestVal = v;
          bestOffset = off;
        }
      }
      scrollX = bestOffset;
      speedMult = 1;
      draw();
    };

    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));
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
      if (reduced) {
        freezeAtDeepValley();
      } else {
        draw();
      }
    };

    resize();
    if (!reduced) wake();

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

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

    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible) {
        last = 0;
        wake();
      }
    });
    io.observe(root);

    const onVisibility = () => {
      visible = !document.hidden && visible;
      if (!document.hidden) {
        last = 0;
        wake();
      }
    };
    document.addEventListener("visibilitychange", onVisibility);

    // -- hover: global speed multiplier only, never a per-x warp (per-x
    // warping would move different parts of one continuous curve at
    // different rates and tear it — the exact seam the spec forbids). -----
    const onEnter = () => {
      if (reduced) return;
      hoverTarget = HOVER_SLOWDOWN;
      wake();
    };
    const onLeave = () => {
      if (reduced) return;
      hoverTarget = 1;
      wake();
    };
    root.addEventListener("pointerenter", onEnter);
    root.addEventListener("pointerleave", onLeave);

    document.fonts.ready.then(() => {
      derive();
      if (reduced) freezeAtDeepValley();
      else draw();
    });

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      mo.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      root.removeEventListener("pointerenter", onEnter);
      root.removeEventListener("pointerleave", onLeave);
    };
  }, [height]);

  return (
    <div
      ref={rootRef}
      role="separator"
      aria-orientation="horizontal"
      className={`relative w-full overflow-hidden ${className}`}
      style={{ height }}
    >
      <canvas ref={canvasRef} aria-hidden className="block h-full w-full" />
    </div>
  );
}

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

Use when Pick profilometer-trace when the surface is a section divider that should read as a live instrument readout continuously mid-measurement — a fixed stylus with a real roughness/waviness-decomposed trace scrolling under it at a constant px/s rate, never resetting or looping visibly. Pick divider-mosaic-split instead when the divider should read as a videotex page painting in with a discrete write sweep and settle/pause cycle rather than a continuously scrolling instrument trace — the two share the 'canvas band standing in for <hr>' surface but profilometer-trace has no discrete steps, no sync pause, and no field re-sample: it is one continuous seedless-buffer scroll, never a stepped write.

Install

npx shadcn add https://design.helpmarq.com/r/profilometer-trace.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 <ProfilometerTrace height? className?> as a <canvas> band wrapped in a <div role="separator" aria-orientation="horizontal">, a drop-in replacement for <hr>/border-top between page sections. SOURCE, NOT INVENTED: contact stylus profilometry (ISO 4287 / ASME B46.1 surface-roughness measurement) — a diamond stylus dragged across a surface at constant speed, its vertical deflection recorded as a trace that the standard cutoff-filter convention decomposes into roughness (short-wavelength) and waviness (longer-wavelength) components. THE MECHANIC: a stylus sits fixed at x = 82% of the band's width; the measured surface's trace scrolls continuously beneath it, right to left, at a constant 24px/s real-time rate (scrollX += (24/1000) * dt * speedMult every frame, dt = clamped real elapsed ms, never raw frame count — a rate tied to frame count reads as jittery on variable refresh and is an explicit kill criterion). THE TRACE IS NOT A RING BUFFER: it is a closed-form deterministic function of world-space x, height(x) = 0.6*valueNoise(x,18px) + 0.4*valueNoise(x,54px) [roughness, 2 octaves] + 0.4*sin(2*PI*x/240px) [waviness, amplitude 0.4x the roughness weight], normalized by the raw envelope (0.6+0.4+0.4=1.4) and scaled to a peak deflection of 0.35 * band height; valueNoise is a hashed-lattice value noise (mulberry32-style integer hash per lattice index, smoothstep-interpolated between neighbours) evaluated fresh at whatever world offset the clock has reached. Because the field is continuous and evaluated on demand rather than shifted through a fixed-size buffer, a wrap seam is structurally impossible — there is no buffer edge to wrap past. The band is drawn as a polyline sampled every SAMPLE_PITCH=4px across the visible width (visual resolution, not a data structure). REAL NUMBERS: scroll speed 24px/s (~6 new 4px samples/s at the sample pitch), stylus at 82% width, peak deflection 0.35 * height, roughness wavelengths 18px/54px, waviness wavelength 240px at 0.4x amplitude. THE STYLUS: a short vertical --foreground stroke from the zero line down to the trace's exact current height at the stylus's world x (the 'drop-off' read, recomputed every frame from the same height() function used for the polyline, never interpolated from the drawn path) plus a small filled --foreground circle at the tip. RESTING LOOP: t0 renders scrollX=0 already mid-trace (the closed-form field has no blank state to seed away from — every offset, including zero, shows real peaks and valleys); 2.5s later ~60px of new trace (24px/s * 2.5s) has scrolled through, so the segment under the stylus is a different peak/valley arrangement; 5s later a fully distinct excursion sits under the tip, proving continuous unbounded drift rather than a loop with a period a viewer could time. LEGIBILITY: the one thing to follow is the trace sliding past the fixed stylus tip, whose indicator line visibly rises and falls as peaks and valleys pass under it; cadence is a continuous constant-px/s drift (24px/s) rather than any discrete swap, so there is nothing to time a blink against — the eye tracks a smooth drift, not an event. INTERACTION: pointerenter on the band eases a single global speed multiplier toward 0.4 (a 60% slowdown, reading as 'zooming attention' on that stretch) over a 150ms time constant; pointerleave eases it back to 1.0 over a 400ms time constant per spec. This is a GLOBAL multiplier, not a per-x warp keyed to pointer position — the spec's 'radius around the pointer' language was implemented as attention-on-the-whole-band rather than a spatially localized warp, because slowing only the samples near the pointer's x while the rest of one continuous curve kept moving at full rate would tear the trace into two visibly different speeds across a single line, which is exactly the seam the kill criteria forbid. Scroll never fully stops at any multiplier value (floor 0.4x), preserving alive-at-rest. Hover must not, and does not, recolor the trace or stylus with --ns-accent. TOKENS: --foreground (trace stroke, stylus stroke+dot) and --ns-muted (zero-line baseline, drawn at 0.35 alpha) are read once via getComputedStyle(document.documentElement) before first paint and re-derived on a documentElement class MutationObserver; a `ready` gate (both values present) blocks every draw() call — including the very first resize(), the ResizeObserver callback, and the IntersectionObserver resume path — so nothing paints before the first successful token read. --border is deliberately NOT used for the zero-line: --border is a separator token measuring ~1.1:1 contrast in light theme and would make the required trace-vs-baseline separation (the spec's own light-theme legibility requirement) structurally impossible; --ns-muted at reduced alpha is used instead, a real token with real contrast that still reads as visually subordinate to the --foreground trace. --ns-accent never appears. GEOMETRY: derived from the band's own height (its only meaningful dimension at divider scale) via PEAK_RATIO; horizontal wavelength/pitch constants are fixed px values per the spec's real numbers and are not re-derived from width, so at a very narrow card the 240px waviness wavelength can read as a gentle tilt rather than a full period — acceptable, matches how real short-span waviness traces read, and roughness excursion alone stays well above the perceptual floor even at the minimum 48px band height (0.35 * 48 ≈ 16.8px peak-to-zero). Canvas uses devicePixelRatio-scaled backing store with CSS pixel style dimensions set explicitly (never relying on intrinsic canvas size). REDUCED MOTION: FREEZE_PHASE = deep-valley-lock — a deterministic search (since the field is seeded) over several waviness periods of scroll offset for the one where the trace directly beneath the fixed stylus x is at its minimum (deepest valley in the visible band), scrollX is set there once, draw() runs once, rAF never starts; hover listeners are disabled entirely under reduced motion. LIFECYCLE: a ResizeObserver re-measures the band on container size changes; an IntersectionObserver pauses the rAF loop while off-screen and resumes it with a fresh last-timestamp (no giant delta-time jump) on re-entry; a document visibilitychange listener does the same for tab backgrounding; unmount cancels the rAF, disconnects all three observers, and removes both pointer listeners. Direct-DOM rAF, zero React state on the hot path, zero dependencies. A11Y: role=separator carries divider semantics with no accessible name required; the canvas is aria-hidden decoration; there is no keyboard surface because there is nothing to operate — hover is a supplementary pointer-only affordance, not the mechanism that makes the component alive (the continuous scroll is), which is why autoplay.mode is 'none' rather than a pointer mode. Props: height (band height px, default 56, expected divider range 48-64), className.

Props

PropTypeDefaultDescription
height?number56band height in px (divider scale). Default 56.
className?string—extra classes merged onto the rendered root element