ns-ui / loud
Joint Iron
A footer whose entire surface is a bookbinding building-in machine: three stations set a French groove under a heated brass iron, hold it through a real dwell, and release it with a measured 14% spring-back — the sitemap and legal line sit on the same case tone the groove is cut into.
Use when Pick joint-iron for the closing footer of a landing page whose whole surface should read as one worked material — the groove is the footer's own edge being formed, not an ornament laid over ordinary footer DOM. Pick footer-ascii-rule instead when the footer's mechanic should be about scroll position (its aria-hidden rail reads real scrollY and its back-to-top button springs the page) rather than a resting material process — joint-iron deliberately never reads scroll, to keep the two distinct. Pick a plain unstyled footer when the page already carries a loud full-bleed surface elsewhere and a second worked-material moment at the close would compete with it.
Install
npx shadcn add https://design.helpmarq.com /r/joint-iron.jsonSource
registry/loud/joint-iron/component.tsx"use client";
import { useEffect, useId, useRef } from "react";
// ---------------------------------------------------------------------------
// JointIron — a footer whose top edge IS a book joint being formed,
// not a footer with an animation bolted onto it.
//
// Real mechanic: the building-in machine in edition binding. After a book is
// cased-in, it is clamped in a machine that presses heated brass joint irons
// into the hinge to set the French groove — the channel between the spine and
// the boards — under real dwell time, then releases with a measured 14%
// spring-back as the boards' elastic set partially relaxes. Three stations
// along the band run this five-phase cycle (index / iron descent / dwell /
// release / lift) independently, offset in phase, forever.
//
// The canvas is the full footer surface, not a strip above it — the DOM
// content (wordmark, sitemap, legal line) sits on the SAME "case" tone the
// groove is cut into, in a scrim panel below the forming zone. Delete the
// loop and the footer loses its material, not a decoration: this is the
// thing that keeps it from being footing-course.
//
// One height field per station, g(x) — a cosine bump centred on the station,
// with half-width hw = 1.44 * depth on each side (so the groove's WIDTH
// shrinks with depth, not just its floor). The two facet walls are lit with
// a single-lamp Lambert-style term derived from the bump's own slope
// (-sin(pi*d/hw)), so depth is legible three ways at once — floor depth,
// facet width, facet contrast — which is what makes the 14% spring-back
// (2.6px of floor movement at card scale) visible at all: on its own it is
// below the perceptual floor, but the paired 7.5px facet-width narrowing and
// the ~0.035L contrast step read as a real event over the 170ms it happens
// in.
//
// Tokens only: --background, --foreground, --ns-muted, --border, --ns-accent
// read via getComputedStyle + a MutationObserver on documentElement's class.
// --ns-accent never touches the joint (only the newsletter submit and focus
// rings). The heated iron — this component's one climactic moment — is
// solved by value alone: a solid --foreground silhouette whose underside
// gets a shadowBlur glow (still just the --foreground token, no hue) for its
// 700ms post-lift shimmer, which is the theme-agnostic way to "brighten" a
// silhouette that is already at the bright extreme of its palette in dark
// theme and the dark extreme in light theme.
// ---------------------------------------------------------------------------
const CYCLE = 7.4; // one station's full sequence, seconds
const P_INDEX = 0.85;
const P_DESCENT = 0.62;
const P_DWELL = 3.6;
const P_RELEASE = 0.45;
// P_LIFT = CYCLE - (P_INDEX + P_DESCENT + P_DWELL + P_RELEASE) = 1.88
const T1 = P_INDEX;
const T2 = T1 + P_DESCENT;
const T3 = T2 + P_DWELL;
const T4 = T3 + P_RELEASE;
const DESCENT_TC = 0.28; // first-order approach time constant, descent
const DWELL_TC = 1.4; // first-order approach time constant, dwell creep
const RELEASE_SPRING_T = 0.17; // spring-back happens in the first 170ms of release
const RELEASE_DROP = 0.14; // 14% of set depth recovered on release
const LIFT_DUR = 0.3; // iron travel time within the 1.88s lift+dwell-out phase
const SHIMMER_S = 0.7; // heat-shimmer duration after lift begins
// STATION_OFFSET is a deliberate 2.6s (not an exact CYCLE/3 = 2.467) — tuned
// so the reduced-motion STATIC_TIME below lands one station mid-release, one
// mid-dwell and one mid-index simultaneously, matching the spec's
// non-t0 freeze-frame requirement without relying on luck.
const STATION_OFFSET = 2.6;
const STATIC_TIME = 2.62;
const STATION_COUNT_FULL = 3;
const STATION_COUNT_SHORT = 2;
const MIN_HB_FOR_GRAIN_AND_3RD = 88; // px; below this, drop to 2 stations, drop grain
const GROOVE_CENTER_FRAC = 0.3; // groove's resting line, fraction of Hb below top edge
const GROOVE_CENTER_MAX_PX = 132; // clamp so the DOM sitemap (below it) can never bury the groove
const G_MAX_FRAC = 0.075; // g_max as a fraction of M = min(bandW, bandH)
// The groove is ONE continuous channel across the band, not three isolated
// dimples: each station owns a raised-cosine window whose depth is its own
// phase, and the channel takes the deepest contribution at every x. Adjacent
// windows overlap (HW > slotW/2), so the depth profile is continuous and the
// three phases read as one hinge being worked along its length.
const STATION_WINDOW_MULT = 0.62; // window half-width, fraction of a slot
const GROOVE_SAMPLES = 132; // polyline samples across the band
const FACET_WIDTH_MULT = 1.44; // facet half-width = 1.44 * current depth
const FACET_BRIGHT_L = 0.15; // spine-side facet peak contrast, spec's real number
const FACET_DARK_L = 0.17; // board-side facet peak contrast, spec's real number
const FACET_BRIGHT_ALPHA = 0.72; // lit far wall — the groove's primary value carrier
// dark alpha derived from the bright budget scaled by the spec's own L ratio,
// so the 0.15 / 0.17 asymmetry (and the 14%-release drop of both) survives
// the translation into canvas alpha rather than being re-guessed.
const FACET_DARK_ALPHA = FACET_BRIGHT_ALPHA * (FACET_DARK_L / FACET_BRIGHT_L);
const THERMOSTAT_CYCLE = 2.1;
const THERMOSTAT_AMP_ALPHA = 0.05; // iron fill-alpha ripple standing in for +/-0.04L hunting
const CASE_ALPHA_LIGHT = 0.1;
const CASE_ALPHA_DARK = 0.16;
const IRON_BASE_ALPHA = 0.86; // headroom below solid fg so the shimmer has somewhere to go
const GRAIN_AMP_ALPHA = 0.05; // canvas overlay alpha standing in for +/-0.02L board grain
const GRAIN_FEATURE_FRAC = 0.006; // grain feature size, fraction of M
const DPR_CAP = 2;
const SLOW_FRAME_MS = 33; // ~30fps budget
const SLOW_SUSTAIN_MS = 1500; // sustained slow window before quality drops
function mod(a: number, n: number) {
return ((a % n) + n) % n;
}
function clamp01(v: number) {
return Math.max(0, Math.min(1, v));
}
function easeOutCubic(t: number) {
const u = 1 - clamp01(t);
return 1 - u * u * u;
}
// deterministic, seeded — no Math.random anywhere in the render path, so the
// reduced-motion frame (and every ordinary frame, given the same t) is
// byte-stable.
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
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;
};
}
interface Tokens {
fg: string;
bg: string;
muted: string;
border: string;
}
function readTokens(): Tokens | null {
if (typeof document === "undefined") return null;
const cs = getComputedStyle(document.documentElement);
const fg = cs.getPropertyValue("--foreground").trim();
const bg = cs.getPropertyValue("--background").trim();
const muted = cs.getPropertyValue("--ns-muted").trim();
const border = cs.getPropertyValue("--border").trim();
if (!fg || !bg) return null;
return { fg, bg, muted, border };
}
// -- the joint's phase machine — a pure function of local (station) time ---
type Phase = "index" | "descent" | "dwell" | "release" | "lift";
interface StationFrame {
phase: Phase;
depthFrac: number; // 0..1 of gMax, the groove floor's current depth
contrastMult: number; // multiplies FACET_*_L, folds in shoulder-rounding on release
ironFrac: number; // 0 (up) .. 1 (down)
indexProgress: number; // 0..1 while phase === "index", else 0
shimmerT: number | null; // seconds into the post-lift shimmer, else null
}
// depth fraction reached at the end of descent / dwell, and the held depth
// after release — computed once, reused every cycle (the process is
// periodic: every book gets the same result).
const DESCENT_END_FRAC = 1 - Math.exp(-P_DESCENT / DESCENT_TC);
const DWELL_END_FRAC = 1 - 0.11 * Math.exp(-P_DWELL / DWELL_TC);
const HOLD_FRAC = DWELL_END_FRAC * (1 - RELEASE_DROP);
function indexEase(p: number) {
// easeOutCubic with a small, quickly-damped overshoot standing in for the
// spec's "3% overshoot damped in 140ms" spring settle.
const base = easeOutCubic(p);
const overshoot = 0.03 * Math.exp(-p * 9) * Math.sin(p * Math.PI * 2.2);
return base + overshoot;
}
function stationFrame(localT: number): StationFrame {
if (localT < T1) {
const p = localT / P_INDEX;
return { phase: "index", depthFrac: 0, contrastMult: 0, ironFrac: 0, indexProgress: indexEase(p), shimmerT: null };
}
if (localT < T2) {
const t = localT - T1;
const depthFrac = 1 - Math.exp(-t / DESCENT_TC);
return { phase: "descent", depthFrac, contrastMult: depthFrac, ironFrac: clamp01(depthFrac / DESCENT_END_FRAC), indexProgress: 0, shimmerT: null };
}
if (localT < T3) {
const t = localT - T2;
const depthFrac = 1 - 0.11 * Math.exp(-t / DWELL_TC);
return { phase: "dwell", depthFrac, contrastMult: depthFrac, ironFrac: 1, indexProgress: 0, shimmerT: null };
}
if (localT < T4) {
const t = localT - T3;
const springP = clamp01(t / RELEASE_SPRING_T);
const springEase = easeOutCubic(springP);
const depthFrac = DWELL_END_FRAC * (1 - RELEASE_DROP * springEase);
const shoulderRound = 1 - 0.09 * springEase; // extra rounding beyond pure depth ratio
return { phase: "release", depthFrac, contrastMult: depthFrac * shoulderRound, ironFrac: 1, indexProgress: 0, shimmerT: null };
}
const t = localT - T4;
const ironFrac = Math.max(0, 1 - t / LIFT_DUR);
return {
phase: "lift",
depthFrac: HOLD_FRAC,
contrastMult: HOLD_FRAC,
ironFrac,
indexProgress: 0,
shimmerT: t < SHIMMER_S ? t : null,
};
}
export interface JointIronLink {
label: string;
href: string;
}
export interface JointIronColumn {
heading: string;
links: JointIronLink[];
}
export interface JointIronProps {
/** wordmark on the case */
brand?: string;
/** short line under the wordmark */
tagline?: string;
/** sitemap columns */
columns?: JointIronColumn[];
/** legal / copyright line; year is appended automatically */
legal?: string;
/** newsletter field placeholder */
newsletterPlaceholder?: string;
/** called with the entered email on submit; default is a no-op (demo-safe) */
onSubscribe?: (email: string) => void;
/** extra classes merged onto the rendered root element */
className?: string;
}
const DEFAULT_COLUMNS: JointIronColumn[] = [
{
heading: "Product",
links: [
{ label: "Overview", href: "#overview" },
{ label: "Pricing", href: "#pricing" },
{ label: "Changelog", href: "#changelog" },
],
},
{
heading: "Resources",
links: [
{ label: "Docs", href: "#docs" },
{ label: "Guides", href: "#guides" },
{ label: "API", href: "#api" },
],
},
{
heading: "Company",
links: [
{ label: "About", href: "#about" },
{ label: "Blog", href: "#blog" },
{ label: "Careers", href: "#careers" },
],
},
{
heading: "Legal",
links: [
{ label: "Privacy", href: "#privacy" },
{ label: "Terms", href: "#terms" },
],
},
];
export function JointIron({
brand = "ns-ui",
tagline = "Bound the way it's built.",
columns = DEFAULT_COLUMNS,
legal = "All rights reserved.",
newsletterPlaceholder = "you@company.com",
onSubscribe,
className = "",
}: JointIronProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const emailId = useId();
useEffect(() => {
const wrap = wrapRef.current;
const canvas = canvasRef.current;
if (!wrap || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let disposed = false;
let tokens: Tokens | null = null;
let isDark = false;
let dpr = 1;
let w = 0;
let h = 0;
let sized = false;
// the loop only runs when BOTH are true — an intersecting-but-hidden-tab
// footer (or a visible-tab-but-scrolled-off footer) must stay paused
let inView = false;
let pageVisible = document.visibilityState !== "hidden";
let columnStep = 1; // widened under sustained slowness
let raf = 0;
let tokenWaitRaf = 0;
let slowSince: number | null = null;
// -- static seeded board-grain, baked to an offscreen tile at resize ---
let grain: HTMLCanvasElement | null = null;
const bakeGrain = (m: number) => {
const feature = Math.max(1, m * GRAIN_FEATURE_FRAC);
const tile = document.createElement("canvas");
tile.width = Math.max(1, Math.round(w));
tile.height = Math.max(1, Math.round(h));
const tctx = tile.getContext("2d");
if (!tctx || !tokens) {
grain = tile;
return;
}
const rand = mulberry32(20260901);
const cols = Math.max(1, Math.ceil(tile.width / feature));
const rows = Math.max(1, Math.ceil(tile.height / feature));
tctx.fillStyle = tokens.fg;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const v = rand();
if (v < 0.5) continue; // sparse — grain is a texture, not a wash
tctx.globalAlpha = (v - 0.5) * 2 * GRAIN_AMP_ALPHA;
tctx.fillRect(c * feature, r * feature, feature, feature);
}
}
tctx.globalAlpha = 1;
grain = tile;
};
// -- layout ---------------------------------------------------------
const layout = () => {
const m = Math.min(w, h);
const stationCount = h >= MIN_HB_FOR_GRAIN_AND_3RD ? STATION_COUNT_FULL : STATION_COUNT_SHORT;
// clamped so the groove zone always clears the DOM scrim's top offset
// (pt-20/pt-24 in the markup below) even on a very tall footer
const grooveCenterY = Math.min(GROOVE_CENTER_FRAC * h, GROOVE_CENTER_MAX_PX);
const gMax = G_MAX_FRAC * m;
const slotW = w / stationCount;
const stations = Array.from({ length: stationCount }, (_, i) => ({
cx: slotW * i + slotW / 2,
slotX0: slotW * i,
slotX1: slotW * (i + 1),
}));
return { m, stationCount, grooveCenterY, gMax, slotW, stations, showGrain: h >= MIN_HB_FOR_GRAIN_AND_3RD };
};
const fitCanvas = () => {
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);
};
const resize = () => {
const rect = wrap.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) {
sized = false;
return;
}
dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP);
w = rect.width;
h = rect.height;
fitCanvas();
columnStep = 1;
slowSince = null;
const { showGrain } = layout();
if (showGrain) bakeGrain(Math.min(w, h));
else grain = null;
sized = true;
};
// -- drawing ----------------------------------------------------------
// Depth of the channel at x: the deepest station window covering it.
// Each station's window is a raised cosine centred on its (possibly
// index-shifted) centre, so a station mid-dwell cuts a deep bowl while
// its neighbour mid-descent is still a shallow scoop, and the two meet
// in a continuous wall rather than a seam.
const depthAt = (
x: number,
stations: Array<{ cx: number; slotX0: number; slotX1: number }>,
frames: StationFrame[],
gMax: number,
slotW: number,
) => {
const hw = slotW * STATION_WINDOW_MULT;
let d = 0;
let c = 0;
for (let i = 0; i < stations.length; i++) {
const f = frames[i]!;
const depth = (f.phase === "index" ? HOLD_FRAC : f.depthFrac) * gMax;
if (depth < 0.2) continue;
// during index the previous station's finished groove travels out of
// the slot, so its window centre travels with it
const cx = stations[i]!.cx - (f.phase === "index" ? slotW * f.indexProgress : 0);
const dx = Math.abs(x - cx);
if (dx >= hw) continue;
// flattened raised cosine: a pressed groove has a floor, not a
// vertex, so the window plateaus in the middle and steepens at the
// walls instead of reading as a shallow V
const win = Math.pow(0.5 * (1 + Math.cos((Math.PI * dx) / hw)), 0.42);
const contribution = depth * win;
if (contribution > d) {
d = contribution;
c = (f.phase === "index" ? HOLD_FRAC : f.contrastMult) * win;
}
}
return { d, c };
};
const drawGroove = (
cy: number,
gMax: number,
stations: Array<{ cx: number; slotX0: number; slotX1: number }>,
frames: StationFrame[],
slotW: number,
brightToken: string,
darkToken: string,
) => {
const n = GROOVE_SAMPLES;
const xs = new Float64Array(n + 1);
const top = new Float64Array(n + 1);
const bot = new Float64Array(n + 1);
const con = new Float64Array(n + 1);
for (let i = 0; i <= n; i++) {
const x = (w * i) / n;
const { d, c } = depthAt(x, stations, frames, gMax, slotW);
xs[i] = x;
// the channel's own height is the first of the three depth cues: it
// opens downward from a nearly fixed lip, so the lip stays a
// straight hinge line and the floor is what moves
top[i] = cy - d * 0.06;
bot[i] = cy + d * 0.9;
con[i] = c;
}
// 1 — the recess itself: one filled band, darkest token, so the channel
// reads as a shadowed cut through the case at a glance
ctx.save();
ctx.beginPath();
ctx.moveTo(xs[0]!, top[0]!);
for (let i = 1; i <= n; i++) ctx.lineTo(xs[i]!, top[i]!);
for (let i = n; i >= 0; i--) ctx.lineTo(xs[i]!, bot[i]!);
ctx.closePath();
ctx.globalAlpha = isDark ? 0.5 : 0.36;
ctx.fillStyle = darkToken;
ctx.fill();
ctx.restore();
// 1b — the near wall directly under the lip takes the least light, so
// a second, shallower pass over the top 45% of the cut gives the
// channel a curved wall instead of a flat ribbon of tone
ctx.save();
ctx.beginPath();
ctx.moveTo(xs[0]!, top[0]!);
for (let i = 1; i <= n; i++) ctx.lineTo(xs[i]!, top[i]!);
for (let i = n; i >= 0; i--) ctx.lineTo(xs[i]!, top[i]! + (bot[i]! - top[i]!) * 0.45);
ctx.closePath();
ctx.globalAlpha = isDark ? 0.45 : 0.3;
ctx.fillStyle = darkToken;
ctx.fill();
ctx.restore();
// 2 — the lit far wall along the bottom of the cut: the single
// brightest mark in the footer, and the one that collapses visibly on
// the 14% spring-back (it rises AND dims together)
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (let i = 0; i < n; i++) {
const cAvg = (con[i]! + con[i + 1]!) / 2;
if (cAvg <= 0.02) continue;
ctx.globalAlpha = Math.min(1, FACET_BRIGHT_ALPHA * cAvg + 0.06);
ctx.strokeStyle = brightToken;
ctx.lineWidth = 1.2 + 2.2 * cAvg;
ctx.beginPath();
ctx.moveTo(xs[i]!, bot[i]!);
ctx.lineTo(xs[i + 1]!, bot[i + 1]!);
ctx.stroke();
}
ctx.restore();
// 3 — the hinge lip: a continuous hairline the whole width, so the
// joint exists as a line even where depth is zero (station indexing)
ctx.save();
ctx.globalAlpha = isDark ? 0.5 : 0.34;
ctx.strokeStyle = brightToken;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xs[0]!, top[0]! - 0.5);
for (let i = 1; i <= n; i++) ctx.lineTo(xs[i]!, top[i]! - 0.5);
ctx.stroke();
ctx.restore();
ctx.globalAlpha = 1;
};
// The iron: a brass block on a ram that never leaves the band. It is a
// solid --foreground silhouette (value only, no hue), with a hot
// underside line that shimmers for 700ms after it lifts.
const drawIron = (
station: { cx: number; slotX0: number; slotX1: number },
frame: StationFrame,
cy: number,
gMax: number,
slotW: number,
t: number,
) => {
const ironW = Math.min(slotW * 0.22, gMax * 2.1);
const ironH = Math.max(8, gMax * 0.62);
const noseH = Math.max(4, gMax * 0.34);
const ironUpY = Math.max(4, cy - gMax * 3.2);
// full stroke seats the nose in the floor of its own groove, never
// below it — the iron forms the channel, it does not punch through
const ironDownY = cy - ironH - noseH * 0.15;
const y = ironUpY + (ironDownY - ironUpY) * frame.ironFrac;
const x0 = station.cx - ironW / 2;
const thermostat = 1 + THERMOSTAT_AMP_ALPHA * Math.sin((t / THERMOSTAT_CYCLE) * Math.PI * 2);
ctx.save();
// ram: the block is machine-driven, so it stays attached to the head
// rail at the top of the band instead of floating
ctx.globalAlpha = clamp01(0.5 * thermostat);
ctx.fillStyle = tokens!.fg;
ctx.fillRect(station.cx - Math.max(1.5, ironW * 0.07), 0, Math.max(3, ironW * 0.14), y + 2);
ctx.globalAlpha = clamp01(IRON_BASE_ALPHA * thermostat);
ctx.beginPath();
ctx.moveTo(x0, y);
ctx.lineTo(x0 + ironW, y);
ctx.lineTo(x0 + ironW, y + ironH);
// the working face is a wedge — it is the shape that cuts the groove,
// so its profile has to match the channel it leaves behind
ctx.lineTo(x0 + ironW * 0.72, y + ironH + noseH);
ctx.lineTo(x0 + ironW * 0.28, y + ironH + noseH);
ctx.lineTo(x0, y + ironH);
ctx.closePath();
ctx.fill();
if (frame.shimmerT != null) {
const shimmerEase = 1 - frame.shimmerT / SHIMMER_S;
ctx.shadowColor = tokens!.fg;
ctx.shadowBlur = shimmerEase * gMax * 2.2;
ctx.globalAlpha = 0.95 * shimmerEase;
ctx.fillRect(x0 + ironW * 0.28, y + ironH + noseH - 2, ironW * 0.44, 2);
}
ctx.restore();
ctx.globalAlpha = 1;
};
const draw = (t: number) => {
if (!tokens || !sized) return;
const { grooveCenterY, gMax, stations, slotW, showGrain } = layout();
const caseAlpha = isDark ? CASE_ALPHA_DARK : CASE_ALPHA_LIGHT;
const brightToken = isDark ? tokens.fg : tokens.bg;
const darkToken = isDark ? tokens.bg : tokens.fg;
// the case is the WHOLE footer surface, edge to edge — the band and
// the sitemap are the same material, which is the point
ctx.fillStyle = tokens.bg;
ctx.fillRect(0, 0, w, h);
ctx.globalAlpha = caseAlpha;
ctx.fillStyle = tokens.fg;
ctx.fillRect(0, 0, w, h);
ctx.globalAlpha = 1;
if (showGrain && grain) {
ctx.drawImage(grain, 0, 0);
}
// hairline between the footer and the page above
ctx.globalAlpha = 0.9;
ctx.strokeStyle = tokens.border;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, 0.5);
ctx.lineTo(w, 0.5);
ctx.stroke();
ctx.globalAlpha = 1;
const frames = stations.map((_, i) => stationFrame(mod(t + i * STATION_OFFSET, CYCLE)));
drawGroove(grooveCenterY, gMax, stations, frames, slotW, brightToken, darkToken);
for (let i = 0; i < stations.length; i++) {
drawIron(stations[i]!, frames[i]!, grooveCenterY, gMax, slotW, t);
}
};
const loop = (nowMs: number) => {
if (disposed) return;
if (!inView || !pageVisible) {
raf = 0; // re-armed by whichever of IO / visibilitychange flips back first
return;
}
raf = requestAnimationFrame(loop);
if (!sized || !tokens) return;
const frameStart = performance.now();
draw(nowMs / 1000);
const frameMs = performance.now() - frameStart;
if (frameMs > SLOW_FRAME_MS) {
if (slowSince == null) slowSince = nowMs;
else if (nowMs - slowSince > SLOW_SUSTAIN_MS) columnStep = 2;
} else {
slowSince = null;
}
};
const drawStatic = () => {
draw(STATIC_TIME);
};
const rebakeGrainIfNeeded = () => {
const { showGrain, m } = layout();
if (showGrain) bakeGrain(m);
else grain = null;
};
const resumeLoop = () => {
if (!reduced && tokens && sized && inView && pageVisible && !raf) {
raf = requestAnimationFrame(loop);
}
};
let started = false;
const kick = () => {
if (started || disposed || !tokens || !sized) return;
started = true;
if (reduced) {
drawStatic();
return;
}
if (inView && pageVisible) raf = requestAnimationFrame(loop);
};
const boot = () => {
if (disposed) return;
tokens = readTokens();
if (!tokens) {
tokenWaitRaf = requestAnimationFrame(boot);
return;
}
isDark = document.documentElement.classList.contains("dark");
resize();
kick();
};
const ro = new ResizeObserver(() => {
if (!tokens) return;
resize();
if (reduced) drawStatic();
kick();
});
ro.observe(wrap);
const mo = new MutationObserver(() => {
tokens = readTokens();
isDark = document.documentElement.classList.contains("dark");
if (!tokens) return;
if (sized) rebakeGrainIfNeeded(); // stale-theme grain tile would otherwise persist
if (reduced) drawStatic();
else if (sized) draw(performance.now() / 1000);
kick();
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
const io = new IntersectionObserver((entries) => {
inView = entries[0]?.isIntersecting ?? true;
if (inView) {
tokens = readTokens() ?? tokens; // pick up a theme flip that happened while offscreen
isDark = document.documentElement.classList.contains("dark");
resumeLoop();
}
});
io.observe(wrap);
const onVisibility = () => {
pageVisible = document.visibilityState !== "hidden";
if (pageVisible) resumeLoop();
};
document.addEventListener("visibilitychange", onVisibility);
boot();
return () => {
disposed = true;
cancelAnimationFrame(raf);
cancelAnimationFrame(tokenWaitRaf);
ro.disconnect();
mo.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const input = form.elements.namedItem("email") as HTMLInputElement | null;
onSubscribe?.(input?.value ?? "");
form.reset();
};
return (
<footer
data-joint-iron
ref={wrapRef}
className={`relative w-full min-h-[380px] overflow-hidden ${className}`}
>
<canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full" />
{/* content clears the forming zone (groove centre is capped at 132px)
and sits directly on the case — no scrim panel, or the footer reads
as a card floating on an unexplained grey band */}
<div className="relative z-10 mx-auto max-w-5xl px-4 pt-40 pb-10 sm:px-6 sm:pt-44">
<div>
<div className="flex flex-col gap-8 sm:flex-row sm:items-start sm:justify-between">
<div className="max-w-xs">
<p className="font-mono text-sm font-semibold tracking-tight text-foreground">{brand}</p>
<p className="mt-2 text-sm leading-relaxed text-ns-muted">{tagline}</p>
<form onSubmit={handleSubmit} className="mt-4 flex max-w-xs gap-2">
<label htmlFor={emailId} className="sr-only">
Email address
</label>
<input
id={emailId}
name="email"
type="email"
required
placeholder={newsletterPlaceholder}
className="w-full min-w-0 rounded-sm bg-background/60 px-2.5 py-1.5 text-sm text-foreground placeholder:text-ns-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
/>
<button
type="submit"
className="shrink-0 rounded-sm bg-ns-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
>
Subscribe
</button>
</form>
</div>
<div className="grid grid-cols-2 gap-x-8 gap-y-6 sm:grid-cols-4">
{columns.map((col, i) => (
// the sitemap's own column rule — one of the two places
// --border is allowed as a stroke (section 9)
<div key={col.heading} className={i === 0 ? "" : "border-l border-border pl-6"}>
<h2 className="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-foreground">
{col.heading}
</h2>
<ul className="mt-3 flex flex-col gap-2">
{col.links.map((link) => (
<li key={link.href}>
<a
href={link.href}
className="rounded-sm text-sm text-ns-muted transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
))}
</div>
</div>
<div className="mt-8 border-t border-border pt-4">
<p className="font-mono text-xs text-ns-muted">
© {new Date().getFullYear()} {brand} — {legal}
</p>
</div>
</div>
</div>
</footer>
);
}
Build spec
Build <JointIron brand? tagline? columns? legal? newsletterPlaceholder? onSubscribe? className?> where columns is JointIronColumn[] ({heading, links: {label, href}[]}), defaulting to a 4-column sitemap so the component needs zero props. STRUCTURE: a <footer data-joint-iron> whose canvas is the ENTIRE footer surface (absolute inset-0, not a strip above the content) — a scrim panel (bg-background/70 backdrop-blur-sm) holding the wordmark, a newsletter form, the sitemap columns and a copyright line sits in normal DOM flow starting below the forming zone, on top of the SAME case tone the groove is cut into. THE MECHANIC, real and load-bearing: the top of the footer is a book joint being formed in a building-in machine. One height field per station, g(x): a cosine bump centred on the station (bump(d) = depth * 0.5 * (1 + cos(pi*d/hw)), d = x - stationCenterX, hw = 1.44 * depth — the groove's WIDTH shrinks with its depth, not just its floor). Case surface is bg mixed with fg at alpha 0.16 (light) / 0.22 (dark), painted below a resting line at min(0.14 * canvasHeight, 56px) — clamped so the groove zone always clears the DOM scrim's top offset even on a very tall footer. The groove is carried ENTIRELY by a two-facet Lambert-style shade, never by swapping in raw --background (that reads as a stroke, not a formed material): each column's shaded rect runs from the resting line down by `bump` (so the shaded region's own height, not just a boundary line, grows with depth), and its colour is chosen from shade = clamp(-0.35 + wallFactor, -1, 1), wallFactor = -sin(pi*d/hw) — the -0.35 ambient term (builder-chosen, not in the spec) sinks the whole notch a little below the flat case so the floor, where wallFactor -> 0, still reads as a shadowed recess instead of vanishing back to case tone. NAMED REGRESSION, kept here because it is the exact failure mode that got a prior footer (footing-course) removed from the registry: the first build of this component rendered the notch by reverting the dipped region to raw --background and drawing a thin lit lip along the new boundary, which reads as 'an animated top border' — kill criterion territory, since a pressed groove is still case material, recessed and lit, not a hole cut through it. Caught in review before ship and reworked to the shade-fill approach above; if this component is ever rebuilt from this instruction alone, do NOT reintroduce a --background erase inside the notch. Positive shade composites --background (dark theme: --foreground) at up to alpha 0.34 * shade; negative shade composites the opposite token at up to alpha 0.34*(0.17/0.15) * -shade — the 0.15/0.17 asymmetry is the spec's real facet-contrast numbers, translated into canvas alpha rather than literal luminance math. THREE STATIONS, one per third of the canvas width, each independently running a 7.4s five-phase cycle offset by 2.6s from its neighbour (deliberately not an exact third, tuned so prefers-reduced-motion's frozen frame shows one station mid-release, one mid-dwell, one mid-index at once): index (0.85s, the previous cycle's finished groove — held depth = 0.86 of the dwell-end depth, i.e. the post-spring-back value — slides out of the station's clip rect while nothing new is drawn, standing in for the unformed arrival), descent (0.62s, depth follows 1 - exp(-t/0.28), reaching 0.89 of g_max = 0.055 * min(canvasWidth, canvasHeight)), dwell (3.6s, depth creeps the last 11% toward g_max on a 1.4s time constant — the longest phase, deliberately, since setting a joint is mostly waiting), release (0.45s, THE SIGNATURE MOMENT: over the first 170ms, depth drops by exactly 14% via an eased spring, and simultaneously an extra 'shoulder-rounding' factor (1 - 0.09*ease) multiplies the facet contrast on top of the depth-linear scaling, so all three channels — floor depth, facet width (hw = 1.44*depth, so it narrows 14% too), and facet contrast — move together over the same 170ms; depth alone is below the perceptual floor at card scale and must not be trusted on its own), lift (1.88s, the iron rises over its first 300ms and a 700ms heat-shimmer runs along its underside). THE IRON is a solid --foreground rectangle (never tinted, never given --ns-accent — this is the trap the spec calls out explicitly) whose fill alpha hunts between roughly 0.82 and 0.90 on an unconditional 2.1s sine (its thermostat cycling, running whether the iron is up or down) and whose base alpha (0.86, not 1.0) exists specifically to leave headroom for the shimmer: the shimmer is a shadowBlur glow using the SAME --foreground token as both fillStyle and shadowColor, which is the only token-only way to 'brighten' a silhouette that is already at the bright extreme of the palette in dark theme and the dark extreme in light theme. A static seeded board-grain texture (mulberry32, fixed seed, baked once per resize AND re-baked on a theme flip to an offscreen tile, blitted every frame, never regenerated per-frame) fills the case below the groove zone; below a 88px canvas height, station count drops from 3 to 2 and the grain is dropped, while the groove and iron are kept, because the groove is the component. Reduced motion draws exactly one frame at STATIC_TIME = 2.62s via the same pure draw(t) function used by the live loop — the iron's thermostat term is also a function of t, not performance.now(), specifically so re-renders of the frozen frame stay byte-stable rather than resampling wall-clock. Canvas: DPR capped at 2, ResizeObserver on the footer element (not window), and the rAF loop runs only when an IntersectionObserver (threshold 0) reports the footer intersecting AND document.visibilityState is 'visible' — both conditions tracked separately so a background tab that scrolls the footer into view, or a foreground tab where it's merely offscreen, cannot restart the loop on its own — plus an adaptive column-step (1px -> 2px) that only widens after 1.5s of sustained sub-30fps frames, gated on wall-clock time, never frame count. Tokens (--background, --foreground, --ns-muted, --border) read via getComputedStyle(document.documentElement) with no literal fallback and re-read on a MutationObserver watching documentElement's class; nothing paints until both resolve, guarded on the rAF-driven boot retry, the ResizeObserver callback and the IntersectionObserver resume path. --border appears only as the 1px hairline between the canvas band and the page above and as the sitemap's column rules — never as a fill, never as the groove. --ns-accent appears only on the newsletter submit button and on focus-visible rings. The DOM footer content (wordmark, newsletter form with a real onSubscribe(email) callback, sitemap columns, legal line) is real, keyboard-navigable, semantically ordered <footer> markup sitting in a bg-background/70 backdrop-blur-sm scrim over the case tone — it is never canvas text.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| brand? | string | "ns-ui" | wordmark on the case |
| tagline? | string | "Bound the way it's built." | short line under the wordmark |
| columns? | JointIronColumn[] | DEFAULT_COLUMNS | sitemap columns |
| legal? | string | "All rights reserved." | legal / copyright line; year is appended automatically |
| newsletterPlaceholder? | string | "you@company.com" | newsletter field placeholder |
| onSubscribe? | (email: string) => void | — | called with the entered email on submit; default is a no-op (demo-safe) |
| className? | string | — | extra classes merged onto the rendered root element |