"use client";
import * as React from "react";
export type SignalLinesTone = "ink" | "accent" | "gradient";
export interface SignalLinesProps extends React.ComponentPropsWithoutRef<"div"> {
/** Number of stacked lines. */
lineCount?: number;
/** Peak displacement, in px. */
amplitude?: number;
/** Waviness: roughly the number of features across the width. */
frequency?: number;
/** Animation speed multiplier. 0 freezes the waves. */
speed?: number;
/** Line stroke width, in px. */
strokeWidth?: number;
/** Each line hides the lines behind it, like a ridgeline plot. */
occlusion?: boolean;
/** ink: theme text colour · accent: all lines in `color` · gradient: ink at the back → `color` at the front. */
tone?: SignalLinesTone;
/** Accent colour (any CSS colour). */
color?: string;
/** A mouse pointer raises a localized bump in the nearest lines. */
interactive?: 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 (dark ink), very light colours are pulled toward the ink 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, i) => Math.round(v + (ink[i] - v) * 0.42)) 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);
};
}
/* ---------------------------------- Noise ---------------------------------- */
/** Compact seeded 3D simplex noise (after Stefan Gustavson). Returns values in roughly [-1, 1]. */
function createNoise3D(seed = 7) {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
let s = seed >>> 0 || 1;
for (let i = 255; i > 0; i--) {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
const j = s % (i + 1);
const t = p[i];
p[i] = p[j];
p[j] = t;
}
const perm = new Uint8Array(512);
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
const grad = [1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1];
const F3 = 1 / 3;
const G3 = 1 / 6;
const corner = (hash: number, x: number, y: number, z: number) => {
let t = 0.6 - x * x - y * y - z * z;
if (t < 0) return 0;
const g = (hash % 12) * 3;
t *= t;
return t * t * (grad[g] * x + grad[g + 1] * y + grad[g + 2] * z);
};
return (x: number, y: number, z: number) => {
const sk = (x + y + z) * F3;
const i = Math.floor(x + sk);
const j = Math.floor(y + sk);
const k = Math.floor(z + sk);
const t = (i + j + k) * G3;
const x0 = x - (i - t);
const y0 = y - (j - t);
const z0 = z - (k - t);
let i1, j1, k1, i2, j2, k2;
if (x0 >= y0) {
if (y0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 1, 0];
else if (x0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 0, 1];
else [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 1, 0, 1];
} else if (y0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 0, 1, 1];
else if (x0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 0, 1, 1];
else [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 1, 1, 0];
const ii = i & 255;
const jj = j & 255;
const kk = k & 255;
const n0 = corner(perm[ii + perm[jj + perm[kk]]], x0, y0, z0);
const n1 = corner(perm[ii + i1 + perm[jj + j1 + perm[kk + k1]]], x0 - i1 + G3, y0 - j1 + G3, z0 - k1 + G3);
const n2 = corner(perm[ii + i2 + perm[jj + j2 + perm[kk + k2]]], x0 - i2 + 2 * G3, y0 - j2 + 2 * G3, z0 - k2 + 2 * G3);
const n3 = corner(perm[ii + 1 + perm[jj + 1 + perm[kk + 1]]], x0 - 0.5, y0 - 0.5, z0 - 0.5);
return 32 * (n0 + n1 + n2 + n3);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Component -------------------------------- */
const SAMPLE_STEP = 4; // px between samples along each line
/**
* Stacked horizontal lines displaced by layered noise and sine waves — a ridgeline /
* oscilloscope look. Drawn back to front on one canvas; with occlusion on, each line erases
* what lies beneath it (destination-out), so it works on any background without knowing its
* colour. A mouse pointer raises a soft localized bump.
*/
export function SignalLines({
lineCount = 32,
amplitude = 40,
frequency = 1.5,
speed = 1,
strokeWidth = 1.25,
occlusion = true,
tone = "ink",
color = "#FF7448",
interactive = true,
className = "",
children,
...props
}: SignalLinesProps) {
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 noise = createNoise3D(5);
const lines = Math.max(2, Math.round(lineCount));
const amp = Math.max(0, amplitude);
let width = 0;
let height = 0;
let dpr = 1;
let t = 0;
let raf = 0;
let last = 0;
let inView = false;
let strokes: string[] = [];
let ys = new Float32Array(0);
const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, tx: 0, ty: 0, inside: false, strength: 0 };
const readTheme = () => {
const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
const accent = legible(resolveColor(color, [255, 116, 72]), ink);
strokes = Array.from({ length: lines }, (_, i) => {
const depth = i / (lines - 1); // 0 = back (top), 1 = front (bottom)
if (tone === "accent") return `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${(0.45 + depth * 0.5).toFixed(3)})`;
if (tone === "gradient") {
const c = ink.map((v, k) => Math.round(v + (accent[k] - v) * depth));
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${(0.4 + depth * 0.55).toFixed(3)})`;
}
return `rgba(${ink[0]}, ${ink[1]}, ${ink[2]}, ${(0.35 + depth * 0.4).toFixed(3)})`;
});
};
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
if (width < 2 || height < 2) return;
// Leave headroom above the first line for its peaks.
const top = Math.min(height * 0.45, amp * 1.3 + height * 0.06);
const bottom = height * 0.94;
const gap = (bottom - top) / (lines - 1);
const points = ys.length;
const cx = width / 2;
const spread = width * 0.3;
const scaleX = (frequency * 4) / Math.max(1, width);
const bumpSigmaX = 40 + width * 0.05;
const bumpSigmaY = Math.max(gap * 2.5, 30);
const bump = amp * 1.4 * ptr.strength;
ctx.lineWidth = strokeWidth;
ctx.lineJoin = "round";
ctx.lineCap = "round";
for (let i = 0; i < lines; i++) {
const base = top + i * gap;
const row = i * 0.37;
const dy = base - ptr.y;
const rowBump = bump > 0.05 ? bump * Math.exp(-(dy * dy) / (2 * bumpSigmaY * bumpSigmaY)) : 0;
for (let p = 0; p < points; p++) {
const x = Math.min(width, p * SAMPLE_STEP);
// A soft envelope keeps the edges calm and the centre lively.
const ex = (x - cx) / spread;
const envelope = 0.18 + 0.82 * Math.exp(-ex * ex);
const u = x * scaleX;
const n = noise(u, row, t) + 0.5 * noise(u * 2.3 + 9, row * 1.7, t * 1.3);
// Positive lobes only, normalised so peaks stay within ~1.2 × amplitude.
const ridge = Math.min(1.2, Math.max(0, n / 1.2) ** 1.4);
const ripple = 0.06 * Math.sin(u * 5 + t * 6 + i * 0.9);
let lift = amp * envelope * (ridge + ripple);
if (rowBump > 0) {
const dx = x - ptr.x;
lift += rowBump * Math.exp(-(dx * dx) / (2 * bumpSigmaX * bumpSigmaX));
}
ys[p] = base - lift;
}
ctx.beginPath();
ctx.moveTo(0, ys[0]);
for (let p = 1; p < points; p++) ctx.lineTo(Math.min(width, p * SAMPLE_STEP), ys[p]);
if (occlusion) {
// Erase everything under this line (earlier lines behind it), then stroke it.
ctx.save();
ctx.lineTo(width, height);
ctx.lineTo(0, height);
ctx.closePath();
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "#000";
ctx.fill();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, ys[0]);
for (let p = 1; p < points; p++) ctx.lineTo(Math.min(width, p * SAMPLE_STEP), ys[p]);
}
ctx.strokeStyle = strokes[i];
ctx.stroke();
}
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : 16.7);
last = now;
const f = dt / 16.7;
if (ptr.inside) {
const rect = canvas.getBoundingClientRect();
ptr.tx = ptr.clientX - rect.left;
ptr.ty = ptr.clientY - rect.top;
}
const follow = 1 - Math.pow(0.82, f);
ptr.x += (ptr.tx - ptr.x) * follow;
ptr.y += (ptr.ty - ptr.y) * follow;
const goal = ptr.inside ? 1 : 0;
ptr.strength += (goal - ptr.strength) * (1 - Math.pow(0.9, f));
t += dt * 0.00018 * speed;
draw();
const settling =
Math.abs(goal - ptr.strength) > 0.003 || (ptr.strength > 0.003 && Math.hypot(ptr.tx - ptr.x, ptr.ty - ptr.y) > 0.5);
if (speed > 0 || settling) schedule();
else last = 0;
};
const schedule = () => {
if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
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));
ys = new Float32Array(Math.ceil(width / SAMPLE_STEP) + 1);
draw();
schedule();
};
const onThemeChange = () => {
readTheme();
draw();
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse" && e.pointerType !== "pen") return;
const rect = canvas.getBoundingClientRect();
ptr.clientX = e.clientX;
ptr.clientY = e.clientY;
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
if (inside && ptr.strength < 0.01) {
// Fresh entry: the bump rises where the pointer is instead of sliding in.
ptr.x = ptr.tx = e.clientX - rect.left;
ptr.y = ptr.ty = e.clientY - rect.top;
}
const changed = inside !== ptr.inside;
ptr.inside = inside;
if (inside || changed) schedule();
};
const onPointerLeave = () => {
ptr.inside = false;
schedule();
};
const onVisibility = () => (document.hidden ? stop() : schedule());
const listenPointer = interactive && !reduceMotion;
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) {
if (listenPointer) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
document.documentElement.addEventListener("pointerleave", onPointerLeave);
}
schedule();
} else {
window.removeEventListener("pointermove", onPointerMove);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
stop();
}
});
readTheme();
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();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pointermove", onPointerMove);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
};
}, [lineCount, amplitude, frequency, speed, strokeWidth, occlusion, tone, color, interactive, 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>
);
}