ns-ui
Range Light Transit
An ambient convergence moment modelled on maritime range lights: a near light and a far light drift independently on incommensurate periods, sliding apart and repeatedly back into vertical alignment, briefly brightening together each time they agree.
Use when an ambient feedback moment where two independent, slowly-varying states are converging and briefly agreeing — a connection-sync or two-way-handshake status distinct from a determinate progress bar or a binary connected/disconnected dot — and the recurrence should read as irregular/organic rather than a knowable fixed-length cycle; pick loader-pendulum-sync instead when the surface is an indeterminate long-wait LOADER (the user looked away and needs a knowable ~10s return-to-unison arc, not an ongoing ambient status) rendered as a row of pendulums rather than a two-point pair, or status-metaball-merge when the thing being shown is group membership/belonging rather than two states agreeing.
Install
npx shadcn add https://design.helpmarq.com /r/range-light-transit.jsonSource
registry/core/range-light-transit/component.tsx"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// RangeLightTransit — an ambient convergence indicator modelled on maritime
// range lights (leading lights): a pair of navigation marks at different
// distances/heights that a pilot keeps vertically stacked ("in transit") to
// hold a safe channel course. Off the line the lights visibly separate; back
// on it, they read as one aligned pair (USCG / Trinity House leading-line
// marks). Used here for a status moment where two independent, slowly
// varying states are converging and briefly agreeing — distinct from a
// determinate progress bar or a binary connected/disconnected dot.
//
// The front (lower, closer) and rear (higher, farther) light each drift
// horizontally on their own sine, incommensurate periods (6.2s / 9.7s) so
// alignment is never on a fixed beat. A thin line always connects the two
// disc centres — vertical exactly when they share an X, visibly tilted
// otherwise — so the tilt straightening out IS the converging read. Its
// opacity has two components: a continuous "how close" glow that climbs as
// the horizontal gap shrinks, plus a 250ms brighten-in that only completes
// if the gap stays under the 3%-of-width alignment threshold continuously
// for that long. The arrival cue (both discs grow and gain a soft --foreground
// drop-shadow — a halo in dark theme, a deepening shadow in light theme, so
// it reads correctly off the same token in both directions with no per-theme
// branch) is GATED on that same 250ms dwell, then fires once, at the true
// local minimum of the gap inside the dwell, held 300ms then eased back over
// 600ms — never a single-frame flash. The dwell gate matters on both cues:
// two independent sines cross within the threshold constantly (every
// ~1.5-4s) just passing through, but only stay inside it for 250ms+ on a
// real transit crossing (roughly every 1.5-11s at these periods) — without
// the gate, the pulse would fire on every passing crossing and read as
// generic blinking, not converging. Pure DOM/SVG, refs-only hot path, no
// React state, no canvas.
// ---------------------------------------------------------------------------
const FRONT_AMP_FRAC = 0.18; // front light drift amplitude, fraction of card width
const FRONT_PERIOD_S = 6.2;
const FRONT_PHASE = 1.9; // rad, arbitrary — chosen so t0 sits at a nonzero offset
const REAR_AMP_FRAC = 0.14; // rear light drift amplitude, fraction of card width
const REAR_PERIOD_S = 9.7;
const REAR_PHASE = 4.3; // rad
const ALIGN_THRESHOLD_FRAC = 0.03; // "in transit" gap, fraction of card width
const APPROACH_WINDOW_FRAC = ALIGN_THRESHOLD_FRAC * 3; // where the continuous glow begins
const GUIDE_FLOOR_OPACITY = 0.12; // guideline is faint, never fully absent — see note below
const GUIDE_RAMP_MS = 250; // continuous dwell inside threshold before the line reads fully "in transit"
const PULSE_HOLD_MS = 300; // arrival cue held at full luminance
const PULSE_DECAY_MS = 600; // then eased back to baseline
const PULSE_GROW = 0.15; // disc radius growth at peak, fraction of base radius
const PULSE_SHADOW_MAX = 6; // drop-shadow blur radius (px) at peak
const FRONT_Y_FRAC = 0.78; // lower in the card (nearer light)
const REAR_Y_FRAC = 0.24; // higher in the card (farther light)
const MIN_DISC_R = 3;
const MAX_DISC_R = 7;
const DISC_R_FRAC = 0.05; // of min(w, h)
const REAR_R_SCALE = 0.75; // farther light reads visibly smaller
// FREEZE FRAME: reduced-motion renders a single frame at t = 1.753s via a
// fresh call to render() (no accumulated dwell state), so the dwell ramp
// term is always 0 there and the guideline shows the continuous glow term
// alone. At this instant gap = 1.32% of card width (inside the 3% threshold,
// still closing — a real approach, not an incidental touch) which puts that
// glow term at ~0.43 opacity: both discs visibly offset, the line visibly
// present but not fully bright, no arrival cue active. The full-alignment
// frame is deliberately avoided (the pulse would read as blown-out on a
// static frame) and so is a maximum-offset frame (the line is fully
// resting at its GUIDE_FLOOR_OPACITY there, the least structured option).
const FREEZE_T_S = 1.753;
function easeOutCubic(t: number): number {
const u = 1 - t;
return 1 - u * u * u;
}
export interface RangeLightTransitProps {
/** label above the reading */
label?: string;
/** card height in px */
height?: number;
/** extra classes merged onto the rendered root element */
className?: string;
}
export function RangeLightTransit({
label = "Sync transit",
height = 200,
className = "",
}: RangeLightTransitProps) {
const svgRef = useRef<SVGSVGElement>(null);
const guideRef = useRef<SVGLineElement>(null);
const frontRef = useRef<SVGCircleElement>(null);
const rearRef = useRef<SVGCircleElement>(null);
useEffect(() => {
const svg = svgRef.current;
const guide = guideRef.current;
const front = frontRef.current;
const rear = rearRef.current;
if (!svg || !guide || !front || !rear) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// fallbacks are CSS keywords, never literal colour values
let fg = "currentColor";
const readTokens = () => {
const root = getComputedStyle(document.documentElement);
fg = root.getPropertyValue("--foreground").trim() || "currentColor";
front.style.fill = fg;
rear.style.fill = fg;
guide.style.stroke = fg;
};
let w = 0;
let h = 0;
let sized = false;
const measure = () => {
const rect = svg.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) {
sized = false;
return;
}
w = rect.width;
h = rect.height;
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
sized = true;
};
// -- per-frame gap state, refs only, never React state ------------------
let insideSince: number | null = null;
let prevDiff = Infinity;
let approaching = false;
let pulseFiredThisDwell = false;
let pulseStartMs: number | null = null;
const render = (tMs: number) => {
if (!sized) return;
const tS = tMs / 1000;
const fx = FRONT_AMP_FRAC * Math.sin((2 * Math.PI * tS) / FRONT_PERIOD_S + FRONT_PHASE);
const rx = REAR_AMP_FRAC * Math.sin((2 * Math.PI * tS) / REAR_PERIOD_S + REAR_PHASE);
const diff = Math.abs(fx - rx);
const within = diff < ALIGN_THRESHOLD_FRAC;
if (within) {
if (insideSince === null) insideSince = tMs;
} else {
insideSince = null;
pulseFiredThisDwell = false;
approaching = false;
}
const rampT = within && insideSince !== null ? Math.min(1, (tMs - insideSince) / GUIDE_RAMP_MS) : 0;
// arrival cue fires once, at the true local minimum of the gap, but
// only once the gap has genuinely dwelled inside the threshold for the
// same 250ms the guideline needs — this is what keeps the cue off the
// many fast, glancing crossings and reserves it for a real transit.
const dwelledEnough = insideSince !== null && tMs - insideSince >= GUIDE_RAMP_MS;
if (within) {
if (diff < prevDiff) {
approaching = true;
} else if (approaching && !pulseFiredThisDwell && dwelledEnough) {
pulseFiredThisDwell = true;
pulseStartMs = tMs;
approaching = false;
}
}
prevDiff = diff;
// max amplitude difference (18%+14%) means the raw gap spends over
// half of any long window beyond the approach window entirely — a
// pure closeness^2 term would leave the connecting line fully absent
// more often than not, which reads as two unconnected drifting dots
// rather than a pair converging. A small resting floor keeps the
// reference line always faintly present (spec allows "dim or absent").
const closeness = Math.max(0, Math.min(1, 1 - diff / APPROACH_WINDOW_FRAC));
const approachOpacity = GUIDE_FLOOR_OPACITY + (0.55 - GUIDE_FLOOR_OPACITY) * closeness * closeness;
const guideOpacity = within ? approachOpacity + (1 - approachOpacity) * easeOutCubic(rampT) : approachOpacity;
let pulseFactor = 0;
if (pulseStartMs !== null) {
const since = tMs - pulseStartMs;
if (since <= PULSE_HOLD_MS) pulseFactor = 1;
else if (since <= PULSE_HOLD_MS + PULSE_DECAY_MS) {
pulseFactor = 1 - (since - PULSE_HOLD_MS) / PULSE_DECAY_MS;
} else {
pulseFactor = 0;
pulseStartMs = null;
}
}
const side = Math.min(w, h);
const baseR = Math.min(MAX_DISC_R, Math.max(MIN_DISC_R, side * DISC_R_FRAC));
const discR = baseR * (1 + PULSE_GROW * pulseFactor);
const rearR = discR * REAR_R_SCALE;
const cx = w / 2;
const frontX = cx + fx * w;
const frontY = h * FRONT_Y_FRAC;
const rearX = cx + rx * w;
const rearY = h * REAR_Y_FRAC;
front.setAttribute("cx", frontX.toFixed(2));
front.setAttribute("cy", frontY.toFixed(2));
front.setAttribute("r", discR.toFixed(2));
rear.setAttribute("cx", rearX.toFixed(2));
rear.setAttribute("cy", rearY.toFixed(2));
rear.setAttribute("r", rearR.toFixed(2));
guide.setAttribute("x1", frontX.toFixed(2));
guide.setAttribute("y1", frontY.toFixed(2));
guide.setAttribute("x2", rearX.toFixed(2));
guide.setAttribute("y2", rearY.toFixed(2));
guide.style.opacity = guideOpacity.toFixed(3);
// One formula for both themes, no isDark branch: a --foreground
// drop-shadow plus a small radius grow. Because fg is already the
// token that flips per theme, this reads as a soft light-coloured
// halo blooming around the disc in dark theme, and as a deepening
// dark shadow around it in light theme — a literal brightness()
// filter was tried here and clamped to a barely-visible +7.6% swing
// against near-white --foreground in dark theme, so the cue is
// carried by shadow + size instead of a filter that can clip.
if (pulseFactor > 0) {
const f = `drop-shadow(0 0 ${(PULSE_SHADOW_MAX * pulseFactor).toFixed(2)}px ${fg})`;
front.style.filter = f;
rear.style.filter = f;
} else {
front.style.filter = "none";
rear.style.filter = "none";
}
};
let raf = 0;
let last = 0;
let globalTMs = 0;
let visible = true;
const loop = (now: number) => {
if (last === 0) last = now;
globalTMs += Math.min(100, now - last);
last = now;
render(globalTMs);
if (visible && !reduced) raf = requestAnimationFrame(loop);
else raf = 0;
};
const mo = new MutationObserver(() => {
readTokens();
render(reduced ? FREEZE_T_S * 1000 : globalTMs);
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const onResize = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
measure();
render(reduced ? FREEZE_T_S * 1000 : globalTMs);
}, 120);
};
const ro = new ResizeObserver(onResize);
ro.observe(svg);
const io = new IntersectionObserver(
(entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (visible && !reduced && sized && !raf) {
last = 0;
raf = requestAnimationFrame(loop);
}
},
{ threshold: 0 }
);
io.observe(svg);
const onVis = () => {
if (document.hidden) {
cancelAnimationFrame(raf);
raf = 0;
} else if (!reduced && sized && visible && !raf) {
last = 0;
raf = requestAnimationFrame(loop);
}
};
document.addEventListener("visibilitychange", onVis);
// no paint before the first token read
readTokens();
measure();
if (reduced) {
render(FREEZE_T_S * 1000);
} else if (sized) {
raf = requestAnimationFrame(loop);
}
return () => {
cancelAnimationFrame(raf);
if (resizeTimer) clearTimeout(resizeTimer);
mo.disconnect();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
};
}, []);
return (
<div className={className}>
<div className="flex items-baseline justify-between gap-3">
<span className="font-mono text-[11px] tracking-wide text-ns-muted">
{label.toUpperCase()}
</span>
<span className="font-mono text-[11px] tracking-wide text-ns-muted">RANGE LIGHTS</span>
</div>
<div
role="img"
aria-label={`${label}: two lights drifting independently, briefly aligning as they converge`}
className="mt-2"
>
<svg ref={svgRef} aria-hidden="true" className="block w-full" style={{ height }}>
<line ref={guideRef} strokeWidth={1.25} strokeLinecap="round" />
<circle ref={rearRef} />
<circle ref={frontRef} />
</svg>
</div>
<div className="mt-1.5 flex items-center justify-between font-mono text-[11px] text-ns-muted">
<span>FRONT {FRONT_PERIOD_S.toFixed(1)}s</span>
<span>REAR {REAR_PERIOD_S.toFixed(1)}s</span>
</div>
</div>
);
}
Build spec
Build <RangeLightTransit label? height? className?> as a card-scale SVG panel modelled on maritime range lights (leading lights): a pair of navigation marks at different distances/heights that a pilot keeps vertically stacked ('in transit') to hold a safe channel course (USCG / Trinity House leading-line marks). Two discs drift on independent vertical guides inside the card — the front (near) light lower at 78% of card height, the rear (far) light higher at 24% — each offset horizontally from the card's own centre by its own sine of elapsed time: front amplitude = 18% of card width, period 6.2s; rear amplitude = 14% of card width, period 9.7s. The two periods are incommensurate on purpose so alignment moments recur aperiodically rather than on a fixed beat; the front and rear discs also render at different radii (rear = 0.75x front, both derived from min(card width, card height) and clamped 3-7px) so the pair reads as near/far, not as two identical dots. A single thin line is drawn every frame directly between the two disc centres — vertical exactly when their horizontal offsets match, visibly tilted otherwise — so the tilt straightening out on approach and reappearing on departure IS the 'converging' read, not a separate abstraction. Alignment is defined as |frontOffset - rearOffset| < 3% of card width. The line's opacity per frame is the sum of two effects: a continuous 'how close' glow — closeness = clamp(1 - gap/(3x threshold), 0, 1), opacity contribution = 0.12 + (0.55 - 0.12) * closeness^2, a small resting floor (0.12) rather than a bare closeness^2 term because at these amplitudes (18%+14%) the raw gap spends over half of any long run beyond the approach window entirely — a pure closeness^2 term left the connecting line fully invisible more than half the time, reading as two unconnected drifting dots rather than a converging pair — that climbs smoothly as the gap shrinks even before true alignment, plus, only while the gap has stayed continuously under the 3% threshold, an eased ramp (easeOutCubic) from that glow value up to full opacity over 250ms of continuous dwell. That 250ms gate is deliberate: two independent sines produce many fast, glancing zero-crossings of their difference (every ~1.5-4s at these periods) that touch the threshold only briefly; only a crossing with real dwell time — genuinely slowing near zero, not just passing through it — reads as an actual transit crossing and earns the full brighten. The SAME 250ms dwell gates the arrival cue itself: it fires only once the gap has already dwelled inside the threshold for 250ms+, then triggers at the true local minimum of the gap (detected by watching the gap shrink then start growing again while still inside the threshold, not by the ramp reaching 1, so it never re-fires on a lingering close pair): held at full for 300ms, then eased back to baseline over 600ms — never a single-frame flash. With this gate, full alignment events (guideline reaching full opacity, arrival cue firing) land roughly every 1.5-11s at these periods/threshold — irregular, not a fixed beat, but comfortably inside any few-second glance window; without the gate the raw zero-crossings of the two sines would fire a cue every ~1.5-4s and read as generic blinking. The cue itself is one CSS formula in both themes, no per-theme branch: `filter: drop-shadow(0 0 (6*pulseFactor)px var(--foreground))` on both discs plus a synchronized radius grow (+15% at peak) — because --foreground is already the token that flips per theme, this reads as a soft light-coloured halo blooming around the disc in dark theme, and as a deepening dark shadow around it in light theme, both from the same expression (a literal `brightness()` filter was tried first and clamped to a barely-visible +7.6% swing against near-white --foreground in dark theme, so the cue is carried by shadow + size instead of a filter that can clip — check this explicitly, it is the one place this cue's mechanism had to be rebuilt to work in both themes). All colour is read once via getComputedStyle(document.documentElement).getPropertyValue('--foreground') before the first paint and re-derived on a documentElement class MutationObserver (theme flip repaints live); disc fill and guideline stroke are both --foreground (so discs render as bright dots on the dark card in dark theme and as filled dark shapes on the light card in light theme, for free, from the same token) — --border and --ns-accent are never used, since the guideline is not a UI separator and this component has no interactive chrome. Geometry (disc radius) is derived from min(containerWidth, containerHeight); horizontal drift amplitude and the alignment threshold are fractions of container WIDTH specifically, so the component still reads correctly in a wide, short card. RESTING LOOP: t0 — discs at some nonzero offset, guideline at or near its resting floor; 2.5s — the front light alone has moved through roughly 40% of its own 6.2s period, both disc positions visibly different from t0; 5s — a different offset again, plausibly mid-approach to or departure from an alignment event. REDUCED MOTION: freezes at t = 1.753s via a single direct call to the render function (never by running the rAF loop once), so the dwell-ramp term is always exactly 0 for that frame and the guideline shows the continuous glow term alone — at that instant the gap is 1.32% of width (inside the 3% threshold and still closing, a genuine approach rather than an incidental touch), which puts the glow term at ~0.43 opacity: both discs visibly offset, the line visibly present but not fully bright, no arrival cue active. The full-alignment frame is deliberately avoided (the shadow/grow spike would read as blown-out or flat on a static frame) and so is a maximum-offset frame (the guideline sits at its 0.12 resting floor there, the least structured option). LIFECYCLE: the SVG is measured via getBoundingClientRect on mount and on a 120ms-debounced ResizeObserver; the rAF loop accumulates real elapsed time (capped at 100ms/frame) rather than assuming 60fps, pauses via an IntersectionObserver when off-screen and via a visibilitychange listener when the tab is hidden (both resume with a fresh last-timestamp, no time-jump), and every rAF/observer/listener is torn down on unmount. Under prefers-reduced-motion the render function is called exactly once at the fixed freeze time and no rAF loop ever starts. autoplay: none because the pair drifts on its own internal clock regardless of any pointer/scroll/press input — there is nothing for a synthetic-input driver to trigger, and this is explicitly an ambient-only surface (interaction: none). A11Y: the SVG is aria-hidden; it sits inside a role=img wrapper with an aria-label describing the reading in prose ('two lights drifting independently, briefly aligning as they converge') since there is no single scalar to expose. Below the panel, a static Geist Mono caption row reads the two REAL NUMBERS that never change — front and rear period in seconds — legible independent of motion. Zero dependencies, DOM+SVG+CSS only, no canvas, every colour a token. Props: label (accessible name and visible caption, default 'Sync transit'), height (svg panel height px, default 200), className.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label? | string | "Sync transit" | label above the reading |
| height? | number | 200 | card height in px |
| className? | string | — | extra classes merged onto the rendered root element |