All components

3D Parallax Reveal Footer

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

Open live demo ↗ Raw prompt (.md)

What it does

Footer pinned behind the page that un-parallaxes from translateY(-35%) to 0 as a scrubbed ScrollTrigger tracks the footer entering the viewport, while a Three.js GLB model inside it dollies from z:-1 to z:0 and untilts from rotation.x 0.5 to 0. A rAF loop adds mouse parallax, lerping the model's rotation toward pointer-driven targets; Lenis drives the smooth scroll.

How it's built

Categoryfooter
Techgsap, lenis, three
GSAP pluginsScrollTrigger
Complexitysection
Performance costheavy
Mobile-safeyes
Scrollhijacks scrolling

footer parallax 3d webgl three scroll lenis mouse glb

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 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)
  • h3 and a: clamp(1.25rem, 1.75vw, 2.5rem), font-weight: lighter
  • a: color: #fff, no underline
  • p: DM Sans, 0.9rem, weight 400

Reset / global

  • * { margin:0; padding:0; box-sizing:border-box }
  • Hide scrollbar: ::-webkit-scrollbar { display:none }

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

Footerposition: relative; width: 100%; height: 75svh; background: #171717; color: #fff; overflow: hidden (overflow hidden clips the parallaxing content and the oversized canvas).

.footer-containerposition: 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-canvasposition: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-contentposition:relative; width:100%; height:100%; display:flex; flex-direction:column; justify-content:space-between; z-index: 1.

.footer-rowdisplay: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..1
  • mouse.y = -(e.clientY / window.innerHeight) * 2 + 1 → range −1..1 (Y inverted for WebGL space)
3. Three.js scene
  • Mount into #footer-canvas (call it container).
  • 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); append renderer.domElement to 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 the translateY(-35%) → 0 parallax.
  • 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 from window mousemove, 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).