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:
- Dark canvas, always. The page background is a near-black
#111111with 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. - 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. - 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 ofstyles.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:
h1uppercase,font-size:225px; font-weight:400; line-height:0.85;.a, ptext-decoration:none; color:#fff; font-size:13px; font-weight:400; line-height:0.9;and<a>uppercase.p spanuses 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:100vhexcept.archivewhich isheight: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 itsps 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 h2into.linewrappers 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
#111111background.
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(), andrenderer.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.