Prism
← All primitives

ScrollScene

3D

A 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.json
Dependencies: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>
  );
}
Live demo — read-only. Every section is a real, copyable primitive.