← All primitives
ScrollScene
3DA sticky R3F canvas whose camera dollies across scroll 'acts', driven by a single scrubbed ScrollTrigger that reads your existing Lenis-smoothed scroll — no competing scroll root.
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-scene.jsonDependencies:three@react-three/fibergsap
View raw manifest →components/prism/ScrollScene.tsx
"use client";
import { useRef, useLayoutEffect } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
/**
* ScrollScene — a sticky R3F canvas whose camera dollies/pans across scroll "acts".
* Driven by a SINGLE scrubbed GSAP ScrollTrigger that reads the same Lenis-smoothed
* scroll position the rest of Prism uses (SmoothScroll pumps Lenis via gsap.ticker +
* ScrollTrigger.update), so there's no second scroll root to fight — unlike drei's
* ScrollControls. Progress lives in a ref so useFrame reads it with zero re-renders.
*/
const ACTS: { pos: [number, number, number]; look: [number, number, number] }[] = [
{ pos: [0, 0, 6], look: [0, 0, 0] },
{ pos: [4, 1.5, 3], look: [0, 0, 0] },
{ pos: [-3, -1, 4.5], look: [1, 0, 0] },
];
function Rig({ progress }: { progress: React.RefObject<number> }) {
const { camera } = useThree();
const pos = useRef(new THREE.Vector3());
const look = useRef(new THREE.Vector3());
const tmp = useRef(new THREE.Vector3());
useFrame(() => {
const p = THREE.MathUtils.clamp(progress.current, 0, 1);
const seg = p * (ACTS.length - 1);
const i = Math.min(Math.floor(seg), ACTS.length - 2);
const t = THREE.MathUtils.smoothstep(seg - i, 0, 1);
pos.current.fromArray(ACTS[i].pos).lerp(tmp.current.fromArray(ACTS[i + 1].pos), t);
look.current.fromArray(ACTS[i].look).lerp(tmp.current.fromArray(ACTS[i + 1].look), t);
camera.position.lerp(pos.current, 0.12);
camera.lookAt(look.current);
});
return null;
}
function Acts() {
return (
<>
<ambientLight intensity={0.45} />
<directionalLight position={[5, 5, 5]} intensity={1.3} />
<mesh position={[0, 0, 0]}>
<icosahedronGeometry args={[1, 0]} />
<meshStandardMaterial color="#5b7cff" roughness={0.2} metalness={0.6} />
</mesh>
<mesh position={[2, 1, -1]}>
<torusKnotGeometry args={[0.5, 0.18, 128, 16]} />
<meshStandardMaterial color="#1fd4e6" roughness={0.3} metalness={0.4} />
</mesh>
<mesh position={[-2, -1, 0.5]}>
<boxGeometry args={[0.9, 0.9, 0.9]} />
<meshStandardMaterial color="#ff3d81" roughness={0.4} metalness={0.3} />
</mesh>
</>
);
}
export function ScrollScene({ acts = 3 }: { acts?: number }) {
const wrap = useRef<HTMLDivElement>(null);
const progress = useRef(0);
useLayoutEffect(() => {
const el = wrap.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const st = ScrollTrigger.create({
trigger: el,
start: "top top",
end: "bottom bottom",
scrub: reduce ? false : true,
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={[1, 1.5]}
gl={{ antialias: true, powerPreference: "high-performance" }}
camera={{ position: [0, 0, 6], fov: 50 }}
>
<Rig progress={progress} />
<Acts />
</Canvas>
</div>
</div>
);
}