3D Parallax Reveal Footer
Goal
Build a full-page scroll layout where the last screen is a dark footer that is pinned behind the page and "un-parallaxes" into place as you scroll to it. The footer's inner content starts pushed up (translateY(-35%)) and slides down to 0% while a semi-transparent Three.js GLB model living inside the footer dollies toward the camera (z: -1 → 0) and un-tilts (rotation.x: 0.5 → 0) — both driven by the same scrubbed ScrollTrigger. On top of that, a requestAnimationFrame loop adds mouse parallax, lerping the model's rotation toward pointer-driven targets. Smooth scroll via Lenis.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, lenis for smooth scroll, and three for the WebGL model (import three and GLTFLoader from three/addons/loaders/GLTFLoader.js). No framework, no bund-specific APIs. Register the plugin with gsap.registerPlugin(ScrollTrigger).
Layout / HTML
Body is a vertical stack of three full-height spacer sections followed by the footer:
<section class="one"><h1>Section 1</h1></section>
<section class="two"><h1>Section 2</h1></section>
<section class="three"><h1>Section 3</h1></section>
<footer>
<div class="footer-container">
<div id="footer-canvas"></div> <!-- Three.js renderer mounts here -->
<div class="footer-content">
<div class="footer-row">
<div class="footer-col">
<h2>Restoring meaning to the things we build</h2>
</div>
<div class="footer-col">
<div class="footer-sub-col">
<h3>Work resumes</h3>
<h3>2026</h3>
</div>
<div class="footer-sub-col">
<a href="#">Write to me</a>
<a href="#">Professional orbit</a>
<a href="#">Loose thoughts</a>
<a href="#">Long form</a>
</div>
</div>
</div>
<div class="footer-row">
<p>Experiment 518</p>
<p>Built by Motionprompts</p>
</div>
</div>
</div>
</footer>
<script type="module" src="./script.js"></script>
Key classes the JS/CSS depend on: .footer-container (the element that gets the translateY parallax), #footer-canvas (Three.js mount), .footer-content, .footer-row, .footer-col, .footer-sub-col. The scroll trigger targets the raw footer element.
Styling
Fonts — load from Google Fonts: DM Sans (full optical/weight range) and Stack Sans Notch (weights 200..700). body uses "Stack Sans Notch", sans-serif; p uses "DM Sans", sans-serif.
Color tokens
--base-100: #d6eed9(pale mint) — section one bg, text black--base-200: #b30078(magenta) — section two bg, text white- section three bg
#ffa630(orange), text black --base-400: #171717(near-black) — footer bg, text white
Type scale (all headings font-weight: lighter; line-height: 1.25)
h1:clamp(4rem, 5vw, 6rem)h2:clamp(3rem, 4vw, 5rem)h3anda:clamp(1.25rem, 1.75vw, 2.5rem),font-weight: lightera:color: #fff, no underlinep: DM Sans,0.9rem, weight 400
Reset / global
* { margin:0; padding:0; box-sizing:border-box }- Hide scrollbar:
::-webkit-scrollbar { display:none }
Sections — position: relative; width: 100%; height: 100svh; padding: 2rem; display:flex; justify-content:center; align-items:center; z-index: 1. The z-index:1 and opaque backgrounds are what make the three sections cover the footer while scrolling; the footer is revealed only as those sections scroll away.
Footer — position: relative; width: 100%; height: 75svh; background: #171717; color: #fff; overflow: hidden (overflow hidden clips the parallaxing content and the oversized canvas).
.footer-container — position: relative; width:100%; height:100%; padding: 2rem; display:flex; flex-direction:column; justify-content:space-between; transform: translateY(-35%); will-change: transform. This is the initial parallax offset the scroll animation drives back to 0.
#footer-canvas — position:absolute; inset: 0 auto auto 0; top:0; left:0; width:100%; height:100%; pointer-events:none; opacity: 0.5; z-index: -1 (sits behind the text, dimmed).
.footer-content — position:relative; width:100%; height:100%; display:flex; flex-direction:column; justify-content:space-between; z-index: 1.
.footer-row — display:flex; justify-content:space-between.
- First row, col 1:
flex: 3. First row, col 2:flex: 2; display:flex; gap: 2rem. .footer-sub-col:flex:1; display:flex; flex-direction:column; gap: 0.5rem..footer-row h2 { width: 75% }.
Responsive @media (max-width:1000px): first .footer-row becomes flex-direction: column; gap: 4rem; .footer-row h2 becomes width: 100%.
GSAP + Three.js effect (be exact)
Wrap everything in DOMContentLoaded.
1. Lenis smooth scroll wiring
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
2. Normalized mouse tracking
Keep a mouse = { x: 0, y: 0 } object. On mousemove:
mouse.x = (e.clientX / window.innerWidth) * 2 - 1→ range −1..1mouse.y = -(e.clientY / window.innerHeight) * 2 + 1→ range −1..1 (Y inverted for WebGL space)
3. Three.js scene
- Mount into
#footer-canvas(call itcontainer). scene = new THREE.Scene().camera = new THREE.PerspectiveCamera(50, container.offsetWidth / container.offsetHeight, 0.1, 1000);camera.position.set(0, 0, 0.75).renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });renderer.setSize(container.offsetWidth, container.offsetHeight);renderer.setPixelRatio(window.devicePixelRatio); appendrenderer.domElementto the container.- One light:
const directionalLight = new THREE.DirectionalLight(0xffffff, 5); directionalLight.position.set(1, 1, 0); scene.add(directionalLight).
4. Load and normalize the GLB
Two module-scope base vars: let modelBaseRotationX = 0.5; and let modelBaseZ = -1;. Load with GLTFLoader from /c/3d-parallax-footer/model.glb. In the callback:
model = gltf.scene.- Compute bounds:
const box = new THREE.Box3().setFromObject(model); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); - Recenter and place:
model.position.sub(center); model.position.y = 0; model.position.z = -1; model.rotation.x = 0.5; - Fit to unit size:
const maxDim = Math.max(size.x, size.y, size.z); model.scale.setScalar(1 / maxDim); scene.add(model).
5. Scrubbed ScrollTrigger (the reveal) — the star
ScrollTrigger.create({
trigger: "footer",
start: "top bottom", // begins when footer's top hits viewport bottom
end: "bottom bottom", // ends when footer's bottom hits viewport bottom
scrub: true,
onUpdate: (self) => {
const progress = self.progress; // 0 → 1
const yValue = -35 * (1 - progress); // -35% → 0%
gsap.set(footerContainer, { y: `${yValue}%` }); // un-parallax the content
modelBaseZ = -1 * (1 - progress); // model dolly base: -1 → 0
modelBaseRotationX = 0.5 * (1 - progress); // model tilt base: 0.5 → 0
},
});
footerContainer is document.querySelector(".footer-container"). Note the trigger does not animate the model directly — it only rewrites the two base targets, and the rAF loop below eases the model toward them. There is no pin and no timeline; it is a single scrub tied to progress with gsap.set.
6. requestAnimationFrame loop (mouse parallax + eased follow)
function animate() {
requestAnimationFrame(animate);
if (model) {
const targetRotationY = mouse.x * 0.3; // yaw from pointer X
const targetRotationX = -mouse.y * 0.2 + modelBaseRotationX; // pitch from pointer Y + scroll base
model.rotation.y += (targetRotationY - model.rotation.y) * 0.05;
model.rotation.x += (targetRotationX - model.rotation.x) * 0.05;
model.position.z += (modelBaseZ - model.position.z) * 0.05; // eased dolly toward scroll base
}
renderer.render(scene, camera);
}
animate();
The lerp factor is 0.05 on all three (rotation.y, rotation.x, position.z), giving a soft, laggy follow. Pointer amplitude: ±0.3 rad yaw, ±0.2 rad pitch. The scroll base values (modelBaseZ, modelBaseRotationX) are constantly folded in, so scroll and mouse compose in the same loop.
7. Resize
On window resize: camera.aspect = container.offsetWidth / container.offsetHeight; camera.updateProjectionMatrix(); renderer.setSize(container.offsetWidth, container.offsetHeight).
Assets
- One GLB 3D model (
/c/3d-parallax-footer/model.glb): a single small decorative/sculptural object. It is auto-centered, auto-scaled to a unit bounding box, lit by one white directional light, rendered on a transparent canvas at 50% opacity behind the footer text. Treat it as an ambient background element, not a hero object — any compact glTF/GLB mesh works. No raster images are used anywhere in the component.
Behavior notes
- The three opaque, full-height sections physically cover the footer (via
z-index+ solid backgrounds); the footer only appears as they scroll off, which is what sells the "reveal from behind" illusion together with thetranslateY(-35%) → 0parallax. - The model motion is intentionally decoupled from the scrub: the ScrollTrigger sets *targets*, the rAF loop *eases* toward them, so the model keeps drifting/settling even after scroll stops.
- WebGL canvas uses
pointer-events: none; mouse parallax reads fromwindowmousemove, so it works even though the canvas ignores pointer events. - No reduced-motion guard in the original; the loop runs continuously. Everything degrades gracefully if the GLB fails to load (the
if (model)guard just skips the model updates and the footer still parallaxes).