Scroll scrubs a camera through a monochrome point tunnel — velocity stretches dots into motion streaks, cursor drift adds parallax, and mono section labels at fixed depths snap into focus as you fly past.
npx shadcn add https://design.helpmarq.com /r/scroll-particle-tunnel.jsonregistry/core/scroll-particle-tunnel/component.tsx"use client";
import { useEffect, useRef, useState } from "react";
// world-space constants
const DEPTH = 4000; // tunnel length in world units
const TRAVEL = 3600; // camera travel across the full scrub
const FOCAL = 600; // perspective focal length: scale = FOCAL / (z - camZ)
const R_MIN = 120; // cylinder shell inner radius
const R_MAX = 900; // cylinder shell outer radius
const NEAR_CLIP = 24; // cull points closer than this to the camera
// streak-factor spring: k = 60, zeta = 0.8 (relaxes lines back to dots)
const SPRING_K = 60;
const SPRING_C = 2 * 0.8 * Math.sqrt(SPRING_K);
// monochrome depth ramp: alpha rises 0.3 -> 1.0 with proximity, color pinned
// to the live --foreground token so contrast against the page background is
// correct in both themes (near = full foreground opacity, far = faded toward bg).
function buildShades(rgb: { r: number; g: number; b: number }) {
return Array.from({ length: 24 }, (_, i) => {
const t = i / 23;
return `rgba(${rgb.r},${rgb.g},${rgb.b},${(0.3 + 0.7 * t).toFixed(3)})`;
});
}
function parseColor(raw: string): { r: number; g: number; b: number } | null {
const v = raw.trim();
if (v.startsWith("#")) {
const hex = v.slice(1);
if (hex.length === 3) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
return { r, g, b };
}
if (hex.length >= 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
return { r, g, b };
}
return null;
}
const m = v.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
if (m) return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };
return null;
}
function readForegroundShades() {
const raw = getComputedStyle(document.documentElement).getPropertyValue("--foreground");
return buildShades(parseColor(raw) ?? { r: 143, g: 143, b: 143 });
}
// deterministic PRNG so the field is identical across mounts and screenshots
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// seed points in a cylinder shell (uniform over the annulus area)
function seedPoints(count: number) {
const rand = mulberry32(0x517cc1b7);
const px = new Float32Array(count);
const py = new Float32Array(count);
const pz = new Float32Array(count);
for (let i = 0; i < count; i++) {
const theta = rand() * Math.PI * 2;
const r = Math.sqrt(R_MIN * R_MIN + (R_MAX * R_MAX - R_MIN * R_MIN) * rand());
px[i] = Math.cos(theta) * r;
py[i] = Math.sin(theta) * r;
pz[i] = rand() * DEPTH;
}
return { px, py, pz };
}
function labelDepthAt(index: number, count: number) {
return count <= 1 ? DEPTH / 2 : 700 + (index * 2700) / (count - 1);
}
// Scroll scrubs a camera through a monochrome point tunnel. Scroll velocity
// stretches dots into motion streaks, cursor drift adds parallax, and mono
// labels sit at fixed world depths, snapping into focus as the camera passes.
// Direct-DOM rAF loop — no React state on the hot path; sleeps when settled.
export function ParticleTunnelScrub({
labels = ["01 SIGNAL", "02 NOISE", "03 FIELD", "04 VOID"],
pointCount = 3000,
className = "",
}: {
/** section labels pinned at fixed depths along the tunnel */
labels?: string[];
/** points seeded in the cylinder shell */
pointCount?: number;
className?: string;
}) {
const sectionRef = useRef<HTMLElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
// full motion path
useEffect(() => {
if (reduced) return;
const section = sectionRef.current;
const canvas = canvasRef.current;
if (!section || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { px, py, pz } = seedPoints(pointCount);
const prevX = new Float32Array(pointCount);
const prevY = new Float32Array(pointCount);
const dirX = new Float32Array(pointCount);
const dirY = new Float32Array(pointCount);
let hasPrev = false;
let shades = readForegroundShades();
const labelCount = labels.length;
const labelDepths = labels.map((_, i) => labelDepthAt(i, labelCount));
let w = 0;
let h = 0;
let p = 0; // scroll progress 0..1
let lastCamZ = 0;
let vel = 0; // EMA of camZ delta per 60fps frame
let streak = 0; // spring-smoothed streak length (px)
let streakV = 0;
let camX = 0;
let camY = 0;
let tX = 0; // parallax targets (world units)
let tY = 0;
let raf = 0;
let lastT = 0;
let dirty = true;
const frame = (now: number) => {
const dt = Math.min(0.05, lastT ? (now - lastT) / 1000 : 1 / 60);
lastT = now;
const frames = dt * 60;
dirty = false;
// camera scrub + scroll-velocity EMA (alpha = 0.12)
const camZ = p * TRAVEL;
const inst = frames > 0 ? (camZ - lastCamZ) / frames : 0;
lastCamZ = camZ;
vel += 0.12 * (inst - vel);
// streak spring toward clamp(|v| * 0.06, 0, 40) px
const target = Math.min(40, Math.abs(vel) * 0.06);
streakV += (SPRING_K * (target - streak) - SPRING_C * streakV) * dt;
streak += streakV * dt;
if (streak < 0) streak = 0;
// cursor parallax: lerp 0.08/frame toward pointer offset
const k = 1 - Math.pow(1 - 0.08, frames);
camX += (tX - camX) * k;
camY += (tY - camY) * k;
ctx.clearRect(0, 0, w, h);
const cx = w / 2;
const cy = h / 2;
const drawStreaks = streak > 0.5;
for (let i = 0; i < pointCount; i++) {
const dz = pz[i] - camZ;
if (dz < NEAR_CLIP) continue; // behind or grazing the camera
const s = FOCAL / dz;
const sx = cx + (px[i] - camX) * s;
const sy = cy + (py[i] - camY) * s;
if (sx < -60 || sx > w + 60 || sy < -60 || sy > h + 60) {
prevX[i] = sx;
prevY[i] = sy;
continue;
}
if (hasPrev) {
const mx = sx - prevX[i];
const my = sy - prevY[i];
const md = Math.hypot(mx, my);
if (md > 0.3) {
dirX[i] = mx / md;
dirY[i] = my / md;
}
}
prevX[i] = sx;
prevY[i] = sy;
const near = Math.min(1, 420 / dz);
const shade = shades[Math.min(23, (near * 23) | 0)] ?? "#8f8f8f";
const size = Math.min(3.2, Math.max(0.6, s * 1.5));
if (drawStreaks && (dirX[i] !== 0 || dirY[i] !== 0)) {
ctx.strokeStyle = shade;
ctx.lineWidth = size;
ctx.beginPath();
ctx.moveTo(sx - dirX[i] * streak, sy - dirY[i] * streak);
ctx.lineTo(sx, sy);
ctx.stroke();
} else {
ctx.fillStyle = shade;
ctx.fillRect(sx - size / 2, sy - size / 2, size, size);
}
}
hasPrev = true;
// labels: focus window +-300 units, fall to blur 6px over the next 900,
// scale runs 0.92 -> 1.08 through the pass so they fly by
for (let i = 0; i < labelCount; i++) {
const el = labelRefs.current[i];
if (!el) continue;
const d = (labelDepths[i] ?? 0) - camZ;
const ad = Math.abs(d);
let opacity = 0;
let blur = 6;
if (ad <= 300) {
opacity = 1;
blur = 0;
} else if (ad <= 1200) {
const t = (ad - 300) / 900;
opacity = 1 - t;
blur = 6 * t;
}
const scale = Math.min(1.08, Math.max(0.92, 1 - (d / 1200) * 0.08));
el.style.opacity = opacity.toFixed(3);
el.style.filter = blur > 0.02 ? `blur(${blur.toFixed(2)}px)` : "none";
el.style.transform = `scale(${scale.toFixed(4)})`;
}
// sleep when scroll, parallax, and the streak spring are all settled
const parallaxSettled = Math.abs(tX - camX) < 0.05 && Math.abs(tY - camY) < 0.05;
const springSettled = streak < 0.05 && Math.abs(streakV) < 0.05 && Math.abs(vel) < 0.05;
if (!dirty && parallaxSettled && springSettled) {
raf = 0;
lastT = 0;
return;
}
raf = requestAnimationFrame(frame);
};
const wake = () => {
if (!raf) raf = requestAnimationFrame(frame);
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
w = rect.width;
h = rect.height;
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.lineCap = "round";
dirty = true;
wake();
};
const readScroll = () => {
const rect = section.getBoundingClientRect();
const track = rect.height - window.innerHeight;
p = track > 0 ? Math.min(1, Math.max(0, -rect.top / track)) : 0;
dirty = true;
wake();
};
const onPointer = (e: PointerEvent) => {
tX = (e.clientX - w / 2) * 0.04;
tY = (e.clientY - h / 2) * 0.04;
wake();
};
// re-derive the shade ramp from --foreground whenever the theme class
// flips on <html>, so light/dark contrast stays correct without a remount
const onThemeChange = () => {
shades = readForegroundShades();
dirty = true;
wake();
};
const themeObserver = new MutationObserver(onThemeChange);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
resize();
readScroll();
lastCamZ = p * TRAVEL; // no phantom velocity on mount mid-page
wake();
window.addEventListener("scroll", readScroll, { passive: true });
window.addEventListener("resize", resize);
window.addEventListener("pointermove", onPointer, { passive: true });
return () => {
cancelAnimationFrame(raf);
themeObserver.disconnect();
window.removeEventListener("scroll", readScroll);
window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onPointer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduced, pointCount, labels.length]);
// reduced motion: one static starfield frame, no loop
useEffect(() => {
if (!reduced) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { px, py, pz } = seedPoints(pointCount);
let shades = readForegroundShades();
const draw = () => {
const rect = canvas.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const camZ = DEPTH * 0.3;
for (let i = 0; i < pointCount; i++) {
const dz = pz[i] - camZ;
if (dz < NEAR_CLIP) continue;
const s = FOCAL / dz;
const sx = w / 2 + px[i] * s;
const sy = h / 2 + py[i] * s;
if (sx < -10 || sx > w + 10 || sy < -10 || sy > h + 10) continue;
const near = Math.min(1, 420 / dz);
ctx.fillStyle = shades[Math.min(23, (near * 23) | 0)] ?? "#8f8f8f";
const size = Math.min(3.2, Math.max(0.6, s * 1.5));
ctx.fillRect(sx - size / 2, sy - size / 2, size, size);
}
};
draw();
const onThemeChange = () => {
shades = readForegroundShades();
draw();
};
const themeObserver = new MutationObserver(onThemeChange);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
window.addEventListener("resize", draw);
return () => {
themeObserver.disconnect();
window.removeEventListener("resize", draw);
};
}, [reduced, pointCount]);
// reduced motion: labels fade in 200ms via IntersectionObserver
useEffect(() => {
if (!reduced) return;
const els = labelRefs.current
.slice(0, labels.length)
.filter((el): el is HTMLSpanElement => el !== null);
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
(entry.target as HTMLElement).style.opacity = entry.isIntersecting ? "1" : "0";
}
},
{ threshold: 0.4 }
);
for (const el of els) {
el.style.transition = "opacity 200ms ease-out";
io.observe(el);
}
return () => io.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduced, labels.length]);
return (
<section
ref={sectionRef}
className={`relative h-[400vh] bg-background ${className}`}
>
<div className="sticky top-0 h-screen w-full overflow-hidden">
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 h-full w-full" />
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(circle at center, transparent 40%, var(--background) 96%)",
}}
/>
{!reduced &&
labels.map((label, i) => (
<div
key={`${label}-${i}`}
className="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<span
ref={(el) => {
labelRefs.current[i] = el;
}}
className="font-mono text-sm tracking-widest text-foreground will-change-[transform,opacity,filter]"
style={{ opacity: 0 }}
>
{label}
</span>
</div>
))}
</div>
{reduced && (
<div className="absolute inset-0">
{labels.map((label, i) => (
<span
key={`${label}-${i}`}
ref={(el) => {
labelRefs.current[i] = el;
}}
className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2 font-mono text-sm tracking-widest text-foreground"
style={{ top: `${((i + 0.5) * 100) / labels.length}%`, opacity: 0 }}
>
{label}
</span>
))}
</div>
)}
</section>
);
}
scroll flies a camera through a 3D particle tunnel with velocity-streaked dots and mono labels snapping into focus at fixed depths; use for a flythrough narrative, not a content-crossfade story or an instrument/HUD readout.
Build a scroll-scrubbed particle tunnel section: a 400vh wrapper with a position:sticky full-viewport Canvas 2D inside it, roughly 3000 points seeded in a cylinder shell (radius 120-900 world units, depth 0-4000) and projected manually with scale = 600/(z - camZ), culling anything behind the camera. Read scroll progress from the section's getBoundingClientRect in a passive scroll listener that writes a plain variable, map it to camZ = p*3600, and drive everything from a direct-DOM rAF loop with no React state on the hot path that sleeps once scroll, pointer parallax, and springs are all settled. Keep an EMA (alpha=0.12) of scroll velocity and stretch each point into a motion streak along its per-frame projected delta with length clamp(|v|*0.06, 0, 40)px, relaxed back to dots by a spring (k=60, zeta=0.8) when scrolling stops; add cursor parallax by lerping camera x/y at 0.08/frame toward pointer offset from viewport center times 0.04. Points are drawn from the live --foreground token (read via getComputedStyle at mount, re-derived through a MutationObserver on <html> class changes) with alpha rising 0.3 to 1.0 proportional to 1/(z - camZ), so near/far contrast stays correct in both light and dark themes instead of a fixed gray ramp tuned for one background. A theme-token radial-gradient vignette (transparent center to var(--background) at the edges) sits over the canvas to sell the tunnel depth on the static pre-scroll frame. Overlay four Geist Mono labels (text-sm tracking-widest) centered on screen at fixed world depths 700/1600/2500/3400 and write their opacity, blur, and scale directly to style each frame: fully focused within +-300 units of camZ, falling to opacity 0 and blur 6px over the next 900 units, scale running 0.92 to 1.08 through the pass so they fly by. Under prefers-reduced-motion draw one static starfield frame (also theme-aware and re-derived on theme change) and render the labels as a normal stacked list that fades in over 200ms via IntersectionObserver.