3D Product Scroll Showcase — pinned scrub section with rotating GLTF model
Goal
Build a full-page scroll experience for a fictional fitness shaker brand called "GRND". The star of the show is a pinned, scrub-driven section where a Three.js GLTF shaker-bottle model spins on its Y axis in sync with scroll progress while, mapped to the same progress value, two giant headlines slide horizontally across the screen, a dark circular clip-path mask expands to swallow the background, and two feature tooltips reveal with masked, staggered SplitText line animations.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins ScrollTrigger and SplitText, lenis for smooth scrolling, and three (npm) with GLTFLoader from three/examples/jsm/loaders/GLTFLoader.js for the 3D model. Icons are Ionicons web components loaded from the unpkg CDN (https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js as a module script plus the nomodule fallback).
All JS runs inside a DOMContentLoaded listener. Register ScrollTrigger and SplitText with gsap.registerPlugin.
Layout / HTML
Three stacked full-viewport sections:
<section class="intro">— a single<h1>with the copy "GRND doesn't shake. It performs."<section class="product-overview">— the pinned showcase, containing in this order:<div class="header-1"><h1>Every Rep Starts With</h1></div><div class="header-2"><h1>GRND Shaker</h1></div><div class="circular-mask"></div><div class="tooltips">with exactly two<div class="tooltip">blocks. Each tooltip contains, in order:<div class="icon"><ion-icon name="..."></ion-icon></div>,<div class="divider"></div>,<div class="title"><h2>...</h2></div>,<div class="description"><p>...</p></div>.- Tooltip 1: icon
flash, title "Built to last", description "Designed to match your pace, GRND runs all week on a single charge. No interruptions, no slowing down." - Tooltip 2: icon
bluetooth, title "Stay synced", description "With app integration, GRND helps you stay consistent. Monitor intake, set goals, and make every sip count." <div class="model-container"></div>— empty; the WebGL canvas is appended here.<section class="outro">— a single<h1>with the copy "Don't Just Train — GRND".
Styling
- Font:
"PP Neue Montreal", sans-serif(import fromhttps://fonts.cdnfonts.com/css/pp-neue-montreal). Global reset:* { margin: 0; padding: 0; box-sizing: border-box; }. - Type scale:
h14rem / weight 500 / line-height 1.h23rem / weight 500 / line-height 1.125 / letter-spacing -0.03rem.p1.1rem / weight 500 / line-height 1.5. - Every
sectionisposition: relative; width: 100vw; height: 100svh; overflow: hidden;with background#e0dfdfand text#0d0d0d. .introand.outroinvert the palette (background#0d0d0d, text#e0dfdf), flex-centered,padding: 2rem..model-container: absolutely centered (top/left 50%,translate(-50%, -50%)),width/height 100%,z-index: 100..header-1:position: relative; width: 200vw; height: 100svh;color#0d0d0d, initialtransform: translateX(0%)..header-2:position: fixed; top: 0; left: 0; width: 150vw; height: 100svh;color#e0dfdf, initialtransform: translateX(100%),z-index: 2.- Both headers are flex containers (
align-items: center; padding: 0 2rem) and theirh1iswidth: 100%; font-size: 15vw; line-height: 1.25; letter-spacing: -0.02em— a single huge line that overflows the viewport horizontally. .circular-mask: absolute, fills the section, background#0d0d0d, initialclip-path: circle(0% at 50% 50%)..tooltips: absolutely centered,width: 75%; height: 75%,display: flex; gap: 15rem..tooltip:flex: 1, column flex withgap: 0.5rem, color#e0dfdf. The second tooltip is pushed to the bottom-right of its column (justify-content: flex-end; align-items: flex-end)..tooltip .divider:width: 100%; height: 1px; background: #5f5f5f; margin: 0.5rem 0;initialtransform: scaleX(0%). Transform-origin:right centeron tooltip 1,left centeron tooltip 2 (so they grow toward each other from the middle)..tooltip .icon:font-size: 2.5rem; overflow: hidden..tooltip .description: color#5f5f5f. On tooltip 2,.iconand.titlearewidth: 70%; every.descriptionis alsowidth: 70%.- Text-mask plumbing (critical for the reveal effect):
.header-1 h1 .char,.tooltip .title .line,.tooltip .description .line→display: inline-block; overflow: hidden;.header-1 h1 .char > span,.tooltip .icon ion-icon,.tooltip .title .line > span,.tooltip .description .line > span→position: relative; display: block; transform: translateY(100%); will-change: transform;(everything starts hidden below its overflow mask).@media (max-width: 1000px):h12rem and centered;h2and.tooltip .icon1.5rem;.tooltipsbecomes a full-width centered column withgap: 2rem; each.tooltipiswidth: 85%and tooltip 2 resets toflex-startalignment; dividers becomewidth: 70%and tooltip 2's divider origin flips toright center; tooltip 2's icon/title go back towidth: 100%.
GSAP effect (exhaustive)
Smooth scroll bridge
Instantiate new Lenis(), wire lenis.on("scroll", ScrollTrigger.update), drive it with gsap.ticker.add((time) => lenis.raf(time * 1000)), and call gsap.ticker.lagSmoothing(0).
SplitText setup
- Split
.header-1 h1withtype: "chars", charsClass: "char". - Split
.tooltip .title h2and.tooltip .description pwithtype: "lines", linesClass: "line". - After splitting, manually wrap the innerHTML of every char and every line in a
<span>(e.g.char.innerHTML =<span>${char.innerHTML}</span>`). The outer char/line acts as anoverflow: hiddenmask and the inner span is the element that animates (CSS starts it attranslateY(100%)`).
Shared tween options
const animOptions = { duration: 1, ease: "power3.out", stagger: 0.025 }; — reused by the divider and tooltip tweens below.
ScrollTrigger #1 — header-1 char reveal on approach
ScrollTrigger.create with trigger: ".product-overview", start: "75% bottom" (no scrub):
onEnter:gsap.to(".header-1 h1 .char > span", { y: "0%", duration: 1, ease: "power3.out", stagger: 0.025 })— chars rise out of their masks left to right.onLeaveBack: same tween back toy: "100%".
ScrollTrigger #2 — the pinned scrub timeline (imperative, via onUpdate)
ScrollTrigger.create with trigger: ".product-overview", start: "top top", end: "+=" + window.innerHeight * 10 + "px" (the section stays pinned for 10 viewport heights), pin: true, pinSpacing: true, scrub: 1.
Everything below happens inside onUpdate: ({ progress }) => { ... } — each range is mapped by hand from the 0→1 progress and applied with gsap.to calls (the header/mask tweens use GSAP's default duration/ease, which gives them a soft glide on top of the scrub):
- Header 1 slides out left — progress 0.05→0.35:
gsap.to(".header-1", { xPercent })wherexPercentis0below 0.05,-100above 0.35, otherwise-100 * ((progress - 0.05) / 0.3). - Circular mask expands — progress 0.20→0.30: compute
maskSize=0below 0.2,100above 0.3, otherwise100 * ((progress - 0.2) / 0.1), thengsap.to(".circular-mask", { clipPath: \circle(${maskSize}% at 50% 50%)\}). The dark circle grows from the center until it covers the whole section, flipping the scene from light to dark. - Header 2 sweeps across — progress 0.15→0.50:
gsap.to(".header-2", { xPercent })wherexPercentis100below 0.15,-200above 0.5, otherwise100 - 300 * ((progress - 0.15) / 0.35)(it enters from the right, crosses the screen, and exits fully left — a 300% travel). - Dividers grow — progress 0.45→0.65:
scaleX=0below 0.45,100above 0.65, otherwise100 * ((progress - 0.45) / 0.2); apply withgsap.to(".tooltip .divider", { scaleX: \${scaleX}%\, ...animOptions }). - Tooltip content reveals (threshold toggles, not ramps) — two groups:
- Group 1 at
progress >= 0.65:".tooltip:nth-child(1) .icon ion-icon",".tooltip:nth-child(1) .title .line > span",".tooltip:nth-child(1) .description .line > span". - Group 2 at
progress >= 0.85: same three selectors for.tooltip:nth-child(2). - For each group:
gsap.to(elements, { y: progress >= threshold ? "0%" : "125%", ...animOptions })— so each group snaps its target and eases in/out with the 0.025 stagger, and scrolling back re-hides it. - Model rotation — for
progress >= 0.05:rotationProgress = (progress - 0.05) / 0.95,targetRotation = Math.PI * 3 * 4 * rotationProgress(12π rad ≈ 6 full turns across the pin). Keep acurrentRotationaccumulator and apply only the delta: if|targetRotation - currentRotation| > 0.001, callmodel.rotateOnAxis(new THREE.Vector3(0, 1, 0), diff)and update the accumulator. This rotates around the model's local Y axis so the tilted bottle spins about its own long axis.
Three.js scene
THREE.Scene,PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000).WebGLRenderer({ antialias: true, alpha: true });setClearColor(0x000000, 0)(transparent — the page background shows through); size = window;setPixelRatio(Math.min(devicePixelRatio, 2));shadowMap.enabled = truewithPCFSoftShadowMap;outputEncoding = THREE.LinearEncoding;toneMapping = THREE.NoToneMapping, exposure 1.0. Appendrenderer.domElementto.model-container.- Lights:
AmbientLight(0xffffff, 0.7); mainDirectionalLight(0xffffff, 1.0)at(1, 2, 3)withcastShadow,shadow.bias = -0.001, 1024×1024 shadow map; fillDirectionalLight(0xffffff, 0.5)at(-2, 0, -2). - Load the
.glbwithGLTFLoader. On load, traverse the scene and on every mesh material setmetalness: 0.05, roughness: 0.9(matte look). Measure the model withnew THREE.Box3().setFromObject(model)and store its size vector. setupModel()(called on load and on every resize; guard until model + size exist):isMobile = window.innerWidth < 1000.- Position: x =
center.x + size.x * 1on mobile,-center.x - size.x * 0.4on desktop (pushes the bottle left of center so it sits under the tooltip gap); y =-center.y + size.y * 0.085; z =-center.z. model.rotation.z = -25°(in radians) on desktop,0on mobile — a jaunty tilt.- Camera at
(0, 0, maxDimension * d)whered= 2 on mobile, 1.25 on desktop, looking at the origin. - Plain
requestAnimationFrameloop callingrenderer.render(scene, camera). resizelistener: update camera aspect + projection matrix, renderer size, and re-runsetupModel().
Assets / images
- 1 × GLTF binary model (
.glb): a fitness shaker bottle (cylindrical drink bottle with a lid), roughly upright, real-world scale irrelevant since the camera frames it from its bounding box. Load it from a local path (e.g../shaker.glb). No textures required beyond whatever the model ships with — materials get forced matte at runtime. - No raster images. Two Ionicons glyphs (
flash,bluetooth) render via the<ion-icon>web component.
Behavior notes
- The whole page scroll is smoothed by Lenis; the showcase section is pinned for 10 viewport heights, so plan for a tall scroll.
- Scrolling backwards fully reverses every stage (headers slide back, mask shrinks, tooltips re-hide via the
125%branch, the model spins back because rotation is delta-based). - The scrub pipeline is imperative (a single
onUpdatedoing range-mapping) rather than a declarative timeline — reproduce it that way to get the same layered easing feel. - Desktop-first; below 1000px the tooltips stack vertically, the model shifts right of center and loses its tilt, and the camera pulls back.
- The header text intentionally overflows the viewport (200vw / 150vw containers with 15vw type) — do not "fix" that; the horizontal slide depends on it.