All components

3D Scroll Scanner Experience

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A full-page scroll experience where a Three.js GLTF model floats and rotates as you scroll, its X-rotation mapped to scroll progress via a Lenis-fed render loop. In a pinned scanner section, a ScrollTrigger onEnter handler plays a scan sound, spins the model a full turn with GSAP, then shrinks it and the scan frame to zero; scrolling back reverses the reveal.

How it's built

Category3d-webgl
Techthree, gsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costheavy
Mobile-safedesktop-first
Scrollhijacks scrolling

scroll three.js webgl scrolltrigger gltf lenis pin product-showcase cinematic

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

3D Scroll Scanner Experience

Goal

Build a full-page, five-viewport-tall scroll story whose star is a single stylized 3D product model (a beverage-can hero prop) rendered in Three.js against a clean off-white stage. On load the model scales up from nothing and floats (a gentle sine bob) while slowly self-rotating. As you scroll, its X-rotation is driven by scroll progress through a Lenis-fed render loop, so it tumbles forward as the page advances. When you reach a pinned "scanner" section — an empty rounded rectangle frame with product-ID chip, barcode, and a red "verified" pill — a GSAP ScrollTrigger.onEnter handler plays a scanner-beep sound, spins the model a full 360°, then shrinks the model and the scanner frame to zero (the product is "scanned away"). Scrolling back up re-grows the frame and restores the model. The whole thing runs on a continuous requestAnimationFrame render loop plus two ScrollTriggers.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use three (npm), gsap (npm) with the ScrollTrigger plugin, and lenis for smooth scroll. Import exactly:

import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger);

Motion comes from three sources working together: (a) a per-frame requestAnimationFrame loop that floats/rotates the model and maps scroll→rotation, (b) two ScrollTriggers (one pin + reveal, one reset-on-scroll-back), and (c) GSAP tweens fired inside the ScrollTrigger callbacks. Lenis drives the ticker and feeds ScrollTrigger.update.

Layout / HTML

The DOM is tiny — a fixed WebGL host div plus four full-viewport sections. The renderer's <canvas> is appended into .model; everything else is HTML overlay/copy.

<body>
  <div class="model"></div>

  <section class="hero">
    <h1>Digital <br />Evolution</h1>
    <h2>Transform Your Brand Identity</h2>
    <p>Experience the next generation of digital product design. We craft
       immersive experiences that blend innovation with functionality, helping
       brands stand out in the digital landscape.</p>
  </section>

  <section class="info">
    <div class="tags">
      <p>Brand Strategy</p>
      <p>User Experience</p>
      <p>Digital Products</p>
      <p>Innovation Lab</p>
    </div>
    <h2>We believe in creating digital products that not only look exceptional
        but drive real business growth and user engagement through thoughtful
        design and strategic innovation.</h2>
    <p>Our approach combines cutting-edge technology with human-centered design
       principles. We transform complex challenges into seamless digital
       experiences that resonate with your audience and elevate your brand in
       the digital space.</p>
  </section>

  <section class="scanner">
    <div class="scan-info">
      <div class="product-id"><h2>#2024</h2></div>
      <div class="product-description"><p>Transform Your Digital Identity</p></div>
    </div>

    <div class="scan-container"></div>

    <div class="barcode"><img src="/path/barcode.png" alt="" /></div>

    <div class="purchased"><p>Innovation Verified</p></div>
  </section>

  <section class="outro">
    <h2>Join the revolution where innovative experience meets strategic thinking.
        Let's create products that don't just exist in digital space -
        they define it.</h2>
  </section>

  <script type="module" src="./script.js"></script>
</body>
  • .model — fixed full-viewport WebGL host; the <canvas> is appended here in JS. It sits behind the sections (they have z-index:2), so the 3D model is always visible centered on screen while the copy scrolls over the top of it.
  • .scanner — the pinned section. .scan-info is an absolutely-positioned top bar (product-ID chip left, description right). .scan-container is the empty 280×480 bordered frame the model appears to sit inside. .barcode is a bottom-left image; .purchased is a bottom-right red "verified" pill.
  • All copy is neutral placeholder text — invent your own; no real client brands.

Styling

Global reset * { margin:0; padding:0; box-sizing:border-box; }, and critically every element uses font-family:"Neue Haas Grotesk Display Pro" (fall back to a clean neutral grotesk like Helvetica/Arial) and text-transform:uppercase.

  • html, body: width:100vw; height:500vh;the page is five viewports tall; the extra height beyond the four sections gives the pinned scanner room to scroll.
  • img: width:100%; height:100%; object-fit:cover;
  • canvas: position:fixed; top:0; left:0;
  • h1: text-align:center; font-size:10vw; font-weight:300; line-height:100%; — huge, light hero headline.
  • h2: font-size:2.5vw; font-weight:500; line-height:100%;
  • p: font-size:12px; font-weight:500; line-height:100%; — tiny uppercase body text.
  • .model: position:fixed; width:100%; height:100vh; background:#fefdfd; — off-white full-screen stage behind the content.
  • section: position:relative; width:100vw; height:100vh; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:1em; z-index:2; — each section is exactly one viewport, contents centered.
  • .hero h1: margin-bottom:0.25em; · .hero p: width:50%; text-align:center;
  • .info: gap:8em; · .info .tags: width:60%; display:flex; gap:2em; · .info h2: width:75%; text-align:center; · .info p: width:60%; text-align:center;
  • .scan-info: position:absolute; top:0; width:100vw; display:flex; justify-content:space-between; padding:2em;
  • .scan-container: width:280px; height:480px; border:1px solid #000; border-radius:0.5em; — the empty scanner frame (no fill; the 3D model shows through from the fixed canvas behind it).
  • .barcode: position:absolute; bottom:1em; left:2em; width:200px; height:100px;
  • .purchased: position:absolute; bottom:2em; right:2em; padding:0.5em 4em; color:red; border:1px solid red; border-radius:2em; — a red-outlined pill.
  • .outro h2: width:70%; text-align:center;
  • Include the standard Lenis CSS block (.lenis.lenis-smooth{scroll-behavior:auto !important} .lenis.lenis-stopped{overflow:clip} etc.).

Palette: near-white #fefdfd stage, black text/border, one red accent on the "verified" pill. The renderer clear color is white (0xffffff) and the scene background is #fefdfd, so canvas and page blend seamlessly.

Three.js scene & render pipeline (be exhaustive)

1. Smooth scroll (Lenis) drives the GSAP ticker
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
2. Scene / camera / renderer
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xfefdfd);

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setClearColor(0xffffff, 1);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.physicallyCorrectLights = true;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 2.5;               // bright, punchy exposure for the white stage
document.querySelector(".model").appendChild(renderer.domElement);

Camera z is not set here — it is computed after the model loads from the model's bounding-box size (see §5).

3. Lighting — four sources (bright, even, product-shot lighting)
const ambientLight = new THREE.AmbientLight(0xffffff, 3);
scene.add(ambientLight);

const mainLight = new THREE.DirectionalLight(0xffffff, 1);
mainLight.position.set(5, 10, 7.5);
scene.add(mainLight);

const fillLight = new THREE.DirectionalLight(0xffffff, 3);
fillLight.position.set(-5, 0, -5);
scene.add(fillLight);

const hemiLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 2);
hemiLight.position.set(0, 25, 0);
scene.add(hemiLight);

All white lights, high intensities — the product reads clean and bright on the near-white background.

4. Bootstrap render loop (runs until the model is ready)

Before the GLTF resolves, run a minimal loop so the (empty) scene paints:

function basicAnimate() { renderer.render(scene, camera); requestAnimationFrame(basicAnimate); }
basicAnimate();

When the model finishes loading, cancelAnimationFrame(basicAnimate) and start the full animate() loop (§7) instead.

5. Load, tune, recenter and frame the GLTF model
let model;
const loader = new GLTFLoader();
loader.load("/path/product.glb", (gltf) => {
  model = gltf.scene;
  model.traverse((node) => {
    if (node.isMesh && node.material) {
      node.material.metalness = 0.3;
      node.material.roughness = 0.4;
      node.material.envMapIntensity = 1.5;
    }
    node.castShadow = true;
    node.receiveShadow = true;
  });

  // recenter on world origin via bounding-box center
  const box = new THREE.Box3().setFromObject(model);
  const center = box.getCenter(new THREE.Vector3());
  model.position.sub(center);
  scene.add(model);

  // frame the camera from the model's size
  const size = box.getSize(new THREE.Vector3());
  const maxDim = Math.max(size.x, size.y, size.z);
  camera.position.z = maxDim * 1.5;              // pull back 1.5× the largest dimension

  model.scale.set(0, 0, 0);                       // start invisible for the reveal
  playInitialAnimation();

  cancelAnimationFrame(basicAnimate);
  animate();
});

Every mesh gets a semi-metallic material (metalness 0.3, roughness 0.4, envMapIntensity 1.5) and shadow casting/receiving. The Box3 → getCenter → position.sub(center) step puts the model's geometric middle at (0,0,0); camera.position.z = maxDim * 1.5 frames it consistently regardless of authored scale.

6. Animation constants + scanner wiring
const floatAmplitude = 0.2;   // vertical bob height
const floatSpeed     = 1.5;   // bob frequency
const rotationSpeed  = 0.3;   // idle self-spin rate
let   isFloating     = true;
let   currentScroll  = 0;

const stickyHeight     = window.innerHeight;
const scannerSection   = document.querySelector(".scanner");
const scannerPosition  = scannerSection.offsetTop;      // scroll offset where scanner starts
const scanContainer    = document.querySelector(".scan-container");
const scanSound        = new Audio("/path/scan-sfx.mp3");
gsap.set(scanContainer, { scale: 0 });                  // frame starts collapsed

GSAP effect (the important part — be exhaustive)

A. Initial reveal (fires the moment the model loads)
function playInitialAnimation() {
  gsap.to(model.scale,   { x: 1, y: 1, z: 1, duration: 1, ease: "power2.out" });
  gsap.to(scanContainer, { scale: 1,          duration: 1, ease: "power2.out" });
}

Model and scanner frame both grow 0 → 1 over 1s / power2.out, in parallel.

B. ScrollTrigger #1 — restore on scroll back to the very top
ScrollTrigger.create({
  trigger: "body",
  start: "top top",
  end: "top -10",
  onEnterBack: () => {
    if (model) {
      gsap.to(model.scale, { x: 1, y: 1, z: 1, duration: 1, ease: "power2.out" });
      isFloating = true;                                 // resume the float
    }
    gsap.to(scanContainer, { scale: 1, duration: 1, ease: "power2.out" });
  },
});

A tiny 10px trigger at the top: when the user scrolls back into it, the model and frame re-grow to full scale (1s / power2.out) and floating turns back on — undoing the scan.

C. ScrollTrigger #2 — the pinned scan sequence
ScrollTrigger.create({
  trigger: ".scanner",
  start: "top top",
  end: `${stickyHeight}px`,     // pins for one viewport of scroll
  pin: true,
  onEnter: () => {
    if (!model) return;
    isFloating = false;          // stop the bob
    model.position.y = 0;        // settle to center

    setTimeout(() => {           // 500ms after enter, play the scanner beep
      scanSound.currentTime = 0;
      scanSound.play();
    }, 500);

    // 1) spin a full 360° on Y …
    gsap.to(model.rotation, {
      y: model.rotation.y + Math.PI * 2,
      duration: 1,
      ease: "power2.inOut",
      onComplete: () => {
        // 2) … then shrink the model to nothing …
        gsap.to(model.scale, {
          x: 0, y: 0, z: 0,
          duration: 0.5,
          ease: "power2.in",
          onComplete: () => {
            // 3) … then collapse the scanner frame.
            gsap.to(scanContainer, { scale: 0, duration: 0.5, ease: "power2.in" });
          },
        });
      },
    });
  },
  onLeaveBack: () => {
    gsap.set(scanContainer, { scale: 0 });               // snap frame to 0 …
    gsap.to(scanContainer, { scale: 1, duration: 1, ease: "power2.out" }); // … then re-grow it
  },
});

This is the signature moment. On entering the pinned scanner:

  1. Full spinmodel.rotation.y += Math.PI * 2 over 1s / power2.inOut.
  2. On complete → shrink modelscale 1 → 0 over 0.5s / power2.in.
  3. On complete → collapse framescanContainer scale 1 → 0 over 0.5s / power2.in.

The three tweens are chained via onComplete, so they run strictly in sequence (spin → model vanishes → frame vanishes), total ~2s. A 500ms setTimeout fires the scanner-beep SFX just as the spin begins. Scrolling back out (onLeaveBack) snaps the frame to 0 and re-grows it (1s / power2.out).

D. Scroll value + the per-frame loop (scroll → model rotation)
lenis.on("scroll", (e) => { currentScroll = e.scroll; });   // capture Lenis scroll px

function animate() {
  if (model) {
    if (isFloating) {
      const floatOffset = Math.sin(Date.now() * 0.001 * floatSpeed) * floatAmplitude;
      model.position.y = floatOffset;                 // sine bob, ±0.2
    }

    const scrollProgress = Math.min(currentScroll / scannerPosition, 1);   // 0→1 up to the scanner

    if (scrollProgress < 1) {
      model.rotation.x = scrollProgress * Math.PI * 2;   // X-tumble mapped to scroll (0 → 2π)
    }
    if (scrollProgress < 1) {
      model.rotation.y += 0.001 * rotationSpeed;         // slow idle Y drift while approaching
    }
  }
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

Two continuous behaviors before the scan:

  • Float — while isFloating, position.y = sin(Date.now()·0.001·1.5)·0.2, an endless gentle hover. Turned off the instant the scanner pin fires (§C) and back on at the top (§B).
  • Scroll-mapped tumblescrollProgress = min(currentScroll / scannerPosition, 1) is 0 at the top and reaches 1 exactly when the scanner section is reached. While < 1, model.rotation.x = scrollProgress · 2π (one full forward tumble across the pre-scanner scroll) plus a tiny rotation.y += 0.001·0.3 idle drift each frame. Once progress hits 1 (scanner reached) these stop, handing control to the ScrollTrigger spin.
8. Resize
window.addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
}, false);

Assets / images

  • One GLTF/GLB 3D model — a single stylized hero product: a beverage can (a tall cylindrical drink can with printed label artwork). Mid-poly, meant to be viewed as one centered object. Any comparable canned-drink / bottle GLB works; no real-world brand or label text required. Materials are overridden in code to a light semi-metallic look.
  • One barcode image (barcode.png) — a black-and-white product barcode graphic, roughly 2:1 landscape (displayed 200×100), bottom-left of the scanner section.
  • One audio file (scan-sfx.mp3) — a short scanner-beep / checkout-scan sound effect (~1s), played once when the scan sequence begins.

Behavior notes

  • Trigger model: load (initial reveal) + scroll (Lenis smooth scroll → scroll-mapped tumble) + a pinned ScrollTrigger (the scan sequence). No hover/click/mousemove.
  • Heavy, WebGL-required, desktop-first. The scene renders continuously via requestAnimationFrame; not mobile-optimized. 100vw/500vh fixed layout with vw-based type; no responsive breakpoints in the original.
  • Audio needs a user gesture — the beep may be blocked until the user has interacted with the page (browsers gate autoplay audio); scrolling to the scanner generally satisfies this.
  • Reversible: scrolling back up to the top re-grows the model and frame and resumes floating; the piece has no explicit reduced-motion path.