"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/**
* Keep:
* - One canvas and one rAF loop draw the rail. A frame redraws only the columns whose brightness
* step changed, and the loop stops once nothing moves or fades, off screen and on a hidden tab.
* - A lit dot's halo stays inside its own cell, so redrawing one column never clips a neighbor.
* - Each end of the run is its own critically damped spring: the end that grows the run uses
* `stiffness`, the end that shrinks it `followStiffness`, so the run stretches toward its target.
* - A dot is on or off by whether its column is inside the run, never half lit by position; only
* its fade after the run leaves is gradual, an exponential that's out by `trail`.
* - A first target and any relayout land without travel, already lit. Under reduced motion every
* target does, with no tail, and the loop parks mid-rail and breathes.
*/
const TIMING = {
/** A dot switching on, in seconds. */
rise: 0.03,
/** How long a dot keeps glowing after the run leaves it, in ms (the `trail` prop). */
trail: 260,
/**
* Spring stiffness of the end that grows the run (critically damped; the `stiffness` prop), and
* of the end that shrinks it (the `followStiffness` prop).
*/
stiffness: 900,
followStiffness: 420,
/** The run's travel under `follow="loop"`, in dots a second. */
loop: 42,
/** One breath of the loop under reduced motion, in seconds. */
breathe: 2.4,
/** The springs' integration step, in seconds. */
substep: 0.004,
/** The longest step one frame advances the springs and fades by, in seconds (after a stall). */
maxFrame: 0.05,
} as const;
/* ─────────────────────────────────────────────────────────
* DOT RAIL STORYBOARD
*
* rest a band of dots three rows deep, at 30% of the text
* color: the dotted strip under a navbar. A run of
* them is lit in the accent, like a few lamps on an
* LED sign, each with a soft halo inside its own cell
* light a dot the run reaches switches on in 30ms. A dot it
* leaves doesn't go dark at once: it fades on an
* exponential curve and is out by `trail` (260ms), so
* a fast move leaves a short comet tail and a slow one
* hardly any. A dot is on or off, never half lit by
* position; only the fade is gradual
* move each end of the run chases its new place on a
* critically damped spring. The end that grows the run
* leads (stiffness 900, in about 0.2s), the end that
* shrinks it follows (420, about 0.3s), so the run
* stretches toward where it's going and gathers up
* behind itself
* track with `trackRef`, the run jumps under whichever link
* or button in it is hovered or keyboard-focused, as
* wide as that item, and goes back to the one marked
* aria-current (or data-active) when the pointer
* leaves. Mouse and keyboard only; a tap just changes
* which one is current
* scroll `follow="scroll"`: the run sits at the scroll
* progress of the page, or of `containerRef`, through
* the same springs, so its tail grows with scroll speed
* loop `follow="loop"`, and the default with nothing else
* to place it: the run travels left to right at 42 dots
* a second and wraps round, a loader or an ambient
* strip. A target that takes over keeps its speed
* timer `fill` lights only part of the current item, from
* its left edge: drive it from a timer and the dots
* under an autoplaying tab switch on one by one. When
* the next tab becomes current at 0, the run shrinks
* over to it, its tail fading on the last one, and
* starts counting again. Hover still shows the whole
* item
* fill `value={[0, 1]}` lights the whole rail. Both ends
* lead, so it floods out from wherever the run was
* sleep the canvas only redraws the columns whose light
* changed, and stops once nothing moves or fades, off
* screen and on a hidden tab
* reduced no tail and no travel: the run is drawn straight at
* its target. The loop parks mid-rail and breathes,
* 55% to full over 2.4s
* ───────────────────────────────────────────────────────── */
const RAIL = {
base: 0.3, // opacity of an unlit dot, as a share of the text color's
radius: 0.27, // dot radius, as a share of `pitch`
halo: { radius: 0.5, alpha: 0.3 }, // a lit dot's glow: radius as a share of `pitch` (it stays inside its cell), and its opacity at full light
floor: 0.03, // brightness under which a fading dot is out
levels: 24, // brightness steps a dot can be drawn at
rest: { distance: 0.01, speed: 0.05 }, // dots, dots/s: an end nearer and slower than this has landed
breathe: { low: 0.55 }, // brightness the loop breathes down to under reduced motion
accent: "#ff5a1f", // without --dot-rail-accent
maxDpr: 2,
} as const;
const ITEM = 'a[href], button, [role="tab"], [data-dot-rail-item]';
const ACTIVE =
'[aria-current]:not([aria-current="false"]), [data-active]:not([data-active="false"]), [aria-selected="true"]';
type Rgba = [number, number, number, number];
type Follow = "scroll" | "loop";
type Options = {
value: number | [number, number] | undefined;
fill: number;
follow: Follow | undefined;
tracking: boolean;
rows: number;
pitch: number;
length: number;
trail: number;
stiffness: number;
followStiffness: number;
reduced: boolean;
};
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
/** Any CSS color (hex, rgb, oklch, a var() already resolved) as RGBA bytes, read off a 1×1 canvas. */
function makeColorReader() {
let ctx: CanvasRenderingContext2D | null = null;
return (color: string): Rgba => {
if (!ctx) {
const c = document.createElement("canvas");
c.width = c.height = 1;
ctx = c.getContext("2d", { willReadFrequently: true });
}
if (!ctx) return [0, 0, 0, 1];
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = "#000";
ctx.fillStyle = color;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
return [r, g, b, a / 255];
};
}
type Edge = { x: number; v: number; t: number };
/** One end of the run, on a critically damped spring of stiffness `k`. Returns whether it's still moving. */
function stepEdge(edge: Edge, dt: number, k: number) {
if (Math.abs(edge.x - edge.t) < RAIL.rest.distance && Math.abs(edge.v) < RAIL.rest.speed) {
edge.x = edge.t;
edge.v = 0;
return false;
}
const c = 2 * Math.sqrt(k);
const n = Math.max(1, Math.ceil(dt / TIMING.substep));
const h = dt / n;
let { x, v } = edge;
for (let i = 0; i < n; i++) {
v += (-k * (x - edge.t) - c * v) * h;
x += v * h;
}
edge.x = x;
edge.v = v;
return true;
}
function createRail() {
let root: HTMLElement | null = null;
let canvas: HTMLCanvasElement | null = null;
let ctx: CanvasRenderingContext2D | null = null;
const readColor = makeColorReader();
let options: Options = {
value: undefined,
fill: 1,
follow: undefined,
tracking: false,
rows: 3,
pitch: 6,
length: 6,
trail: TIMING.trail,
stiffness: TIMING.stiffness,
followStiffness: TIMING.followStiffness,
reduced: false,
};
// Geometry, in CSS px. Column c's dots are centered at ox + c * pitch; the
// run's ends are in column-edge units, so column c is lit when
// start <= c + 0.5 < end.
let dpr = 1;
let width = 0;
let height = 0;
let cols = 0;
let ox = 0;
let glow = new Float32Array(0); // 0…1 per column
let drawn = new Int16Array(0); // brightness step last drawn per column, -1 for none
let palette: string[] = [];
let halos: string[] = []; // the glow around a dot, per brightness step
const start: Edge = { x: 0, v: 0, t: 0 };
const end: Edge = { x: 0, v: 0, t: 0 };
let placed = false; // the run has had a first target; until then it lands without travel
let configured = false; // options have arrived from React; nothing is placed before that
let ownsValueNow = false; // aria-valuenow was set here (scroll), not rendered by React
let looping = false;
let breathing = false;
// What places the run.
let scroller: HTMLElement | Window | null = null;
let track: HTMLElement | null = null;
let hovered: Element | null = null;
let focused: Element | null = null;
let raf = 0;
let last = 0;
let visible = false;
const cleanups: (() => void)[] = [];
let unbindScroll: (() => void) | null = null;
let unbindTrack: (() => void) | null = null;
function readColors() {
if (!root) return;
const css = getComputedStyle(root);
const ink = readColor(css.color);
const accent = readColor(css.getPropertyValue("--dot-rail-accent").trim() || RAIL.accent);
const top = RAIL.levels - 1;
palette = Array.from({ length: RAIL.levels }, (_, i) => {
const g = i / top;
const mix = (a: number, b: number) => Math.round(a + (b - a) * g);
const alpha = ink[3] * RAIL.base * (1 - g) + accent[3] * g;
return `rgba(${mix(ink[0], accent[0])}, ${mix(ink[1], accent[1])}, ${mix(ink[2], accent[2])}, ${alpha.toFixed(3)})`;
});
halos = Array.from({ length: RAIL.levels }, (_, i) => {
const g = i / top;
return `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${(accent[3] * RAIL.halo.alpha * g * g).toFixed(3)})`;
});
drawn.fill(-1);
}
function layout() {
if (!canvas || !ctx) return;
dpr = Math.min(window.devicePixelRatio || 1, RAIL.maxDpr);
width = canvas.clientWidth;
height = canvas.clientHeight;
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
const pitch = options.pitch;
cols = Math.max(1, Math.floor(width / pitch));
ox = (width - cols * pitch) / 2 + pitch / 2;
glow = new Float32Array(cols);
drawn = new Int16Array(cols).fill(-1);
ctx.clearRect(0, 0, canvas.width, canvas.height);
// A new width moves every target: land on them without travel, lit already.
placed = false;
resolve();
wake();
}
const lit = (c: number) => c + 0.5 >= start.x && c + 0.5 < end.x;
/** Left and right edge of an element, in column-edge units. */
function span(el: Element): [number, number] {
if (!canvas) return [0, 0];
const box = canvas.getBoundingClientRect();
const r = el.getBoundingClientRect();
const u = (x: number) => (x - box.left - ox) / options.pitch + 0.5;
let a = u(r.left);
let b = u(r.right);
if (b - a < 1) {
const mid = (a + b) / 2;
a = mid - 0.5;
b = mid + 0.5;
}
return [a, b];
}
function progress() {
if (!scroller) return 0;
if (scroller instanceof Window) {
const max = document.documentElement.scrollHeight - window.innerHeight;
return max > 0 ? clamp01(window.scrollY / max) : 0;
}
const max = scroller.scrollHeight - scroller.clientHeight;
return max > 0 ? clamp01(scroller.scrollTop / max) : 0;
}
/** A run of `length` dots at p along the rail: 0 sits at the left end, 1 at the right. */
function at(p: number): [number, number] {
const len = Math.min(options.length, cols);
const a = clamp01(p) * (cols - len);
return [a, a + len];
}
function aim(a: number, b: number) {
start.t = a;
end.t = b;
if (!placed || options.reduced) {
// Land without travel, already lit, so the first frame is the rest state.
const landing = !placed;
start.x = a;
end.x = b;
start.v = end.v = 0;
placed = true;
if (landing) for (let c = 0; c < cols; c++) glow[c] = lit(c) ? 1 : 0;
}
}
/** Work out where the run belongs now, from the highest-priority source that has an answer. */
function resolve() {
if (!root || !cols || !configured) return;
looping = breathing = false;
// The sources bind in effects after the options land; don't place the run
// for a source that isn't there yet, or it would travel in from nowhere.
if (options.tracking && !track) return;
if (options.follow === "scroll" && !scroller) return;
const active = track?.querySelector(ACTIVE);
const current = active ? (active.closest(ITEM) ?? active.querySelector(ITEM) ?? active) : null;
const item = hovered ?? focused ?? current;
if (item && track?.contains(item)) {
const [a, b] = span(item);
// A timer counts only under the current item; one pointed at shows whole.
const share = item === current && item !== hovered && item !== focused ? clamp01(options.fill) : 1;
aim(a, a + (b - a) * share);
} else if (options.follow === "scroll") {
const p = progress();
root.setAttribute("aria-valuenow", String(Math.round(p * 100)));
ownsValueNow = true;
const [a, b] = at(p);
aim(a, b);
} else if (options.follow === "loop" || (options.follow === undefined && options.value === undefined && !options.tracking)) {
if (options.reduced) {
breathing = true;
aim(...at(0.5));
} else {
// Carry on from wherever the run is, or come in from the left on a first run.
looping = true;
if (!placed) aim(-options.length, 0);
end.t = start.t + Math.min(options.length, cols);
}
} else if (typeof options.value === "number") {
aim(...at(options.value));
} else if (Array.isArray(options.value)) {
const lo = clamp01(Math.min(options.value[0], options.value[1]));
const hi = clamp01(Math.max(options.value[0], options.value[1]));
aim(lo * cols, hi * cols);
} else if (placed) {
// Nothing current: the run closes up where it stands.
const mid = (start.x + end.x) / 2;
aim(mid, mid);
} else {
// Dark from the start, without counting as placed, so the first real
// target lands where it is instead of growing out of the middle.
start.x = end.x = start.t = end.t = cols / 2;
}
if (options.follow !== "scroll" && ownsValueNow) {
root.removeAttribute("aria-valuenow");
ownsValueNow = false;
}
wake();
}
function drawColumn(c: number, level: number) {
if (!ctx) return;
const p = options.pitch * dpr;
const x = (ox + c * options.pitch) * dpr;
const r = RAIL.radius * p;
ctx.clearRect(x - p / 2, 0, p, height * dpr);
if (level > 0) {
// The glow fills the dot's own cell and no more, so redrawing one column never clips a neighbor's.
const halo = RAIL.halo.radius * p;
ctx.fillStyle = halos[level];
ctx.beginPath();
for (let row = 0; row < options.rows; row++) {
const y = (row + 0.5) * p;
ctx.moveTo(x + halo, y);
ctx.arc(x, y, halo, 0, Math.PI * 2);
}
ctx.fill();
}
ctx.fillStyle = palette[level];
ctx.beginPath();
for (let row = 0; row < options.rows; row++) {
const y = (row + 0.5) * p;
ctx.moveTo(x + r, y);
ctx.arc(x, y, r, 0, Math.PI * 2);
}
ctx.fill();
}
function frame(time: number) {
raf = 0;
if (!ctx || !palette.length) return;
const dt = last ? Math.min(TIMING.maxFrame, (time - last) / 1000) : 1 / 60;
last = time;
let busy = looping || breathing;
if (looping) {
const len = Math.min(options.length, cols);
start.t += TIMING.loop * dt;
end.t = start.t + len;
// Wrap once the run is fully off the right end, moving targets and
// springs together so nothing sweeps back across.
if (start.x > cols) {
const shift = cols + len;
start.x -= shift;
end.x -= shift;
start.t -= shift;
end.t -= shift;
}
}
const spring = (grows: boolean) => (grows ? options.stiffness : options.followStiffness);
if (stepEdge(start, dt, spring(start.t < start.x))) busy = true;
if (stepEdge(end, dt, spring(end.t > end.x))) busy = true;
const top = RAIL.levels - 1;
const fade = options.reduced || options.trail <= 0 ? 0 : Math.exp(-dt / (options.trail / 1000 / 3));
const peak = breathing
? RAIL.breathe.low + (1 - RAIL.breathe.low) * (0.5 + 0.5 * Math.cos((2 * Math.PI * time) / 1000 / TIMING.breathe))
: 1;
for (let c = 0; c < cols; c++) {
let g = glow[c];
if (lit(c)) {
if (g < 1) {
g = options.reduced ? 1 : Math.min(1, g + dt / TIMING.rise);
busy = true;
}
} else if (g > 0) {
g *= fade;
if (g < RAIL.floor) g = 0;
busy = true;
}
glow[c] = g;
const level = Math.round((lit(c) ? g * peak : g) * top);
if (level !== drawn[c]) {
drawColumn(c, level);
drawn[c] = level;
}
}
if (busy && visible && !document.hidden) raf = requestAnimationFrame(frame);
else last = 0;
}
function wake() {
if (raf || !visible || !ctx || document.hidden) return;
raf = requestAnimationFrame(frame);
}
return {
connect(el: { root: HTMLElement; canvas: HTMLCanvasElement }) {
root = el.root;
canvas = el.canvas;
ctx = canvas.getContext("2d");
readColors();
layout();
const resize = new ResizeObserver(() => {
if (!canvas) return;
if (canvas.clientWidth === width && canvas.clientHeight === height) return;
layout();
});
resize.observe(canvas);
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) wake();
});
io.observe(canvas);
const scheme = window.matchMedia("(prefers-color-scheme: dark)");
const recolor = () => {
readColors();
wake();
};
scheme.addEventListener("change", recolor);
const themeAttrs = new MutationObserver(recolor);
themeAttrs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme"] });
const pageVisibility = () => {
if (!document.hidden) wake();
};
document.addEventListener("visibilitychange", pageVisibility);
cleanups.push(
() => resize.disconnect(),
() => io.disconnect(),
() => scheme.removeEventListener("change", recolor),
() => themeAttrs.disconnect(),
() => document.removeEventListener("visibilitychange", pageVisibility),
);
return () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
visible = false;
cleanups.splice(0).forEach((fn) => fn());
root = canvas = null;
ctx = null;
};
},
setOptions(next: Options) {
const prev = options;
options = next;
if (!ctx) return;
if (!configured) {
configured = true;
layout();
return;
}
if (next.rows !== prev.rows || next.pitch !== prev.pitch) {
layout();
return;
}
if (next.reduced && !prev.reduced) {
// Settle at once: no travel, no tail.
start.x = start.t;
end.x = end.t;
start.v = end.v = 0;
}
resolve();
},
bindScroll(target: HTMLElement | Window | null) {
unbindScroll?.();
unbindScroll = null;
scroller = target;
if (target) {
const onScroll = () => resolve();
target.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
unbindScroll = () => {
target.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}
resolve();
},
bindTrack(el: HTMLElement | null) {
unbindTrack?.();
unbindTrack = null;
track = el;
hovered = focused = null;
if (el) {
const itemOf = (target: EventTarget | null) => {
const item = target instanceof Element ? target.closest(ITEM) : null;
return item && el.contains(item) ? item : null;
};
const over = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
const item = itemOf(e.target);
if (item && item !== hovered) {
hovered = item;
resolve();
}
};
const leave = () => {
if (!hovered) return;
hovered = null;
resolve();
};
const focusIn = (e: FocusEvent) => {
const item = itemOf(e.target);
// Keyboard focus only: a click focuses a link too, and the run should
// go back to the current item, not stay where the mouse clicked.
focused = item && item.matches(":focus-visible") ? item : null;
resolve();
};
const focusOut = (e: FocusEvent) => {
if (e.relatedTarget instanceof Node && el.contains(e.relatedTarget)) return;
focused = null;
resolve();
};
el.addEventListener("pointerover", over);
el.addEventListener("pointerleave", leave);
el.addEventListener("focusin", focusIn);
el.addEventListener("focusout", focusOut);
// The current item can change, and items move when the nav reflows or a
// web font lands.
const sizes = new ResizeObserver(() => resolve());
const observeItems = () => {
sizes.disconnect();
sizes.observe(el);
el.querySelectorAll(ITEM).forEach((item) => sizes.observe(item));
};
observeItems();
const changes = new MutationObserver((records) => {
if (records.some((r) => r.type === "childList")) observeItems();
resolve();
});
changes.observe(el, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["aria-current", "data-active", "aria-selected"],
});
unbindTrack = () => {
el.removeEventListener("pointerover", over);
el.removeEventListener("pointerleave", leave);
el.removeEventListener("focusin", focusIn);
el.removeEventListener("focusout", focusOut);
sizes.disconnect();
changes.disconnect();
};
}
resolve();
},
disconnectSources() {
unbindScroll?.();
unbindTrack?.();
unbindScroll = unbindTrack = null;
scroller = null;
track = hovered = focused = null;
},
};
}
type Rail = ReturnType<typeof createRail>;
export interface DotRailProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/** Where the run sits: a number from 0 (the left end) to 1 (the right end), or a `[from, to]` range to light, such as `[0, 1]` for the whole rail. `follow` and a hovered `trackRef` item take precedence. */
value?: number | [number, number];
/** Let the run place itself: `"scroll"` sits it at the scroll progress of the page (or `containerRef`), `"loop"` sends it round the rail on its own, like a loader. With no `follow`, `value` or `trackRef`, it loops. */
follow?: Follow;
/** The scrolling element whose progress `follow="scroll"` reads. Defaults to the page. */
containerRef?: React.RefObject<HTMLElement | null>;
/** A nav, tab list or menu to follow: the run jumps under whichever of its links or buttons is hovered or focused, as wide as it is, and goes back to the one marked `aria-current` (or `data-active`, or `aria-selected`) when the pointer leaves. */
trackRef?: React.RefObject<HTMLElement | null>;
/** With `trackRef`, how much of the current item is lit, from its left edge, from 0 to 1. Drive it from a timer and the dots under an autoplaying tab count up one by one. */
fill?: number;
/** Rows of dots in the band. */
rows?: number;
/** Distance between dot centers, in px. The rail is `rows × pitch` tall. */
pitch?: number;
/** How many dots wide the run is when it's a position (a number `value`, scroll or loop). */
length?: number;
/** How long, in ms, a dot keeps glowing after the run leaves it. `0` turns the tail off. */
trail?: number;
/** Spring stiffness of the end that leads the run when it moves. Higher is snappier. */
stiffness?: number;
/** Spring stiffness of the end that catches up behind it. Lower than `stiffness` and the run stretches toward where it's going. */
followStiffness?: number;
}
export function DotRail({
value,
follow,
containerRef,
trackRef,
fill = 1,
rows = 3,
pitch = 6,
length = 6,
trail = TIMING.trail,
stiffness = TIMING.stiffness,
followStiffness = TIMING.followStiffness,
className,
style,
...props
}: DotRailProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [rail] = React.useState<Rail>(createRail);
const reduced = useReducedMotion() ?? false;
React.useLayoutEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const disconnect = rail.connect({ root, canvas });
return () => {
rail.disconnectSources();
disconnect();
};
}, [rail]);
// A range arrives as a new array each render; compare its ends instead.
const from = Array.isArray(value) ? value[0] : value;
const to = Array.isArray(value) ? value[1] : undefined;
React.useEffect(() => {
rail.setOptions({
value: from === undefined ? undefined : to === undefined ? from : [from, to],
fill,
follow,
tracking: trackRef !== undefined,
rows: Math.max(1, Math.round(rows)),
pitch: Math.max(2, pitch),
length: Math.max(1, Math.round(length)),
trail: Math.max(0, trail),
stiffness: Math.max(1, stiffness),
followStiffness: Math.max(1, followStiffness),
reduced,
});
}, [rail, from, to, fill, follow, trackRef, rows, pitch, length, trail, stiffness, followStiffness, reduced]);
// Refs are attached by the time effects run, including a parent's.
React.useEffect(() => {
rail.bindScroll(follow === "scroll" ? (containerRef?.current ?? window) : null);
}, [rail, follow, containerRef]);
React.useEffect(() => {
rail.bindTrack(trackRef?.current ?? null);
}, [rail, trackRef]);
const position = follow === "scroll" || (follow === undefined && typeof value === "number");
return (
<div
ref={rootRef}
role={position ? "progressbar" : undefined}
aria-label={position ? (follow === "scroll" ? "Scroll progress" : "Progress") : undefined}
aria-valuemin={position ? 0 : undefined}
aria-valuemax={position ? 100 : undefined}
aria-valuenow={follow === undefined && typeof value === "number" ? Math.round(clamp01(value) * 100) : undefined}
className={cn("relative w-full", className)}
style={{ height: Math.max(1, Math.round(rows)) * Math.max(2, pitch), ...style }}
{...props}
>
<canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 size-full" />
</div>
);
}