Prism
← All primitives

ScrollDive

3D

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