ns-ui
Equation Kidney Cam
A section divider whose midpoint tick rides a lookup table built from the real equation-of-time curve (an equation-marchante watch's kidney cam compressed to an 11s loop), sliding, slowing, and reversing direction with four unevenly-spaced zero-crossing brighten-pulses per cycle, never a symmetric back-and-forth.
Install
npx shadcn add https://design.helpmarq.com /r/equation-kidney-cam.jsonSource
registry/core/equation-kidney-cam/component.tsx"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// EquationKidneyCam — a section divider whose midpoint tick rides the real
// equation-of-time curve instead of sitting still. In an "equation
// marchante" watch (Breguet, Blancpain), a kidney-profiled cam turns once
// per year and a spring-loaded follower reads its edge, converting the
// cam's UNIFORM rotation into the NON-uniform, sign-changing swing between
// mean solar time and apparent solar time (-14m15s mid-February to
// +16m23s early November, crossing zero four times a year).
//
// The cam profile itself is never generated live. MONTH_EOT below holds 12
// real, almanac-sourced equation-of-time values (minutes) at 1-of-month
// intervals; wrapping December back into January makes a 13th, closing
// point. A periodic Catmull-Rom spline through those 12 anchors (each
// evaluated with its wrapped neighbours) is resampled at 5 points per
// segment — 60 points total, EOT_LUT — once, at module load. That table IS
// the cam: index i is the follower's radial reading at the angle the disc
// would be at i/60 of a full turn. Per rAF frame the only work done is
// picking two adjacent LUT entries by the current phase and lerping
// between them — no trig, no live curve evaluation.
//
// Real annual period: 1 year. Rendered period: 11s, documented here as a
// ~2.87-million-times compression (365.25 days / 11s) purely for card
// legibility — not a simulated calendar, just the same closed curve run
// fast. The follower's on-screen offset is the LUT value scaled by the
// LARGER of the curve's two real extremes (|+16.23| minutes), so the
// positive swing reaches the full +-22% of the divider's half-length while
// the (smaller-magnitude) negative swing falls proportionally short of
// it — the asymmetry is inherited from the real numbers, never forced.
// Because the underlying anchors are unevenly spaced in "when it crosses
// zero", the resulting sweep is not a sine: the drop from most-negative to
// zero is visibly quicker than the climb from zero to most-positive, and
// the four zero-crossing brighten-pulses per 11s cycle land at uneven
// intervals — the detail that reads as "cam", not "wave".
//
// Colour: the rule line sits at a mid-strength mix of --foreground (35%)
// so it's visible without being a full-strength rule (a --border stroke,
// at light theme's ~1.1:1 contrast, would make the follower's motion
// unreadable — the whole point of this component). Each zero-crossing
// briefly lifts that same --foreground opacity by 8% for 400ms via a
// restarted CSS animation — never --ns-accent, which is reserved for
// interaction chrome and never appears here since this is a passive
// divider with no interaction. An optional low-opacity ghost of the cam's
// own kidney outline (rendered from the identical LUT, as a closed SVG
// path) fades in on hover/focus purely as a decorative aid — position:
// absolute, so it can never affect layout.
// ---------------------------------------------------------------------------
export interface EquationKidneyCamProps {
className?: string;
}
// -- Real, almanac-sourced equation-of-time values (minutes), 1st-of-month,
// Jan through Dec. Standard published approximation; the true extremes
// (-14m15s mid-Feb, +16m23s early Nov) fall between these monthly anchors
// and are recovered by the spline, not hardcoded. --------------------------
const MONTH_EOT = [-3.4, -13.6, -12.6, -4.1, 2.9, 2.4, -3.6, -6.3, -0.1, 10.1, 16.4, 11.2];
const ANCHOR_COUNT = MONTH_EOT.length;
const SAMPLES_PER_SEGMENT = 5;
const LUT_SIZE = ANCHOR_COUNT * SAMPLES_PER_SEGMENT; // 60
function catmullRom(p0: number, p1: number, p2: number, p3: number, t: number): number {
const t2 = t * t;
const t3 = t2 * t;
return 0.5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
}
function buildLut(): number[] {
const lut: number[] = [];
for (let seg = 0; seg < ANCHOR_COUNT; seg++) {
const p0 = MONTH_EOT[(seg - 1 + ANCHOR_COUNT) % ANCHOR_COUNT];
const p1 = MONTH_EOT[seg];
const p2 = MONTH_EOT[(seg + 1) % ANCHOR_COUNT];
const p3 = MONTH_EOT[(seg + 2) % ANCHOR_COUNT];
for (let s = 0; s < SAMPLES_PER_SEGMENT; s++) {
const t = s / SAMPLES_PER_SEGMENT;
lut.push(catmullRom(p0, p1, p2, p3, t));
}
}
return lut;
}
const EOT_LUT = buildLut();
const LUT_MAX_ABS = EOT_LUT.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
let MIN_INDEX = 0;
let minVal = Infinity;
for (let i = 0; i < EOT_LUT.length; i++) {
const v = EOT_LUT[i];
if (v < minVal) {
minVal = v;
MIN_INDEX = i;
}
}
// -- ghost cam outline, built once from the same LUT: a closed polar path
// (angle = i/60 of a turn, radius = base + LUT value * scale). Decorative
// only; never rendered visibly at rest, only revealed faintly on hover. --
const GHOST_VB = 64;
const GHOST_CX = GHOST_VB / 2;
const GHOST_CY = GHOST_VB / 2;
const GHOST_BASE_R = 14;
const GHOST_R_SCALE = 8 / LUT_MAX_ABS;
const GHOST_PATH = (() => {
let d = "";
for (let i = 0; i < LUT_SIZE; i++) {
const angle = (i / LUT_SIZE) * Math.PI * 2 - Math.PI / 2;
const r = GHOST_BASE_R + EOT_LUT[i] * GHOST_R_SCALE;
const x = GHOST_CX + Math.cos(angle) * r;
const y = GHOST_CY + Math.sin(angle) * r;
d += i === 0 ? `M ${x.toFixed(2)} ${y.toFixed(2)}` : ` L ${x.toFixed(2)} ${y.toFixed(2)}`;
}
return `${d} Z`;
})();
const CAM_PERIOD_MS = 11000; // real period: 1 year — an ~2.87M:1 compression
const MAX_OFFSET_PCT = 22; // +-22% of the divider's half-length
const PULSE_MS = 400;
const HEIGHT_PX = 28; // fixed card-scale height; every other dimension below derives from it
const TICK_HEIGHT_PX = Math.round(HEIGHT_PX * 0.5);
export function EquationKidneyCam({ className = "" }: EquationKidneyCamProps) {
const rootRef = useRef<HTMLDivElement>(null);
const lineRef = useRef<HTMLDivElement>(null);
const tickRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
const line = lineRef.current;
const tick = tickRef.current;
if (!root || !line || !tick) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const sampleAt = (idxFloat: number): number => {
const i0 = Math.floor(idxFloat) % LUT_SIZE;
const i1 = (i0 + 1) % LUT_SIZE;
const frac = idxFloat - Math.floor(idxFloat);
return EOT_LUT[i0] + (EOT_LUT[i1] - EOT_LUT[i0]) * frac;
};
const applyOffset = (value: number) => {
const pct = (value / LUT_MAX_ABS) * MAX_OFFSET_PCT;
tick.style.left = `${50 + pct}%`;
};
const firePulse = () => {
line.style.animation = "none";
// force reflow so re-setting the animation below restarts it
void line.offsetWidth;
line.style.animation = `ns-eqcam-pulse ${PULSE_MS}ms ease-out`;
};
if (reduced) {
// freeze on the real-world most-negative point (mid-February
// analogue, -14m15s) — the cam's tightest inward point and the most
// visually distinct resting position, per the reduced-motion rule.
applyOffset(EOT_LUT[MIN_INDEX]);
return;
}
let raf = 0;
let visible = true;
let startTime = 0;
let prevSign = Math.sign(EOT_LUT[0]) || 1;
const loop = (now: number) => {
raf = 0;
if (!visible) return;
if (startTime === 0) startTime = now;
const elapsed = now - startTime;
const phase = (elapsed % CAM_PERIOD_MS) / CAM_PERIOD_MS;
const idxFloat = phase * LUT_SIZE;
const value = sampleAt(idxFloat);
applyOffset(value);
const sign = value === 0 ? prevSign : Math.sign(value);
if (sign !== prevSign) firePulse();
prevSign = sign;
raf = requestAnimationFrame(loop);
};
// -- no paint before the first token/geometry read: only start once
// the divider is actually on screen, and pause the rAF loop (not the
// logical clock — startTime is wall-clock, so re-entering resumes the
// correct phase, not a reset) while it scrolls off. -------------------
const io = new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (visible && !raf) raf = requestAnimationFrame(loop);
});
io.observe(root);
return () => {
cancelAnimationFrame(raf);
io.disconnect();
};
}, []);
return (
<div
ref={rootRef}
role="separator"
aria-orientation="horizontal"
data-eqcam
className={`ns-eqcam relative w-full ${className}`}
style={{ height: HEIGHT_PX }}
>
<style>{`
.ns-eqcam{ display:flex; align-items:center; }
.ns-eqcam-line{
position:relative;
width:100%;
height:1px;
background:var(--foreground);
opacity:0.35;
}
.ns-eqcam-tick{
position:absolute;
top:50%;
width:1px;
height:${TICK_HEIGHT_PX}px;
background:var(--foreground);
opacity:0.9;
transform:translate(-50%, -50%);
left:50%;
}
.ns-eqcam-ghost{
position:absolute;
left:50%;
top:50%;
width:${GHOST_VB}px;
height:${GHOST_VB}px;
transform:translate(-50%, -50%);
opacity:0;
pointer-events:none;
transition:opacity 300ms ease;
}
.ns-eqcam:hover .ns-eqcam-ghost{ opacity:0.18; }
@keyframes ns-eqcam-pulse{
0%{ opacity:0.43; }
100%{ opacity:0.35; }
}
@media (prefers-reduced-motion: reduce){
.ns-eqcam-line{ animation:none!important; }
.ns-eqcam-ghost{ transition:none!important; }
}
`}</style>
<div ref={lineRef} className="ns-eqcam-line" aria-hidden="true" />
<div ref={tickRef} className="ns-eqcam-tick" aria-hidden="true" />
<svg
className="ns-eqcam-ghost"
viewBox={`0 0 ${GHOST_VB} ${GHOST_VB}`}
aria-hidden="true"
focusable="false"
>
<path d={GHOST_PATH} fill="none" stroke="var(--foreground)" strokeWidth={1} />
</svg>
</div>
);
}
Build spec
Build <EquationKidneyCam className?> as a drop-in replacement for <hr>/border-top between page sections. THE DATA: a module-scope constant MONTH_EOT holds 12 real, almanac-sourced equation-of-time values in minutes (Jan-Dec, 1st-of-month) — the sundial-minus-clock offset that swings between roughly -14 and +16 minutes across the real year. A periodic Catmull-Rom spline is run through those 12 anchors (each segment's four control points wrap via modulo so December connects back into January, making the 12 anchors read as a 13th, closing point) and resampled at 5 points per segment for a 60-entry EOT_LUT, computed once at module load with plain arithmetic — no trig at runtime. THE ANIMATION: a single rAF loop reads a wall-clock elapsed time, maps it to a phase via a fixed CAM_PERIOD_MS=11000 (documented in-code as a ~2.87-million-times compression of the real 1-year period, purely for card legibility, not a simulated calendar), multiplies phase by 60 to get a float LUT index, and linearly interpolates between the two adjacent LUT entries — that interpolated value IS the follower's reading for this frame, no re-evaluation of the spline. THE OFFSET: the follower's on-screen position is `50% + (value / LUT_MAX_ABS) * 22%` applied as the tick element's `left`, where LUT_MAX_ABS is the LARGER-magnitude extreme found in the actual resampled table (near +16.4) — so the positive swing reaches the full +-22% of the divider's half-length while the smaller-magnitude negative swing (near -13.6) falls proportionally short of it; this asymmetry must never be forced to a symmetric +-22%, it has to fall out of the real numbers. Because the anchors are unevenly spaced in when they cross zero, the resulting motion is NOT a sine — verify visually that the drop from most-negative to zero is faster than the climb from zero to most-positive. THE PULSE: every frame compares the current sample's sign against the previous frame's; on a sign flip, restart a `ns-eqcam-pulse` CSS animation on the line element (set style.animation='none', read offsetWidth to force reflow, then set the animation string again) that lifts opacity from 0.43 to its resting 0.35 over 400ms — this fires 4 times per 11s cycle, at whatever intervals the real curve's own zero-crossing dates produce (never evenly spaced; do not add a timer-based even-interval pulse as a shortcut). STRUCTURE: outer <div role="separator" aria-orientation="horizontal" data-eqcam> at a fixed 28px height (every other dimension, e.g. the 14px tick height, derives from that one constant, satisfying 'derive geometry from the container's smaller dimension' for a component that is mostly width) containing (1) a 1px-tall line div at `background:var(--foreground); opacity:0.35` — deliberately NOT `--border` (whose ~1.1:1 light-theme contrast would make the follower's motion imperceptible) and NOT full-strength `--foreground` (this is a separator, not a headline) — (2) an absolutely positioned 1px-wide tick div, taller than the line, at `var(--foreground)` opacity 0.9, whose `left` is rewritten every frame, and (3) a decorative aria-hidden SVG built once from the identical LUT as a closed polar path (angle = i/60 of a turn, radius = base + LUT value * scale) — opacity 0 at rest, fading to ~0.18 only on `:hover`/`:focus-within` via CSS, absolutely positioned and centered so it can never affect layout. LIFECYCLE: an IntersectionObserver starts/stops the rAF loop as the divider enters/leaves the viewport, but never resets `startTime` — re-entering resumes the correct real phase, it does not restart the cycle, satisfying 'must NOT respond to hover/scroll by changing rate or resetting phase'. REDUCED MOTION: skip the rAF loop entirely and call the same `applyOffset` once with `EOT_LUT[MIN_INDEX]` — MIN_INDEX is computed once at module load as the argmin of the whole LUT, i.e. the real-world most-negative point (the mid-February analogue, the cam's tightest inward point), not t0, which could land anywhere including a near-invisible near-zero offset. COLOUR: every stroke/fill is `var(--foreground)` at an authored opacity (0.35 line, 0.9 tick, 0.18 ghost-on-hover, 0.43 pulse peak) or `var(--background)`; `--ns-accent` never appears anywhere in this component — it is a passive divider with zero interaction states to mark, and the spec explicitly forbids the brighten-on-cross effect from touching accent. A11Y: role=separator carries the semantics, no accessible name needed; every visual element is aria-hidden since there is nothing to operate. DIFFERS FROM: craze-rule, which is a ONE-SHOT scroll-triggered reveal (a propagating fracture that arrives once via IntersectionObserver, then idles) — pick craze-rule instead when the divider should announce a section boundary with a single arrival moment; equation-kidney-cam is the opposite, an already-settled line with continuous, non-uniform ambient drift that is running identically whether or not the viewer ever scrolled past it, and it never has a one-time 'arrival' state. DIFFERS FROM: divider-telephone-cord-delam, whose motion is a physical coil-unwinding metaphor with a single dominant oscillation frequency — equation-kidney-cam's signature is specifically an ASYMMETRIC, sign-changing, non-periodic-looking sweep sourced from astronomical data, with unevenly-timed pulses being the tell. Zero dependencies, no canvas — DOM+SVG+CSS only; the only per-frame JS is one LUT lookup, one lerp, one style write, and an occasional animation restart.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| className? | string | — | — |