All components

SofiHealth Product Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

A pinned, scrub-driven scroll section where a Three.js GLTF shaker model spins on its Y-axis while a single ScrollTrigger onUpdate handler maps scroll progress to sliding headers, an expanding circular clip-path mask, and staggered SplitText tooltip reveals. Lenis smooth-scroll feeds ScrollTrigger, and a separate ScrollTrigger reveals the split header characters on enter.

How it's built

Categoryscroll
Techthree, gsap, lenis
GSAP pluginsScrollTrigger, SplitText
Complexitypage
Performance costheavy
Mobile-safedesktop-first
Scrollhijacks scrolling

scroll three.js webgl scrolltrigger splittext gltf product-showcase lenis clip-path

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 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:

  1. <section class="intro"> — a single <h1> with the copy "GRND doesn't shake. It performs."
  2. <section class="product-overview"> — the pinned showcase, containing in this order:
  3. <div class="header-1"><h1>Every Rep Starts With</h1></div>
  4. <div class="header-2"><h1>GRND Shaker</h1></div>
  5. <div class="circular-mask"></div>
  6. <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>.
  7. 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."
  8. 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."
  9. <div class="model-container"></div> — empty; the WebGL canvas is appended here.
  10. <section class="outro"> — a single <h1> with the copy "Don't Just Train — GRND".

Styling

  • Font: "PP Neue Montreal", sans-serif (import from https://fonts.cdnfonts.com/css/pp-neue-montreal). Global reset: * { margin: 0; padding: 0; box-sizing: border-box; }.
  • Type scale: h1 4rem / weight 500 / line-height 1. h2 3rem / weight 500 / line-height 1.125 / letter-spacing -0.03rem. p 1.1rem / weight 500 / line-height 1.5.
  • Every section is position: relative; width: 100vw; height: 100svh; overflow: hidden; with background #e0dfdf and text #0d0d0d.
  • .intro and .outro invert 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, initial transform: translateX(0%).
  • .header-2: position: fixed; top: 0; left: 0; width: 150vw; height: 100svh; color #e0dfdf, initial transform: translateX(100%), z-index: 2.
  • Both headers are flex containers (align-items: center; padding: 0 2rem) and their h1 is width: 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, initial clip-path: circle(0% at 50% 50%).
  • .tooltips: absolutely centered, width: 75%; height: 75%, display: flex; gap: 15rem.
  • .tooltip: flex: 1, column flex with gap: 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; initial transform: scaleX(0%). Transform-origin: right center on tooltip 1, left center on 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, .icon and .title are width: 70%; every .description is also width: 70%.
  • Text-mask plumbing (critical for the reveal effect):
  • .header-1 h1 .char, .tooltip .title .line, .tooltip .description .linedisplay: inline-block; overflow: hidden;
  • .header-1 h1 .char > span, .tooltip .icon ion-icon, .tooltip .title .line > span, .tooltip .description .line > spanposition: relative; display: block; transform: translateY(100%); will-change: transform; (everything starts hidden below its overflow mask).
  • @media (max-width: 1000px): h1 2rem and centered; h2 and .tooltip .icon 1.5rem; .tooltips becomes a full-width centered column with gap: 2rem; each .tooltip is width: 85% and tooltip 2 resets to flex-start alignment; dividers become width: 70% and tooltip 2's divider origin flips to right center; tooltip 2's icon/title go back to width: 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 h1 with type: "chars", charsClass: "char".
  • Split .tooltip .title h2 and .tooltip .description p with type: "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 an overflow: hidden mask and the inner span is the element that animates (CSS starts it at translateY(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 to y: "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):

  1. Header 1 slides out left — progress 0.05→0.35: gsap.to(".header-1", { xPercent }) where xPercent is 0 below 0.05, -100 above 0.35, otherwise -100 * ((progress - 0.05) / 0.3).
  2. Circular mask expands — progress 0.20→0.30: compute maskSize = 0 below 0.2, 100 above 0.3, otherwise 100 * ((progress - 0.2) / 0.1), then gsap.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.
  3. Header 2 sweeps across — progress 0.15→0.50: gsap.to(".header-2", { xPercent }) where xPercent is 100 below 0.15, -200 above 0.5, otherwise 100 - 300 * ((progress - 0.15) / 0.35) (it enters from the right, crosses the screen, and exits fully left — a 300% travel).
  4. Dividers grow — progress 0.45→0.65: scaleX = 0 below 0.45, 100 above 0.65, otherwise 100 * ((progress - 0.45) / 0.2); apply with gsap.to(".tooltip .divider", { scaleX: \${scaleX}%\, ...animOptions }).
  5. Tooltip content reveals (threshold toggles, not ramps) — two groups:
  6. 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".
  7. Group 2 at progress >= 0.85: same three selectors for .tooltip:nth-child(2).
  8. 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.
  9. 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 a currentRotation accumulator and apply only the delta: if |targetRotation - currentRotation| > 0.001, call model.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 = true with PCFSoftShadowMap; outputEncoding = THREE.LinearEncoding; toneMapping = THREE.NoToneMapping, exposure 1.0. Append renderer.domElement to .model-container.
  • Lights: AmbientLight(0xffffff, 0.7); main DirectionalLight(0xffffff, 1.0) at (1, 2, 3) with castShadow, shadow.bias = -0.001, 1024×1024 shadow map; fill DirectionalLight(0xffffff, 0.5) at (-2, 0, -2).
  • Load the .glb with GLTFLoader. On load, traverse the scene and on every mesh material set metalness: 0.05, roughness: 0.9 (matte look). Measure the model with new 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 * 1 on mobile, -center.x - size.x * 0.4 on 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, 0 on mobile — a jaunty tilt.
  • Camera at (0, 0, maxDimension * d) where d = 2 on mobile, 1.25 on desktop, looking at the origin.
  • Plain requestAnimationFrame loop calling renderer.render(scene, camera).
  • resize listener: update camera aspect + projection matrix, renderer size, and re-run setupModel().

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 onUpdate doing 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.