"use client";
import * as React from "react";
export interface RibbonTrailProps extends React.ComponentPropsWithoutRef<"div"> {
/** Number of ribbons. Each one is a little looser than the last. */
ribbonCount?: number;
/** Ribbon width at the head, in px. */
thickness?: number;
/** Points per ribbon. More points make longer tails. */
length?: number;
/** How tightly the ribbons follow, 0–1. */
stiffness?: number;
/** Colour of the first ribbon (any CSS colour). */
accent?: string;
/** Colour of the last ribbon; the ones between blend (any CSS colour). */
secondary?: string;
/** Drift along a slow Lissajous path while no pointer is present. */
idle?: boolean;
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored → transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
/** On light themes, very pale colours are deepened (keeping their hue) so they stay visible. */
function legible(color: RGB, ink: RGB): RGB {
if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;
return color.map((v) => Math.round(v * 0.7)) as RGB;
}
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* --------------------------------- Physics --------------------------------- */
const STEP_MS = 1000 / 60;
interface Strand {
x: Float32Array;
y: Float32Array;
vx: Float32Array;
vy: Float32Array;
/** Head spring stiffness / damping and follower stiffness for this strand. */
head: number;
headDamp: number;
follow: number;
/** Fixed offset of this strand's head around the target, so ribbons don't sit on top of each other. */
ox: number;
oy: number;
/** Twist phase: where along the ribbon it turns edge-on. */
twist: number;
/** Gradient stops: head, middle, tail. */
color: string;
mid: string;
fade: string;
}
/**
* Silky ribbons chasing the pointer. Each ribbon is a chain of springs: the head springs toward
* the pointer, every following point springs toward the one before it, and each ribbon gets a
* different stiffness so they fan out and overtake each other. Ribbons are drawn as tapered,
* gently twisting curves with a head-to-tail gradient. With no pointer they drift along a slow
* Lissajous path. Physics runs at a fixed 60Hz step, so the motion is identical at any refresh rate.
*/
export function RibbonTrail({
ribbonCount = 5,
thickness = 18,
length = 34,
stiffness = 0.5,
accent = "#FF7448",
secondary = "#B6F3D1",
idle = true,
className = "",
children,
...props
}: RibbonTrailProps) {
const reduceMotion = usePrefersReducedMotion();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const count = Math.max(1, Math.min(12, Math.round(ribbonCount)));
const points = Math.max(6, Math.min(120, Math.round(length)));
const width0 = Math.max(1, thickness);
const stiff = Math.min(1, Math.max(0, stiffness));
const segment = 9 + width0 * 0.2; // max link length in px
const rest = segment * 0.8; // resting link length in px
let width = 0;
let height = 0;
let dpr = 1;
let raf = 0;
let last = 0;
let pending = 0;
let moving = true;
let inView = false;
let clock = 0; // simulation time, in steps
let strands: Strand[] = [];
const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, down: false };
/** The idle path: a slow Lissajous figure around the centre. `t` is in steps. */
const lissajous = (t: number): [number, number] => {
const s = t / 60;
return [
width / 2 + Math.sin(s * 0.53 + 0.6) * width * 0.32 + Math.sin(s * 1.37) * width * 0.04,
height / 2 + Math.sin(s * 0.81) * height * 0.28 + Math.cos(s * 1.71) * height * 0.04,
];
};
const build = () => {
const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
const a = legible(resolveColor(accent, [255, 116, 72]), ink);
const b = legible(resolveColor(secondary, [182, 243, 209]), ink);
const prev = strands;
const fresh: boolean[] = [];
strands = Array.from({ length: count }, (_, s): Strand => {
const u = count === 1 ? 0 : s / (count - 1);
const c = a.map((v, k) => Math.round(v + (b[k] - v) * u));
const angle = s * 2.39996; // golden angle spreads the head offsets evenly
const r = (10 + width0 * 1.4) * Math.sqrt(u);
const old: Strand | undefined = prev[s];
const keep = old !== undefined && old.x.length === points;
fresh[s] = !keep;
return {
x: keep ? old.x : new Float32Array(points),
y: keep ? old.y : new Float32Array(points),
vx: keep ? old.vx : new Float32Array(points),
vy: keep ? old.vy : new Float32Array(points),
head: (0.028 + stiff * 0.07) * (1 - u * 0.55),
headDamp: 0.86 - u * 0.05,
follow: (0.3 + stiff * 0.25) * (1 - u * 0.3),
ox: Math.cos(angle) * r,
oy: Math.sin(angle) * r,
twist: s * 1.3,
color: `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.92)`,
mid: `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.55)`,
fade: `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0)`,
};
});
// New chains start laid out along the idle path (walking back in time, one link length per
// point), so the first frame — and the reduced-motion still — is already a full composition.
strands.forEach((st, s) => {
if (!fresh[s]) return;
let t = clock - s * 9;
let [px, py] = lissajous(t);
st.x[0] = px + st.ox;
st.y[0] = py + st.oy;
for (let i = 1, guard = 0; i < points; i++) {
let walked = 0;
while (walked < rest && guard++ < 40000) {
t -= 0.5;
const [nx, ny] = lissajous(t);
walked += Math.hypot(nx - px, ny - py);
px = nx;
py = ny;
}
st.x[i] = px + st.ox;
st.y[i] = py + st.oy;
}
});
};
/** One fixed 60Hz step. Returns true while anything is still moving. */
const step = () => {
clock++;
const following = ptr.inside || ptr.down;
let tx: number;
let ty: number;
let driven = following;
if (following) {
tx = ptr.x;
ty = ptr.y;
} else if (idle) {
[tx, ty] = lissajous(clock);
driven = true;
} else {
// No pointer and no idle drift: settle where the pointer was last.
tx = ptr.x || width / 2;
ty = ptr.y || height / 2;
}
let active = driven;
strands.forEach((st, s) => {
// While drifting, each ribbon chases a point a little further back along the idle path.
if (!following && idle) [tx, ty] = lissajous(clock - s * 9);
// Head: spring toward the target (plus this strand's offset, breathing slowly).
const breathe = 1 + Math.sin(clock * 0.02 + st.twist) * 0.25;
st.vx[0] = (st.vx[0] + (tx + st.ox * breathe - st.x[0]) * st.head) * st.headDamp;
st.vy[0] = (st.vy[0] + (ty + st.oy * breathe - st.y[0]) * st.head) * st.headDamp;
st.x[0] += st.vx[0];
st.y[0] += st.vy[0];
// Followers: each point springs toward the one ahead of it.
for (let i = 1; i < points; i++) {
// Each point springs toward a rest spot one link behind the point ahead (follow-the-leader),
// so ribbons keep their length and trace the head's path, with a little overshoot.
const lx = st.x[i] - st.x[i - 1];
const ly = st.y[i] - st.y[i - 1];
const ld = Math.hypot(lx, ly) || 1;
const rx = st.x[i - 1] + (lx / ld) * rest;
const ry = st.y[i - 1] + (ly / ld) * rest;
st.vx[i] = (st.vx[i] + (rx - st.x[i]) * st.follow) * 0.58;
st.vy[i] = (st.vy[i] + (ry - st.y[i]) * st.follow) * 0.58;
st.x[i] += st.vx[i];
st.y[i] += st.vy[i];
// Rope constraint: a link never stretches beyond `segment`, so fast moves drag the tail
// along instead of drawing long straight spans across the canvas.
const dx = st.x[i] - st.x[i - 1];
const dy = st.y[i] - st.y[i - 1];
const d = Math.hypot(dx, dy);
if (d > segment) {
st.x[i] = st.x[i - 1] + (dx / d) * segment;
st.y[i] = st.y[i - 1] + (dy / d) * segment;
st.vx[i] *= 0.6;
st.vy[i] *= 0.6;
}
}
if (!active) {
for (let i = 0; i < points; i += 3) {
if (Math.abs(st.vx[i]) + Math.abs(st.vy[i]) > 0.02) {
active = true;
break;
}
}
}
});
return active;
};
// Scratch buffers for the outline, reused every frame.
let lx = new Float32Array(points);
let ly = new Float32Array(points);
let rx = new Float32Array(points);
let ry = new Float32Array(points);
/** Builds a ribbon outline: offset both sides along the normal, tapering head → tail with a twist. */
const drawStrand = (st: Strand) => {
const n = points;
if (lx.length !== n) {
lx = new Float32Array(n);
ly = new Float32Array(n);
rx = new Float32Array(n);
ry = new Float32Array(n);
}
for (let i = 0; i < n; i++) {
const a = Math.max(0, i - 1);
const b = Math.min(n - 1, i + 1);
let nx = -(st.y[b] - st.y[a]);
let ny = st.x[b] - st.x[a];
const len = Math.hypot(nx, ny) || 1;
nx /= len;
ny /= len;
const u = i / (n - 1);
// Round-ish head, long taper to the tail, and a slow twist that narrows the ribbon edge-on.
const taper = Math.min(1, u * 9 + 0.25) * Math.pow(1 - u, 1.1);
const twist = 0.3 + 0.7 * Math.abs(Math.cos(st.twist + u * 3.2 + clock * 0.012));
const half = (width0 * taper * twist) / 2;
lx[i] = st.x[i] + nx * half;
ly[i] = st.y[i] + ny * half;
rx[i] = st.x[i] - nx * half;
ry[i] = st.y[i] - ny * half;
}
// Smooth outline: quadratic curves through midpoints on both sides.
ctx.beginPath();
ctx.moveTo(lx[0], ly[0]);
for (let i = 1; i < n - 1; i++) ctx.quadraticCurveTo(lx[i], ly[i], (lx[i] + lx[i + 1]) / 2, (ly[i] + ly[i + 1]) / 2);
ctx.lineTo(lx[n - 1], ly[n - 1]);
ctx.lineTo(rx[n - 1], ry[n - 1]);
for (let i = n - 2; i > 0; i--) ctx.quadraticCurveTo(rx[i], ry[i], (rx[i] + rx[i - 1]) / 2, (ry[i] + ry[i - 1]) / 2);
ctx.lineTo(rx[0], ry[0]);
ctx.closePath();
const grad = ctx.createLinearGradient(st.x[0], st.y[0], st.x[n - 1], st.y[n - 1]);
grad.addColorStop(0, st.color);
grad.addColorStop(0.55, st.mid);
grad.addColorStop(1, st.fade);
ctx.fillStyle = grad;
ctx.fill();
};
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
// Loosest ribbon first, so the tightest one rides on top.
for (let s = strands.length - 1; s >= 0; s--) drawStrand(strands[s]);
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : STEP_MS);
last = now;
if (ptr.inside || ptr.down) {
const rect = canvas.getBoundingClientRect();
ptr.x = ptr.clientX - rect.left;
ptr.y = ptr.clientY - rect.top;
}
// Fixed 60Hz steps. A short (120/144Hz) frame may run no step at all; it then keeps the
// previous "still moving" verdict instead of concluding the ribbons have settled.
pending = Math.min(pending + dt, STEP_MS * 4);
if (pending >= STEP_MS) moving = false;
while (pending >= STEP_MS) {
moving = step() || moving;
pending -= STEP_MS;
}
draw();
if (moving) schedule();
else last = 0;
};
const schedule = () => {
if (!raf && inView && !document.hidden && !reduceMotion) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && strands.length) return;
const first = strands.length === 0;
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
if (first) clock = 1440; // a spot on the idle path where the opening (and reduced-motion) pose sweeps across the centre
build();
draw();
moving = true;
schedule();
};
const onThemeChange = () => {
build();
if (!raf) draw();
};
const locate = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
ptr.clientX = e.clientX;
ptr.clientY = e.clientY;
ptr.x = e.clientX - rect.left;
ptr.y = e.clientY - rect.top;
return ptr.x >= 0 && ptr.y >= 0 && ptr.x <= rect.width && ptr.y <= rect.height;
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse" && !ptr.down) return;
const inside = locate(e);
if (e.pointerType === "mouse") ptr.inside = inside;
if (inside) {
moving = true;
schedule();
}
};
// Touch and pen steer the ribbons while pressed; listeners are passive, so scrolling still works.
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType === "mouse") return;
if (!locate(e)) return;
ptr.down = true;
moving = true;
schedule();
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType === "mouse") return;
ptr.down = false;
moving = true;
schedule();
};
const onPointerLeave = () => {
ptr.inside = false;
moving = true;
schedule();
};
const onVisibility = () => (document.hidden ? stop() : schedule());
const passive = { passive: true } as const;
const listen = (on: boolean) => {
if (on) {
window.addEventListener("pointermove", onPointerMove, passive);
window.addEventListener("pointerdown", onPointerDown, passive);
window.addEventListener("pointerup", onPointerUp, passive);
window.addEventListener("pointercancel", onPointerUp, passive);
document.documentElement.addEventListener("pointerleave", onPointerLeave);
} else {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
}
};
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) {
if (!reduceMotion) listen(true);
moving = true;
schedule();
} else {
listen(false);
ptr.inside = false;
ptr.down = false;
stop();
}
});
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
ro.disconnect();
io.disconnect();
unobserveTheme();
listen(false);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [ribbonCount, thickness, length, stiffness, accent, secondary, idle, reduceMotion]);
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100"
/>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}