← All primitives
ScrollDive
3DScroll into the page, not down it — a sticky canvas where scroll flies the camera forward through a tunnel of glowing rings. Reuses your Lenis-smoothed scroll via one scrubbed ScrollTrigger.
Pairs with a Lenis + gsap.ticker smooth-scroll setup (see SmoothScroll). Render via next/dynamic ssr:false.
$
npx shadcn@latest add https://prism.icglabs.co/r/scroll-dive.jsonDependencies:three@react-three/fiber@react-three/postprocessinggsap
View raw manifest →components/prism/ScrollDive.tsx
"use client";
import { useMemo, useRef, useState, useLayoutEffect } from "react";
import { useDeviceTier } from "@/lib/useDeviceTier";
import { AutoDpr, PauseWhenOffscreen } from "@/components/showcase/CanvasPerf";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { EffectComposer, Bloom } from "@react-three/postprocessing";
import * as THREE from "three";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
/**
* ScrollDive — scroll *into* the page, not down it. A sticky canvas where scroll
* flies the camera FORWARD through a tunnel of glowing rings. Driven by one
* scrubbed ScrollTrigger reading the same Lenis-smoothed scroll the site uses.
* Honors prefers-reduced-motion (static composed shot).
*/
const PALETTE = ["#8b5cff", "#5b7cff", "#1fd4e6", "#ff3d81", "#ffb24d", "#9bf25a"];
const RING_COUNT = 30;
const SPACING = 7;
const DEPTH = RING_COUNT * SPACING;
function Rings() {
const rings = useMemo(
() =>
Array.from({ length: RING_COUNT }, (_, i) => ({
z: -i * SPACING - 6,
x: (Math.random() - 0.5) * 2.6,
y: (Math.random() - 0.5) * 2.6,
rot: Math.random() * Math.PI,
color: PALETTE[i % PALETTE.length],
r: 2.3 + Math.random() * 1.5,
})),
[]
);
return (
<>
{rings.map((rg, i) => (
<mesh key={i} position={[rg.x, rg.y, rg.z]} rotation={[0, 0, rg.rot]}>
<torusGeometry args={[rg.r, 0.06, 16, 80]} />
<meshStandardMaterial
color={rg.color}
emissive={rg.color}
emissiveIntensity={1.3}
roughness={0.4}
toneMapped={false}
/>
</mesh>
))}
</>
);
}
function Diver({ progress, reduce }: { progress: React.RefObject<number>; reduce: boolean }) {
const camera = useThree((s) => s.camera);
useFrame(() => {
const p = reduce ? 0.02 : THREE.MathUtils.clamp(progress.current, 0, 1);
const z = 2 - p * (DEPTH - 12);
// gentle banking sway so the flight feels piloted, not on rails
const sx = Math.sin(p * 6.0) * 0.7;
const sy = Math.cos(p * 5.0) * 0.5;
camera.position.set(sx, sy, z);
camera.lookAt(Math.sin((p + 0.04) * 6.0) * 0.7, Math.cos((p + 0.04) * 5.0) * 0.5, z - 10);
});
return null;
}
export function ScrollDive({ acts = 4 }: { acts?: number }) {
const wrap = useRef<HTMLDivElement>(null);
const progress = useRef(0);
const reduceRef = useRef(false);
const q = useDeviceTier();
const [low, setLow] = useState(false);
const bloomOn = q.bloom && !low;
useLayoutEffect(() => {
const el = wrap.current;
if (!el) return;
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const st = ScrollTrigger.create({
trigger: el,
start: "top top",
end: "bottom bottom",
scrub: reduceRef.current ? false : 0.6,
invalidateOnRefresh: true,
onUpdate: (self) => {
progress.current = self.progress;
},
});
return () => st.kill();
}, []);
return (
<div ref={wrap} style={{ height: `${acts * 100}vh`, position: "relative" }}>
<div style={{ position: "sticky", top: 0, height: "100vh", width: "100%" }}>
<Canvas
dpr={q.dpr}
gl={{ antialias: false, powerPreference: "high-performance" }}
camera={{ position: [0, 0, 2], fov: 62, near: 0.1, far: 240 }}
>
<color attach="background" args={["#07070c"]} />
<fog attach="fog" args={["#07070c", 6, 70]} />
<ambientLight intensity={0.4} />
<Rings />
<Diver progress={progress} reduce={reduceRef.current} />
<AutoDpr onLow={() => setLow(true)} />
<PauseWhenOffscreen />
{bloomOn && (
<EffectComposer>
<Bloom intensity={1.1} luminanceThreshold={0.2} luminanceSmoothing={0.3} mipmapBlur radius={0.7} />
</EffectComposer>
)}
</Canvas>
</div>
</div>
);
}