All components

Three.js 3D Scroll Experience

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

Open live demo ↗ Raw prompt (.md)

What it does

A fixed Three.js chair model (GLB) scales in on load, floats with a sine bob, and tilts on its X axis proportionally to scroll progress driven by a Lenis + GSAP ticker loop; a SplitType-lined outro headline reveals via a staggered translateY masked by ScrollTrigger.

How it's built

Category3d-webgl
Techgsap, three, lenis, split-type
GSAP pluginsScrollTrigger
Complexitypage
Performance costheavy
Mobile-safedesktop-first
Scrollhijacks scrolling

three-js webgl gltf scroll lenis scrolltrigger split-type editorial furniture

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

Three.js 3D Scroll Experience

Goal

Build a full-page editorial hero for a fictional furniture design studio ("oak atelier") whose star effect is a fixed Three.js furniture model (a designer chair loaded from a GLB) floating in the center of the viewport. On load the model scales up from nothing with an elastic-free ease; it then bobs gently up and down forever (a sine float) while its X-axis rotation is driven directly by page-scroll progress — as you scroll the 400vh page the chair tumbles head-over-heels through two full revolutions (4π radians). Smooth scrolling is handled by Lenis, wired into GSAP's ticker, and the same ticker also runs the Three.js render loop. A secondary effect: the closing headline is split into lines and each line masks up into view with a staggered translateY via ScrollTrigger.

NON-NEGOTIABLE visual requirements (get these exactly right)

These are essential — a reproduction that misses them does not look like the original, even if the animation is perfect:

  1. Dark canvas, always. The page background is a near-black #111111 with white (#fff) text. There is no light mode, no white page. The transparent WebGL chair floats above this dark canvas. If you ship a white/unstyled page you have failed the brief.
  2. The stylesheet MUST be linked. The index.html <head> must contain <link rel="stylesheet" href="./styles.css" />. Without it the page renders 100% unstyled (white background, browser-default type, a raw blue underlined link) — this is the single most common way this build breaks. See the full HTML skeleton below.
  3. Real display typography. A huge 225px uppercase grotesque headline and 120px serif archive titles — load actual web fonts (below). System-font fallbacks (Helvetica/Times) do not read like the original.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, three (Three.js core + GLTFLoader from three/examples/jsm/loaders/GLTFLoader.js), lenis for smooth scroll, and split-type for the headline line-split. Imports:

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

gsap.registerPlugin(ScrollTrigger);

Fonts (load these concretely — do not rely on system fonts)

The original pairs a neutral Swiss grotesque (Akkurat-style) with a high-contrast Didone serif that has a true italic (Gascogne-style). Reproduce that pairing with two web-loadable Google Fonts and keep explicit fallbacks:

  • Grotesque (global / body / the 225px h1): Inter — neutral, Helvetica-adjacent. Stack: "Inter", "Helvetica Neue", Arial, sans-serif.
  • High-contrast serif WITH true italic (the "atelier" nav span, the 120px archive <h2>, the italic "Collection" label, the <span>s inside outro paragraphs): Playfair Display — a Didone with a genuine italic (not a slanted roman). Stack: "Playfair Display", Georgia, "Times New Roman", serif.

Load both at the very top of styles.css (include the italic axis so the italics are real):

@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500&family=Playfair+Display:ital,wght@0,300;0,400;1,400;1,500&display=swap");

(If you prefer, load the same two families via <link> in the <head> instead — either works, but they MUST be loaded. If Google Fonts is unavailable in your environment, substitute any neutral grotesque + any high-contrast serif-with-true-italic and self-host via @font-face; never leave the display type on system defaults.)

Layout / HTML

Single scrolling page. Show and ship the whole document — the <head> (with the stylesheet link) is part of the deliverable, not boilerplate you can drop:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Three.js 3D Scroll Experience</title>
    <!-- REQUIRED: without this link the page renders completely unstyled -->
    <link rel="stylesheet" href="./styles.css" />
  </head>
  <body>
    <div class="model"></div>            <!-- fixed; Three.js canvas is appended here -->

    <nav>
      <p>oak <span>atelier</span></p>     <!-- "atelier" is set in the serif italic -->
      <a href="#">Contact Us</a>
    </nav>

    <section class="intro">
      <div class="header-row"><h1>Spaces for</h1></div>
      <div class="header-row">
        <h1>Future</h1>
        <p>Innovative furniture design studio. Crafting sustainable, bespoke, and functional solutions for homes and businesses.</p>
      </div>
      <div class="header-row"><h1>Living Here</h1></div>
    </section>

    <section class="archive">
      <div class="archive-header"><p>Collection</p></div>
      <!-- 6 archive-item blocks; each: -->
      <div class="archive-item">
        <h2>Ripple Bench</h2>
        <div class="archive-info"><p>US / EU</p><p>Design Concept</p><p>Bench</p><p>Outdoor</p></div>
      </div>
      <!-- remaining five titles + info rows:
           Arc Table    | US / EU | Design Concept  | Table    | Modern
           Orb Vase     | US / EU | Limited Edition | Decor    | Contemporary
           Grid Shelving| US / EU | Project Details | Shelving | Industrial
           Halo Pendant | US / EU | Project Details | Lighting | Modern
           Flow Chair   | US / EU | Design Concept  | Armchair | Minimalist -->
    </section>

    <section class="outro">
      <div class="outro-copy">
        <h2>We are a French, Dutch, and German multidisciplinary design atelier specializing in bespoke furniture, spatial installations, and immersive visual experiences.</h2>
        <p>Promos <span>info.oakatelier.com</span></p>
        <p>Contact <span>hello.oakatelier.com</span></p>
      </div>
      <div class="footer">
        <p>We are a French, Dutch, and German multidisciplinary design atelier specializing in bespoke furniture, spatial installations, and immersive visual experiences.</p>
        <p>Built by oak atelier</p>
      </div>
    </section>

    <!-- REQUIRED: the module entry point, at the end of the body -->
    <script type="module" src="./script.js"></script>
  </body>
</html>

Styling

  • Fonts: put the Google Fonts @import (above) as the first line of styles.css. Global font is the grotesque Inter stack; the serif Playfair Display stack is used for the word "atelier" in the nav, the archive <h2> titles, the "Collection" label (italic), and the <span>s inside paragraphs.
  • Reset: * { margin:0; padding:0; box-sizing:border-box; font-family:"Inter","Helvetica Neue",Arial,sans-serif; }.
  • Page (dark canvas — mandatory): html, body { width:100vw; height:400vh; background-color:#111111; color:#fff; overflow-x:hidden; }. Near-black background, white text — non-negotiable.
  • Type scale: h1 uppercase, font-size:225px; font-weight:400; line-height:0.85;. a, p text-decoration:none; color:#fff; font-size:13px; font-weight:400; line-height:0.9; and <a> uppercase. p span uses the Playfair serif stack.
  • .model: position:fixed; z-index:2; — the WebGL canvas lives here, fixed above the scrolling content.
  • nav: position:fixed; top:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center;. nav a { text-transform:uppercase; }.
  • section: each width:100vw; height:100vh except .archive which is height:200vh.
  • .intro: display:flex; flex-direction:column; justify-content:center; padding:1em;. .header-row { display:flex; gap:12em; align-items:center; }; .header-row p { text-transform:uppercase; width:20%; }.
  • .archive: display:flex; flex-direction:column; gap:3em; justify-content:center; align-items:center; text-align:center;. .archive h2 { font-family:"Playfair Display",Georgia,serif; font-size:120px; font-weight:300; color:#4f4f4f; }. .archive-info { width:100%; padding:1em; display:flex; justify-content:space-around; align-items:center; } with its ps uppercase and #4f4f4f. .archive-header p { font-family:"Playfair Display",Georgia,serif; font-style:italic; }.
  • .outro: display:flex; flex-direction:column; justify-content:space-between; padding:6em 2em 2em 2em;. .outro-copy h2 { width:75%; text-transform:uppercase; font-size:60px; font-weight:400; line-height:1; margin-bottom:0.5em; }. .outro-copy p { display:flex; margin:1em 0; gap:2em; text-transform:uppercase; }. .outro-copy p span { font-family:"Playfair Display",Georgia,serif; }. .footer { display:flex; justify-content:space-between; align-items:flex-end; text-transform:uppercase; } and .footer p:nth-child(1) { width:25%; }.
  • SplitText line mask (required for the outro reveal): the JS splits .outro-copy h2 into .line wrappers and puts a <span> inside each. Style them so the span can slide behind a clipped edge:
.outro-copy h2 .line { position:relative; display:block; overflow:hidden;
  clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%); }
.outro-copy h2 .line span { position:relative; display:block; will-change:transform;
  transform: translateY(70px); }   /* starts pushed down, hidden by the line's overflow:hidden */
  • Include the standard Lenis helper CSS: .lenis.lenis-smooth { scroll-behavior:auto !important; }, .lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior:contain; }, .lenis.lenis-stopped { overflow:clip; }, .lenis.lenis-smooth iframe { pointer-events:none; }.

GSAP / Three.js effect (the important part — be exhaustive)

1. Lenis + GSAP ticker wiring
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);

Also subscribe a second lenis.on("scroll", (e) => { currentScroll = e.scroll; }) to keep a live scroll value for the model rotation.

2. Three.js scene, camera, renderer
const scene  = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);

const renderer = new THREE.WebGLRenderer({ antialias:true, alpha:true });
renderer.setClearColor(0x000000, 0);                 // transparent canvas over the #111 page
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;
document.querySelector(".model").appendChild(renderer.domElement);
3. Lighting rig (four lights)
scene.add(new THREE.AmbientLight(0xffffff, 0.75));

const mainLight = new THREE.DirectionalLight(0xffffff, 7.5); mainLight.position.set(0.5, 7.5, 2.5); scene.add(mainLight);
const fillLight = new THREE.DirectionalLight(0xffffff, 2.5); fillLight.position.set(-15, 0, -5);  scene.add(fillLight);
const hemiLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 1.5); hemiLight.position.set(0, 0, 0); scene.add(hemiLight);
4. Load the model, center it, frame the camera, kick off the entrance

Run a lightweight basicAnimate() render loop (just renderer.render + requestAnimationFrame) until the model arrives, then swap to the real animate() loop.

let model;
new GLTFLoader().load("/path/to/chair.glb", (gltf) => {
  model = gltf.scene;
  model.traverse((n) => {
    if (n.isMesh && n.material) {
      n.material.metalness = 2;         // pushed high for a lacquered/metallic sheen
      n.material.roughness = 3;
      n.material.envMapIntensity = 5;
    }
    n.castShadow = true; n.receiveShadow = true;
  });

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

  // frame: camera distance = 1.75 × largest bounding-box dimension
  const size = box.getSize(new THREE.Vector3());
  const maxDim = Math.max(size.x, size.y, size.z);
  camera.position.z = maxDim * 1.75;

  model.scale.set(0, 0, 0);           // start invisible (scaled to zero)
  model.rotation.set(0, 0.5, 0);      // slight Y yaw so it reads 3/4-view
  playInitialAnimation();             // entrance tween (below)

  cancelAnimationFrame(basicAnimate); // (id is captured from the basic loop)
  animate();                          // start the real loop
});
5. Entrance tween (on load)
function playInitialAnimation() {
  gsap.to(model.scale, { x:1, y:1, z:1, duration:1, ease:"power2.out" });
}

The chair scales 0 → 1 over 1s, power2.out — it pops into existence as the page opens.

6. Per-frame model animation (float + scroll-tilt)

Constants: floatAmplitude = 0.2, floatSpeed = 1.5. Precompute totalScrollHeight = document.documentElement.scrollHeight - window.innerHeight. In the render loop:

function animate() {
  if (model) {
    // (a) infinite vertical float — a sine bob, always on
    const floatOffset = Math.sin(Date.now() * 0.001 * floatSpeed) * floatAmplitude;
    model.position.y = floatOffset;   // oscillates within ±0.2 units

    // (b) scroll-driven tumble on X
    const scrollProgress = Math.min(currentScroll / totalScrollHeight, 1); // 0 → 1, clamped
    const baseTilt = 0.5;
    model.rotation.x = scrollProgress * Math.PI * 4 + baseTilt;            // up to 4 full π turns + 0.5 rad
  }
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

Key numbers: the float uses Date.now()*0.001*1.5 as the sine argument (period ≈ 4.2 s, amplitude 0.2 world units). The tilt maps scroll 0→1 onto rotation.x of 0.5 → 0.5 + 4π (≈ 13.07 rad) — the chair rotates head-over-heels two full revolutions across the whole 400vh scroll, always offset by the baseTilt of 0.5 rad. Rotation is absolute per-frame (recomputed from currentScroll, not incremented), so it tracks the scrollbar exactly and reverses when scrolling up.

7. Outro headline line-reveal (ScrollTrigger + SplitType)
const splitText = new SplitType(".outro-copy h2", { types:"lines", lineClass:"line" });
splitText.lines.forEach((line) => {
  const text = line.innerHTML;
  line.innerHTML = `<span style="display:block; transform:translateY(70px);">${text}</span>`;
});

ScrollTrigger.create({
  trigger: ".outro",
  start: "top center",
  toggleActions: "play reverse play reverse",
  onEnter: () => gsap.to(".outro-copy h2 .line span", {
    translateY: 0, duration: 1, stagger: 0.1, ease: "power3.out", force3D: true,
  }),
  onLeaveBack: () => gsap.to(".outro-copy h2 .line span", {
    translateY: 70, duration: 1, stagger: 0.1, ease: "power3.out", force3D: true,
  }),
});

Each headline line is a .line with overflow:hidden + clip-path (see Styling); its inner <span> starts at translateY(70px) (below the mask). When .outro's top hits the viewport center, the spans tween to translateY(0)duration 1s, power3.out, 0.1s stagger top-to-bottom — so the sentence wipes up line by line. Scrolling back up past the trigger pushes them back down to 70px (the reverse tween).

Assets / images

  • One 3D model file: a single GLB/glTF furniture model — a stylized designer chair works best — modeled on/near the origin, transparent (no baked background). The code auto-centers it and auto-frames the camera to 1.75 × maxDim, so any reasonably-scaled model fits. Material params are overridden in code (high metalness/roughness/envMapIntensity) for a glossy studio look. No texture atlas or other image assets are used.
  • No photographic images anywhere else — the piece is all type + the single 3D object on a flat #111111 background.

Behavior notes

  • Desktop-first / heavy: real-time WebGL + shadows + high-DPI render; not mobile-optimized (no reduced-motion or mobile fallback in the original).
  • The float is an infinite loop independent of scroll; the tilt is scroll-linked and clamped to [0,1] progress (it stops tumbling once you reach the bottom).
  • The model canvas is fixed and sits above the page (z-index:2, transparent clear color) so the scrolling type passes behind the floating chair.
  • Handle window resize by updating camera.aspect, camera.updateProjectionMatrix(), and renderer.setSize(...) if you want it robust (the original omits this).
  • gsap.ticker.lagSmoothing(0) is set so Lenis/GSAP time stays in lockstep on frame drops.