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 v7 web components served from your own origin (/vendor/ionicons/ionicons.esm.js as a module script plus the nomodule fallback ionicons.js); get them with npm i ionicons@7.1.0 and copy the whole node_modules/ionicons/dist/ionicons/ folder, since the loader fetches its chunks and one SVG per icon relative to the script's own URL.

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: "Hanken Grotesk", sans-serif (Google Fonts, import with @import url("https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700;800&display=swap");). 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.

Images

This component ships with 1 reference asset, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/sofihealth-product-scroll-animation/shaker.glb

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--ink, --paper, --muted, --accent. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the lenis.on("scroll", ScrollTrigger.update) wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.

Adapting this to React

Everything above describes a standalone document: a single script that waits for DOMContentLoaded, reaches into the page with unscoped document.querySelector calls for .header-1, .header-2, .circular-mask, .tooltip, and .model-container, and only then wires two independent ScrollTrigger.create calls, a Lenis bridge, and a Three.js scene that never expects to be torn down. React withdraws all three of those guarantees at once, and quietly: the shaker spins, the headlines slide, and the tooltips reveal correctly on first load, and the damage only shows up on a second mount or a real navigation away.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. This component accumulates five independent pieces of live state before the first paint completes: a Lenis instance, a gsap.ticker subscription driving it, two ScrollTriggers (the char-reveal on approach and the ten-viewport-height pinned scrub), a self-scheduling requestAnimationFrame render loop, and an in-flight GLTFLoader fetch. A double mount that doesn't undo every one of those leaves two WebGLRenderers appending competing <canvas> elements into .model-container, two pins fighting over .product-overview's own inline styles, two onUpdate callbacks each running their own gsap.to range-mapping against the same headlines and mask, and a shaker whose rotation stutters between two separate currentRotation accumulators. None of it reproduces in a production build — React only double-invokes effects in development — so treat the teardown below as load-bearing, not optional.

*(1) The entry point* — the whole script is wrapped in document.addEventListener("DOMContentLoaded", ...). By the time a React component mounts, that event has already fired, so the listener never runs: no Lenis, no SplitText, no ScrollTrigger, no scene, nothing. Delete the listener and move its entire body — plugin registration through the closing ScrollTrigger.create for the pinned scrub — into a useEffect with an empty dependency array. gsap.registerPlugin(ScrollTrigger, SplitText) can move to module scope instead; repeating it on every mount is harmless but pointless.

*(2) Element lookups* — .header-1, .header-2, .circular-mask, .tooltip, .model-container, and both trigger elements resolve as class selectors against .product-overview, and renderer.domElement gets appended into whatever document.querySelector(".model-container") finds first. Put a root ref on the wrapping <section className="product-overview"> and scope every one of those — the two SplitText targets, the divider and tooltip selectors used inside onUpdate, and the .model-container the canvas is appended to — off that ref. The .model-container lookup is the one that bites hardest: during the StrictMode remount two copies of it briefly coexist, and an unscoped querySelector can append the second mount's renderer canvas into the container that's already being torn down.

*(3) Cleanup* — wrap the whole body in a gsap.context scoped to the root ref and revert it in cleanup:

useEffect(() => {
  const ctx = gsap.context(() => {
    // Lenis + ticker wiring, both SplitText calls, both ScrollTrigger.create calls
  }, rootRef);
  return () => ctx.revert();
}, []);

ctx.revert() covers both SplitText instances, the char-reveal trigger, and the pinned scrub — including the pin-spacer that pin: true inserts around .product-overview and the inline styles it rewrites for the ten-viewport-height scroll. Skip the revert and a remount leaves an orphaned spacer plus a second live pin disagreeing with the first about the current progress value. This component never calls ctx.add/self.add — every trigger and tween is created directly inside the factory — so the two-overload trap doesn't apply here.

The ticker drives Lenis, and the context doesn't see it

gsap.ticker.add((time) => lenis.raf(time * 1000)) is not a tween or a trigger, so ctx.revert() never touches it — this is exactly the case where that matters, because this callback *is* Lenis's own frame loop. Keep the function reference and remove it before destroying Lenis, in this order:

const driveLenis = (time) => lenis.raf(time * 1000);
gsap.ticker.add(driveLenis);
gsap.ticker.lagSmoothing(0);
// cleanup:
gsap.ticker.remove(driveLenis);
lenis.destroy();

Reverse that order and a ticker frame landing between the two calls invokes .raf() on a Lenis instance that no longer exists.

Lenis

new Lenis() here is bare — no options — and its only subscriber is lenis.on("scroll", ScrollTrigger.update). If this shaker section ends up as one piece of a larger app, lift the instance to the app shell and have this effect subscribe to the existing one instead of constructing a second; a page gets exactly one smooth scroller. If the section genuinely owns the page's scroll, construct it inside the effect and call lenis.destroy() in the cleanup, after removing driveLenis as shown above.

The render loop and the pinned scrub are two separate clocks

function animate() only calls renderer.render(scene, camera) and reschedules itself — it carries no simulation state of its own, but it is a second live loop running alongside the ScrollTrigger onUpdate that turns the model. Keep the handle its requestAnimationFrame call returns and cancel it in the same cleanup that reverts the context; an uncancelled copy keeps calling renderer.render against a scene/camera pair the next mount has already replaced, and the shaker renders twice, out of phase.

SplitText

header1Split (chars, on .header-1 h1) and titleSplits/descriptionSplits (lines, on .tooltip .title h2 and .tooltip .description p) each get their innerHTML rewritten a second time right after splitting, to wrap every char/line in the <span> the CSS mask expects (.char > span / .line > span starting at the hidden translateY(100%) offset). Revert all three splits inside the same gsap.context, before the wrap step can run again on a remount — otherwise it wraps the already-wrapped spans, the reveal targets a nested span one level too deep, and the char and line tweens in both the approach trigger and the tooltip-reveal branch of onUpdate stop matching any visible element.

The GLTF load can finish after the component is gone

new GLTFLoader().load(...) assigns model, adds it to scene, and calls setupModel() from its callback — and that callback can fire after a StrictMode unmount has already reverted the first mount's context and cancelled its render loop. If it does, it populates a scene nothing renders and runs setupModel()'s isMobile/position math against a container the DOM has already discarded, while the *second* mount's own onUpdate keeps reading a model variable that stays undefined until its own, separate load resolves. Guard the callback with the same cancellation flag the cleanup sets:

useEffect(() => {
  let cancelled = false;
  const ctx = gsap.context(() => {
    new GLTFLoader().load(modelUrl, (gltf) => {
      if (cancelled) return;
      // assign model, traverse for the matte material, scene.add, setupModel()
    });
  }, rootRef);
  return () => { cancelled = true; ctx.revert(); };
}, []);
Three.js → React Three Fiber
  • Scene ownership. <Canvas> takes over the Scene, PerspectiveCamera(60, aspect, 0.1, 1000), and WebGLRenderer({ antialias: true, alpha: true }) triple — delete that block and carry the alpha flag and the transparent clear color into <Canvas gl={{ alpha: true }}> so the page background still shows through the model container. The ambient light, the shadow-casting main directional light at (1, 2, 3), and the fill light at (-2, 0, -2) become three JSX elements instead of three new THREE.*Light() calls.
  • No extra render loop. animate()'s only job is renderer.render(scene, camera), and <Canvas> already does that on every frame by default — delete it entirely rather than reaching for useFrame. The rotation itself is not a per-frame animation either: it's computed from the pinned ScrollTrigger's progress, once per scrub tick, so it belongs in that same onUpdate callback, applied to a ref on the loaded group (groupRef.current.rotateOnAxis(new THREE.Vector3(0, 1, 0), rotationDiff)) — not moved into useFrame, which would run it on the render clock instead of the scroll one.
  • Model loading and grading. GLTFLoader().load(modelUrl, ...) becomes useGLTF(modelUrl). The matte-material rewrite (forcing metalness and roughness on every mesh) and the Box3-based centering in setupModel() still have to run once the geometry is available — do them in an effect keyed on the loaded scene, guarded by the same cancellation pattern, not inline during render — and keep in mind useGLTF caches by URL: mutate a clone of each material rather than the cached one, or a second consumer of the same .glb inherits an already-matte finish.
  • Resize. <Canvas> already observes its own container and keeps the camera's aspect and the renderer's size in sync — drop the manual resize listener along with its camera.updateProjectionMatrix()/renderer.setSize() calls, and re-run only the isMobile branch of setupModel() (position, the tilt on rotation.z, camera distance) from a ResizeObserver or useThree(({ size }) => ...) instead of reading window.innerWidth directly.
  • Poster mandatory. The .glb fetch plus the matte-material traverse happens after mount, and the pinned scrub above .model-container starts answering scroll immediately — a visitor who scrolls fast during a cold load reaches the mask-flip and tooltip reveal against an empty box where the shaker should be. Render a poster image sized to the same container and swap it for the <Canvas> once useGLTF's suspense boundary resolves.
  • No Environment preset. This scene is already fully lit by the three explicit sources above, tuned specifically for the matte finish the material traversal forces — don't reach for drei's Environment with a preset to add reflections; a fetch failure to that preset's third-party CDN would leave the shaker unlit with no visible error. Self-host an HDRI and point Environment at it if you want image-based lighting instead.