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 havez-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-infois an absolutely-positioned top bar (product-ID chip left, description right)..scan-containeris the empty 280×480 bordered frame the model appears to sit inside..barcodeis a bottom-left image;.purchasedis 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:
- Full spin —
model.rotation.y += Math.PI * 2over 1s /power2.inOut. - On complete → shrink model —
scale 1 → 0over 0.5s /power2.in. - On complete → collapse frame —
scanContainer scale 1 → 0over 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 tumble —
scrollProgress = 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 tinyrotation.y += 0.001·0.3idle 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/500vhfixed layout withvw-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.