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.

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.

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