Skip to main content

ns-ui / loud

Kymograph Smoke Trace

A full-bleed hero built on a subtractive recording mechanic: a soot-coated drum turns beneath a fixed stylus that scratches a bright, permanent trace through the dark coating, while a resmoking brush at the trailing edge repaints the outgoing soot fresh so the drum recirculates forever.

Use when a full-bleed hero that should feel like something is being permanently, physically recorded right now — a lab notebook, precision-instrument, or health/measurement product page wanting an inked, mechanical mood rather than a digital chart; pick hero-chart-recorder instead when the point is a live value the pen is visibly chasing with mechanical lag (spring overshoot/ring/settle) rather than a subtractive scratch-through-soot texture, dye-whorl instead for an additive ink-diffusion field with no drum/stylus mechanism at all, and flyback-tear instead for a failing-broadcast CRT mood rather than a physiology-lab recording instrument.

Install

npx shadcn add https://design.helpmarq.com/r/kymograph-smoke-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.

Source
registry/loud/kymograph-smoke-trace/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// KymographSmokeTrace — a full-bleed hero built on a subtractive recording
// mechanic: a soot-coated drum turns beneath a fixed stylus that scratches a
// bright, permanent trace through the dark coating. The drum's circumference
// is wider than the visible window, so a hidden arc carries the trace off
// past the LEFT edge before it can reappear at the WRITE point on the right
// — a resmoking brush lives at the left edge and repaints that outgoing soot
// back to full density just before it exits view, which is what lets the
// drum recirculate forever without ever hard-resetting the canvas.
//
// Everything is drawn into one persistent offscreen buffer (the "drum"); the
// visible canvas each frame is just a window blitted out of that buffer, so
// the scratch trace is genuinely permanent ink, not a redrawn sample array.
// ---------------------------------------------------------------------------

type Vec3 = [number, number, number];

function parseColor(raw: string): Vec3 | null {
  const s = raw.trim();
  if (s.startsWith("#")) {
    const hex = s.slice(1);
    if (hex.length === 3) {
      const r = parseInt(hex[0]! + hex[0], 16);
      const g = parseInt(hex[1]! + hex[1], 16);
      const b = parseInt(hex[2]! + hex[2], 16);
      return Number.isNaN(r + g + b) ? null : [r, g, b];
    }
    if (hex.length >= 6) {
      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];
    }
    return null;
  }
  const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}

// -- real numbers (documented in the spec, not exposed as props: this
// specific mechanical character IS the component) --------------------------
const SPEED_PX_S = 40; // drum surface speed
const REVOLUTION_FACTOR = 1.8; // drum circumference = container width * this
const WRITE_INSET = 26; // px from the right edge — the fixed stylus point
const BRUSH_INSET = 26; // px from the left edge — the fixed resmoke point
const TWITCH_INTERVAL_S = 3.2; // mean seconds between twitch events
const TWITCH_JITTER_S = 0.5; // +/- randomization on that interval
const TWITCH_RISE_S = 0.12;
const TWITCH_DECAY_S = 0.6;
const TWITCH_MIN_AMP = 18;
const TWITCH_MAX_AMP = 30;
const BASELINE_JITTER_AMP = 1.5;
const SCRATCH_WIDTH = 2;
const STATIC_TIME_S = 9.8; // reduced-motion freeze frame, deliberately non-t0
const DPR_CAP = 1.5;

// small deterministic PRNG so the reduced-motion freeze frame is built the
// same way the live loop would build it, just run once and stopped.
function mulberry32(seed: number) {
  let a = seed;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export interface KymographSmokeTraceProps {
  /** content rendered on top of the drum, e.g. a headline + CTA */
  children?: React.ReactNode;
  className?: string;
}

export function KymographSmokeTrace({
  children,
  className = "",
}: KymographSmokeTraceProps) {
  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;

    // -- token-derived ink, read at mount and re-derived on theme flip ------
    let bg: Vec3 = [10, 10, 10];
    let fg: Vec3 = [237, 237, 237];
    let mu: Vec3 = [110, 110, 110];
    const derive = () => {
      const cs = getComputedStyle(document.documentElement);
      bg = parseColor(cs.getPropertyValue("--background")) ?? bg;
      fg = parseColor(cs.getPropertyValue("--foreground")) ?? fg;
      mu = parseColor(cs.getPropertyValue("--ns-muted")) ?? mu;
    };
    derive();

    // -- hot-path state: locals only, never React state ---------------------
    let w = 0;
    let h = 0;
    let dpr = 1;
    let bufW = 0; // drum circumference, device px
    let buf: HTMLCanvasElement | null = null;
    let bctx: CanvasRenderingContext2D | null = null;
    let raf = 0;
    let last = 0;
    let paused = false;
    let reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let scrollOffset = 0; // world px, wraps at bufW (CSS px, not device)
    let prevWriteWorldX = 0;
    let prevStylusY = 0;
    let stylusY = 0; // css px, relative to h
    let nextTwitchAt = 0;
    let twitchStart = -Infinity;
    let twitchAmp = 0;
    let simTime = 0;
    let rand = Math.random;

    // -- soot texture: a muted fill biased toward the background so the
    // scratch trace (drawn at full foreground contrast) always reads as the
    // far end of the ramp from it in both themes -----------------------------
    const sootRGBA = (alpha: number) =>
      `rgba(${Math.round((mu[0] + bg[0]) / 2)},${Math.round(
        (mu[1] + bg[1]) / 2
      )},${Math.round((mu[2] + bg[2]) / 2)},${alpha})`;

    const paintSoot = (x0: number, width: number) => {
      if (!bctx || !buf || width <= 0) return;
      // a few overlapping rects at slightly varying alpha reads as a
      // textured coating rather than a flat fill
      bctx.fillStyle = sootRGBA(1);
      bctx.fillRect(x0, 0, width, buf.height);
      const grain = Math.max(2, Math.round(width / 6));
      for (let i = 0; i < grain; i++) {
        const gx = x0 + rand() * width;
        const gy = rand() * buf.height;
        const gw = 1 + rand() * 3;
        const gh = 1 + rand() * 3;
        bctx.fillStyle = sootRGBA(0.35 + rand() * 0.25);
        bctx.fillRect(gx, gy, gw, gh);
      }
    };

    const twitchOffset = (t: number) => {
      const dt = t - twitchStart;
      if (dt < 0) return 0;
      const rise = Math.min(1, dt / TWITCH_RISE_S);
      const decay = Math.exp(-dt / TWITCH_DECAY_S);
      const ring = Math.sin((dt / TWITCH_DECAY_S) * Math.PI * 2.4);
      return twitchAmp * rise * decay * ring;
    };

    const scheduleTwitch = (t: number) => {
      nextTwitchAt =
        t + TWITCH_INTERVAL_S + (rand() * 2 - 1) * TWITCH_JITTER_S;
      twitchAmp = TWITCH_MIN_AMP + rand() * (TWITCH_MAX_AMP - TWITCH_MIN_AMP);
    };

    const stylusYAt = (t: number) => {
      const jitter = Math.sin(t * 5.3) * BASELINE_JITTER_AMP * 0.6 +
        Math.sin(t * 11.7 + 1.4) * BASELINE_JITTER_AMP * 0.4;
      return h / 2 + jitter + twitchOffset(t);
    };

    // one simulation step: advances scrollOffset, scratches the buffer at
    // the write point, and resmokes the buffer at the brush point.
    const step = (dt: number) => {
      simTime += dt;
      if (simTime >= nextTwitchAt) {
        twitchStart = simTime;
        scheduleTwitch(simTime);
      }
      const prevOffset = scrollOffset;
      scrollOffset = (scrollOffset + SPEED_PX_S * dt) % (bufW / dpr);
      stylusY = stylusYAt(simTime);

      if (!bctx || !buf) return;
      const bufWCss = bufW / dpr;
      const writeWorldX = (scrollOffset + WRITE_INSET) % bufWCss;
      const wrapped = writeWorldX < prevWriteWorldX - 1; // crossed the seam
      bctx.strokeStyle = `rgb(${fg[0]},${fg[1]},${fg[2]})`;
      bctx.lineWidth = SCRATCH_WIDTH;
      bctx.lineCap = "round";
      if (!wrapped) {
        bctx.beginPath();
        bctx.moveTo(prevWriteWorldX * dpr, prevStylusY * dpr);
        bctx.lineTo(writeWorldX * dpr, stylusY * dpr);
        bctx.stroke();
      }
      prevWriteWorldX = writeWorldX;
      prevStylusY = stylusY;

      // resmoke: a fixed screen point (BRUSH_INSET from the left) that
      // continuously repaints whatever world column is currently passing
      // beneath it back to fresh soot, right before that column scrolls
      // off the visible window for good.
      const advance = Math.max(1, Math.ceil((scrollOffset - prevOffset + bufWCss) % bufWCss * dpr));
      const brushWorldX = ((scrollOffset + BRUSH_INSET) % bufWCss) * dpr;
      paintSoot(brushWorldX - advance, advance + 1);
    };

    const blit = () => {
      if (!buf) return;
      const bufWCss = bufW / dpr;
      const srcX = (scrollOffset % bufWCss) * dpr;
      const destWpx = Math.round(w * dpr);
      const hpx = Math.round(h * dpr);
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.clearRect(0, 0, destWpx, hpx);
      const firstChunk = Math.min(destWpx, buf.width - srcX);
      ctx.drawImage(buf, srcX, 0, firstChunk, hpx, 0, 0, firstChunk, hpx);
      if (firstChunk < destWpx) {
        const remaining = destWpx - firstChunk;
        ctx.drawImage(buf, 0, 0, remaining, hpx, firstChunk, 0, remaining, hpx);
      }
    };

    const initBuffer = () => {
      const bufWCss = Math.max(w, 480) * REVOLUTION_FACTOR;
      bufW = Math.max(1, Math.round(bufWCss * dpr));
      const bh = Math.max(1, Math.round(h * dpr));
      buf = document.createElement("canvas");
      buf.width = bufW;
      buf.height = bh;
      bctx = buf.getContext("2d");
      scrollOffset = 0;
      prevWriteWorldX = WRITE_INSET;
      prevStylusY = h / 2;
      simTime = 0;
      nextTwitchAt = TWITCH_INTERVAL_S * 0.4;
      twitchStart = -Infinity;
      if (bctx) paintSoot(0, bufW);
    };

    const renderStatic = () => {
      // deterministic replay up to STATIC_TIME_S, then stop — the frame
      // never changes again, satisfying the reduced-motion byte-stability
      // check.
      rand = mulberry32(20260827);
      initBuffer();
      const stepDt = 1 / 60;
      let t = 0;
      while (t < STATIC_TIME_S) {
        const d = Math.min(stepDt, STATIC_TIME_S - t);
        step(d);
        t += d;
      }
      blit();
    };

    const draw = (now: number) => {
      const dt = Math.min(0.05, last === 0 ? 1 / 60 : (now - last) / 1000);
      last = now;
      step(dt);
      blit();
    };

    const rafLoop = (now: number) => {
      draw(now);
      if (!paused) raf = requestAnimationFrame(rafLoop);
    };

    const stopLoop = () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      last = 0;
    };

    const startLoop = () => {
      stopLoop();
      if (paused) return;
      if (reduced) {
        renderStatic();
        return;
      }
      rand = Math.random;
      raf = requestAnimationFrame(rafLoop);
    };

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

    resize();

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

    const onThemeChange = () => {
      derive();
      // soot already painted with the old tokens keeps its shape; only the
      // colour of future paints changes, matching how a MutationObserver
      // re-derive is expected to behave without a hard reset.
    };
    const mo = new MutationObserver(onThemeChange);
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "data-theme"],
    });
    const colorScheme = window.matchMedia("(prefers-color-scheme: dark)");
    colorScheme.addEventListener("change", onThemeChange);

    const reducedMq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const onReducedChange = () => {
      reduced = reducedMq.matches;
      startLoop();
    };
    reducedMq.addEventListener("change", onReducedChange);

    const onVisibility = () => {
      paused = document.hidden;
      if (paused) stopLoop();
      else startLoop();
    };
    document.addEventListener("visibilitychange", onVisibility);

    let io: IntersectionObserver | undefined;
    if ("IntersectionObserver" in window) {
      io = new IntersectionObserver((entries) => {
        const entry = entries[0];
        if (!entry) return;
        paused = !entry.isIntersecting || document.hidden;
        if (paused) stopLoop();
        else startLoop();
      });
      io.observe(root);
    }

    return () => {
      stopLoop();
      ro.disconnect();
      mo.disconnect();
      colorScheme.removeEventListener("change", onThemeChange);
      reducedMq.removeEventListener("change", onReducedChange);
      document.removeEventListener("visibilitychange", onVisibility);
      io?.disconnect();
    };
  }, []);

  return (
    <div
      ref={rootRef}
      className={`relative h-full w-full overflow-hidden bg-background ${className}`}
    >
      <canvas
        ref={canvasRef}
        aria-hidden
        className="pointer-events-none absolute inset-0 h-full w-full"
      />
      {children}
    </div>
  );
}
Build spec

`<KymographSmokeTrace children? className? />` renders a full-bleed hero canvas simulating a 19th-century kymograph: a drum wrapped in soot-blackened paper turns at a constant surface speed (40px/s, hardcoded, not a prop — this specific mechanical character is the component's identity) beneath a fixed stylus that scratches a bright permanent trace through the soot as the drum rotates. Internally the whole drum surface is drawn into ONE PERSISTENT OFFSCREEN CANVAS (`buf`) whose width is the drum's circumference — `max(containerWidth, 480) * 1.8` CSS px (device-scaled by a DPR capped at 1.5) — deliberately wider than the visible window, so a hidden arc of the drum exists off both edges of what's shown. Every rAF frame accumulates real elapsed time into `scrollOffset` (mod the circumference) and the visible `<canvas>` is just a window blitted out of `buf` at that offset (two `drawImage` calls when the window straddles the buffer's wrap seam) — nothing about the trace is redrawn from a sample array; ink drawn into `buf` stays there permanently until the resmoke brush repaints over it. The STYLUS is a fixed point on screen, `WRITE_INSET = 26px` from the right edge; each frame its world-space x-coordinate is `(scrollOffset + WRITE_INSET) mod circumference`, and a line segment is stroked into `buf` (in `--foreground`, 2px, round caps) from the previous frame's write position to the current one at a y driven by a twitch generator: low-amplitude sinusoidal baseline jitter (`±1.5px`, two summed sine terms) plus a discrete twitch event that fires on average every 3.2s (jittered ±0.5s) — a fast rise (0.12s) into an exponentially-decaying (0.6s time constant) ~2.4-cycle ring, amplitude randomized 18-30px — modeled as a muscle-twitch/pulse trace, not literal biodata. The RESMOKE BRUSH is a second fixed point, `BRUSH_INSET = 26px` from the LEFT edge; every frame it repaints the `buf` column(s) currently passing beneath that screen position back to fresh soot (a semi-transparent fill derived from `--ns-muted` mixed toward `--background`, plus a scatter of small randomized-alpha grain rects for texture) — since content only becomes visible again at the write point after crossing the hidden arc, by the time any given patch of drum re-enters view on the right it has already been resmoked clean at the left, which is what lets the loop run forever with no hard reset. All colors come from `getComputedStyle(document.documentElement)` on `--background`, `--foreground`, `--ns-muted`, re-read on a `MutationObserver` watching `documentElement`'s class/data-theme attributes and a `prefers-color-scheme` `matchMedia` listener (already-painted soot keeps its color until the next repaint touches it, by design — no hard re-tint sweep). `--ns-accent` is never used anywhere; this is a fully ambient, non-interactive hero (`pointer-events-none` on the canvas, `aria-hidden`) with `autoplay: mode: none` because it animates entirely on its own internal clock and there is nothing for a synthetic pointer driver to do. REDUCED MOTION: instead of the rAF loop, a deterministic replay runs synchronously at mount — a seeded PRNG (`mulberry32`, fixed seed) drives the exact same `step()` function through fixed 1/60s ticks from t=0 to `STATIC_TIME_S = 9.8` (chosen because it lands mid-revolution with a substantial scratch trace and at least two completed twitch spikes already inked, not the empty t=0 frame), then a single `blit()` is called and the loop never starts — the canvas is genuinely byte-stable afterward, not just slowed down. The rAF loop pauses on `document.hidden` (`visibilitychange`) and via an `IntersectionObserver` (threshold default) while scrolled offscreen, both resuming through the same `startLoop()` path used by resize and theme/reduced-motion changes; `ResizeObserver` on the root re-derives `w/h/dpr`, rebuilds `buf` at the new circumference and restarts (a visual restart on resize is an accepted tradeoff, not a correctness bug). `children` render as an ordinary absolutely-positioned DOM overlay on top of the canvas (untouched by any of this — a headline/CTA can sit on it with a token scrim, same pattern as `flyback-tear`). Zero dependencies, DOM+Canvas 2D only, no WebGL.

Props

PropTypeDefaultDescription
children?React.ReactNodecontent rendered on top of the drum, e.g. a headline + CTA
className?string