Skip to main content

ns-ui / loud

Offset Fountain Split

A full-bleed ambient background modeling a web-offset press ink train: ink splits and smears across six rollers from fountain to form, driven by 24 independently drifting fountain-key zones so the field's ribbed banding never converges flat.

Usage

registry/loud/offset-fountain-split/demo.tsx
"use client";

import { OffsetFountainSplit } from "./component";

// Full-viewport background. Purely ambient — the ink-train field drifts on
// its own clock, never converging flat, with no synthetic input required to
// demonstrate it.
export default function OffsetFountainSplitDemo() {
  return (
    <div className="relative h-screen w-full overflow-hidden bg-background">
      <div data-offset-fountain-split-stage className="absolute inset-0">
        <OffsetFountainSplit />
      </div>
      <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-center">
        <div className="flex flex-col items-center gap-3 rounded-lg bg-background/40 px-8 py-6 backdrop-blur-md">
          <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
            ns-ui / offset-fountain-split
          </p>
          <h1 className="text-4xl font-semibold tracking-tight text-foreground">
            An ink train that never sits still
          </h1>
        </div>
      </div>
    </div>
  );
}

Component

registry/loud/offset-fountain-split/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// OffsetFountainSplit — a full-bleed ambient background modeling a web-offset
// press ink train: ink drawn from a fountain reservoir passes through a chain
// of 6 rollers (fountain -> ductor -> 4 oscillating distributors -> form),
// splitting into a thinner film at every nip (value(x,i) = 0.5*value(x,i-1)
// + 0.5*value(x,i-1) sampled at a laterally-oscillating offset) so each stage
// both halves-and-recombines AND smears sideways. The fountain roller itself
// never holds still: it tracks 24 discrete "fountain key" zones across the
// width, each drifting on its own slow sine (a real press operator's key
// settings are never perfectly stable), which is what keeps the whole train
// from ever converging to a flat field — the visible symptom real pressmen
// call "ribbing", fought with exactly this roller train.
//
// Only the FINAL (form-roller) field is painted, as vertical full-height
// ink-density strips: --foreground at an alpha proportional to that band's
// value, over the --background the wrapper already shows. No canvas fill of
// background — nothing paints before the token read below, and the wrapper's
// own bg-background class covers the frame regardless.
//
// Every roller's array persists across frames and relaxes toward its target
// with a 180ms time constant, so a fountain-key shift visibly ripples down
// the train stage by stage instead of snapping instantly.
// ---------------------------------------------------------------------------

const ROLLER_COUNT = 6; // 0 fountain, 1-4 distributors (oscillate), 5 form
const OSC_START = 1;
const OSC_END = 4; // inclusive — bands 0 and 5 never oscillate
const ZONE_COUNT = 24;
const ZONE_BASE = 0.5;
const ZONE_DRIFT_PERIOD = 40; // s
const ZONE_DRIFT_AMPLITUDE = 0.18;
const RELAX_TAU = 0.18; // s, per-band relaxation constant
const OSC_PERIOD = 2.6; // s, distributor oscillation
const OSC_AMPLITUDE_PX = 14;
const NUDGE_BOOST = 0.1;
const NUDGE_DECAY = 1.2; // s, linear decay back to 0
const FREEZE_T = 8.4; // s — chosen phase for the reduced-motion frame
const COL_PITCH_MIN = 3;
const COL_PITCH_MAX = 10; // px — derived from container's smaller dimension
const N_MIN = 256;
const N_MAX = 720; // sample count clamp
const MAX_ALPHA_DARK = 0.85;
const MAX_ALPHA_LIGHT = 0.75;
const DPR_CAP = 1.5;
const RESIZE_DEBOUNCE_MS = 150;

function clamp(x: number, lo: number, hi: number) {
  return x < lo ? lo : x > hi ? hi : x;
}
function lerp(a: number, b: number, t: number) {
  return a + (b - a) * t;
}

// relative luminance of a `--token` value read as a hex string; returns null
// if the token isn't a plain hex color (e.g. inherits `currentColor`).
function hexLuminance(raw: string): number | null {
  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());
  if (!m) return null;
  let h = m[1];
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  const r = ((n >> 16) & 255) / 255;
  const g = ((n >> 8) & 255) / 255;
  const b = (n & 255) / 255;
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

export interface OffsetFountainSplitProps {
  className?: string;
  style?: CSSProperties;
}

export function OffsetFountainSplit({
  className = "",
  style,
}: OffsetFountainSplitProps) {
  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;

    let sized = false;
    let visible = false;

    let fg = "currentColor";
    let maxAlpha = MAX_ALPHA_DARK;

    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      fg = cs.getPropertyValue("--foreground").trim() || fg;
      const bgRaw = cs.getPropertyValue("--background").trim();
      const fl = hexLuminance(fg);
      const bl = hexLuminance(bgRaw);
      // background lighter than foreground => light theme, cap alpha lower
      // (dark ink on a light ground reads heavier at equal alpha).
      maxAlpha =
        fl != null && bl != null && bl > fl ? MAX_ALPHA_LIGHT : MAX_ALPHA_DARK;
    };

    let width = 0;
    let height = 0;
    let colPitch = COL_PITCH_MIN;
    let n = N_MIN;

    let bands: Float32Array[] = [];
    const zonePhase = new Float32Array(ZONE_COUNT);
    const zoneBoost = new Float32Array(ZONE_COUNT);
    for (let z = 0; z < ZONE_COUNT; z++) zonePhase[z] = z * 0.9137;

    const initBands = () => {
      bands = [];
      for (let i = 0; i < ROLLER_COUNT; i++) bands.push(new Float32Array(n));
      zoneBoost.fill(0);
    };

    const resize = () => {
      const rect = root.getBoundingClientRect();
      const w = Math.round(rect.width);
      const h = Math.round(rect.height);
      if (w < 2 || h < 2) {
        sized = false;
        return;
      }
      width = w;
      height = h;
      colPitch = clamp(Math.min(w, h) / 90, COL_PITCH_MIN, COL_PITCH_MAX);
      n = clamp(Math.ceil(w / colPitch), N_MIN, N_MAX);
      colPitch = w / n; // exact pitch so columns tile the width with no gap
      const dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      initBands();
      sized = true;
    };

    // stepped fountain-key field: 24 discrete zones, each drifting on its
    // own slow sine plus a decaying pointer "key tweak" boost.
    const zoneTargetAt = (xSample: number, t: number) => {
      const zoneW = n / ZONE_COUNT;
      const z = clamp(Math.floor(xSample / zoneW), 0, ZONE_COUNT - 1);
      const drift =
        ZONE_DRIFT_AMPLITUDE *
        Math.sin((2 * Math.PI * t) / ZONE_DRIFT_PERIOD + zonePhase[z]);
      return clamp(ZONE_BASE + drift + zoneBoost[z], 0, 1);
    };

    const cascade = (t: number, relax: number, write: Float32Array[]) => {
      const b0 = write[0];
      for (let x = 0; x < n; x++) {
        const target = zoneTargetAt(x, t);
        b0[x] += (target - b0[x]) * relax;
      }
      for (let i = 1; i < ROLLER_COUNT; i++) {
        const prev = write[i - 1];
        const cur = write[i];
        const oscillating = i >= OSC_START && i <= OSC_END;
        const offsetPx = oscillating
          ? OSC_AMPLITUDE_PX * Math.sin((2 * Math.PI * t) / OSC_PERIOD + i * 0.7)
          : 0;
        const offsetSamples = offsetPx / colPitch;
        for (let x = 0; x < n; x++) {
          let xs = x - offsetSamples;
          if (xs < 0) xs = 0;
          else if (xs > n - 1) xs = n - 1;
          const x0 = Math.floor(xs);
          const x1 = Math.min(n - 1, x0 + 1);
          const shifted = lerp(prev[x0], prev[x1], xs - x0);
          const target = 0.5 * prev[x] + 0.5 * shifted;
          cur[x] += (target - cur[x]) * relax;
        }
      }
    };

    const paintField = (field: Float32Array) => {
      ctx.clearRect(0, 0, width, height);
      ctx.fillStyle = fg;
      for (let x = 0; x < n; x++) {
        const alpha = clamp(field[x], 0, 1) * maxAlpha;
        if (alpha <= 0.002) continue;
        ctx.globalAlpha = alpha;
        ctx.fillRect(x * colPitch, 0, colPitch + 1, height);
      }
      ctx.globalAlpha = 1;
    };

    const drawFrame = () => {
      if (!sized) return;
      paintField(bands[ROLLER_COUNT - 1]);
    };

    // reduced-motion: one static frame at a fixed sim-time, computed as if
    // every band had already fully relaxed to its instantaneous target
    // (relax = 1) rather than replaying the transient history — a legible
    // "process caught mid-run" frame with no rAF loop and no listeners.
    const drawStaticFreeze = () => {
      if (!sized) return;
      const tmp: Float32Array[] = [];
      for (let i = 0; i < ROLLER_COUNT; i++) tmp.push(new Float32Array(n));
      cascade(FREEZE_T, 1, tmp);
      paintField(tmp[ROLLER_COUNT - 1]);
    };

    let raf = 0;
    let last = 0;
    let simT = 0;

    const loop = (now: number) => {
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
      last = now;
      simT += dt;
      cascade(simT, 1 - Math.exp(-dt / RELAX_TAU), bands);
      for (let z = 0; z < ZONE_COUNT; z++) {
        if (zoneBoost[z] > 0) {
          zoneBoost[z] = Math.max(
            0,
            zoneBoost[z] - (NUDGE_BOOST / NUDGE_DECAY) * dt
          );
        }
      }
      drawFrame();
      raf = !document.hidden && visible ? requestAnimationFrame(loop) : 0;
    };

    const startLoop = () => {
      if (raf || reduced || document.hidden || !visible || !sized) return;
      last = 0;
      raf = requestAnimationFrame(loop);
    };
    const stopLoop = () => {
      if (raf) {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    };

    const onPointerMove = (e: PointerEvent) => {
      if (reduced || !sized) return;
      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      if (x < 0 || x > rect.width || y < 0 || y > rect.height) return;
      const xs = x / colPitch;
      const zoneW = n / ZONE_COUNT;
      const z = clamp(Math.floor(xs / zoneW), 0, ZONE_COUNT - 1);
      zoneBoost[z] = NUDGE_BOOST;
    };

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const onResize = () => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        resize();
        if (reduced) drawStaticFreeze();
      }, RESIZE_DEBOUNCE_MS);
    };

    readTokens();
    resize();
    if (reduced) {
      drawStaticFreeze();
    }

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced) drawStaticFreeze();
      else if (sized && !raf) drawFrame();
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

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

    let io: IntersectionObserver | undefined;
    const onVis = () => {
      if (document.hidden) stopLoop();
      else startLoop();
    };

    if (!reduced) {
      io = new IntersectionObserver(
        (entries) => {
          for (const entry of entries) {
            visible = entry.isIntersecting;
            if (visible) startLoop();
            else stopLoop();
          }
        },
        { threshold: 0 }
      );
      io.observe(root);
      document.addEventListener("visibilitychange", onVis);
      window.addEventListener("pointermove", onPointerMove);
    }

    return () => {
      stopLoop();
      if (resizeTimer) clearTimeout(resizeTimer);
      mo.disconnect();
      ro.disconnect();
      io?.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      window.removeEventListener("pointermove", onPointerMove);
    };
  }, []);

  return (
    <div
      ref={rootRef}
      className={`relative h-full w-full overflow-hidden bg-background ${
        /\bmin-h-/.test(className) ? "" : "min-h-screen"
      } ${className}`}
      style={style}
    >
      <canvas
        ref={canvasRef}
        aria-hidden
        className="pointer-events-none absolute inset-0 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/offset-fountain-split.tsx, so point the import there.

Use when a full-bleed backdrop where the texture itself is a real print-process story — cascading ink-train splitting driven by a never-settling fountain-key drift, rendered as vertical density strips. Pick background-gradient-shader instead for a calm moving current of color with no print-process narrative; pick background-halftone-rosette instead when the read you want is same-ink dot-screen moiré rather than roller-splitting ribbing.

Install

npx shadcn add https://design.helpmarq.com/r/offset-fountain-split.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 <OffsetFountainSplit className? style?> as a full-bleed, aria-hidden, pointer-events-transparent 2D canvas background (no children wrapping — callers overlay their own content in a sibling element, matching the background-gradient-shader pattern). Model a web-offset ink train as 6 roller stages (index 0 fountain, 1-4 oscillating distributors, 5 form), each stage a Float32Array of N samples (N = clamp(ceil(width / colPitch), 256, 720); colPitch = clamp(min(width,height)/90, 3, 10)px, derived from the container's smaller dimension). Stage 0's per-sample TARGET comes from 24 discrete fountain-key zones spanning the width (zoneWidth = N/24, one stepped target per zone, not interpolated — a real fountain key is discrete), each zone drifting on its own sine: 0.5 + 0.18*sin(2*PI*t/40 + zonePhase[z]), zonePhase[z] = z*0.9137 for a non-repeating spread across zones. This 40s drift is what keeps the whole train from ever converging flat. For stages 1-5, each sample's TARGET is 0.5*prev[x] + 0.5*prev[x sampled at a laterally shifted, linearly-interpolated position] — an ink-splitting halve-and-recombine at every nip, with the shift only applied on stages 1-4 (distributors): offsetPx = 14*sin(2*PI*t/2.6 + stageIndex*0.7), converted to samples via /colPitch; stages 0 and 5 (fountain, form) never oscillate, offset = 0. Every stage's array RELAXES toward its target rather than snapping: value += (target - value) * (1 - exp(-dt/0.18)), a 180ms per-stage time constant, so a fountain-key shift visibly ripples down the train stage by stage over multiple frames rather than teleporting. Only the final (form-roller, index 5) array is painted, as full-height vertical strips: ctx.fillStyle = the --foreground token (read raw as a CSS color string via getComputedStyle(document.documentElement).getPropertyValue('--foreground'), re-read on a MutationObserver watching documentElement's class), globalAlpha = clamp(value,0,1) * maxAlpha per strip, strip width = exact colPitch (recomputed as width/N so columns tile with no gap), no background fill — the wrapper's own bg-background class supplies the ground, so nothing paints before the token read. maxAlpha is chosen by comparing the relative luminance (0.2126r+0.7152g+0.0722b from a parsed #hex) of --foreground against --background: 0.75 when background is the lighter token (light theme, where equal alpha reads heavier), 0.85 otherwise (dark theme) — this comparison, not a hardcoded theme guess, is what the token rule requires. Pointer interaction: a window-level pointermove listener (filtered against the canvas's own getBoundingClientRect so it only reacts inside the frame) finds the nearest fountain-key zone under the cursor's x and sets that zone's zoneBoost to 0.1, added directly into the zone's target for one relaxation pass; zoneBoost then linearly decays back to 0 over 1.2s ((0.1/1.2)*dt per frame) whether or not the pointer is still present. This is a luminance-only 'key tweak' nudge — never mix --ns-accent into it, and it must leave no permanent trace once decayed. Canvas backing store is DPR-capped at 1.5, resized via a 150ms-debounced ResizeObserver on the root (each resize reallocates and zeroes all 6 stage arrays — state loss on resize is acceptable). The rAF loop only runs while an IntersectionObserver (threshold 0) on the root reports intersecting AND document.visibilityState is visible; it is started/stopped from both the IntersectionObserver callback and a visibilitychange listener, and is never started at all under prefers-reduced-motion. prefers-reduced-motion instead renders exactly one static frame: the same 6-stage cascade computed once at a fixed sim-time of 8.4s with an idealized relax factor of 1 (each stage snaps straight to its instantaneous target rather than replaying the transient history) — no rAF loop, no pointer listener, no IntersectionObserver, just a direct recomputation on token-change too (the MutationObserver callback re-invokes this same static draw when reduced-motion is active). Clean up on unmount: cancel any pending rAF, clear the resize debounce timer, and disconnect the MutationObserver, ResizeObserver, and IntersectionObserver (the last only exists when motion is not reduced). Props: className, style — both merged onto the root; a `min-h-*` class in className suppresses the default `min-h-screen` the same way the sibling backgrounds do.

Props

PropTypeDefaultDescription
className?string——
style?CSSProperties——