f04

Parallax Hero

Free

A pinned hero that pulls its layers into depth from one scrubbed ScrollTrigger: a headline sandwiched inside the stack, ridges or photographs climbing over it, and a sun that sets behind the far layer.

ScrollTrigger Lenis intermediate
4 more details
parallaxscrubpinningscroll-progress

This demo reads better at your own screen size than in the frame below:

Updated

About this effect

A layered parallax hero where the headline lives inside the depth stack rather than on top of it. Every layer is a plain wrapper carrying one data-parallax-speed attribute: the sun at a negative speed sets behind the far ridge, the ridges sink at their own rates, and the near ridge with its trees climbs up over the headline as you scroll. A second layer set swaps the ink silhouettes for photographs (a full-bleed landscape, two photo tiles rising over the type) to show the same mechanic on real imagery.

Read the full effect overview

One pinned, scrubbed ScrollTrigger drives all of it, and on a fine pointer the same depth values drive a cursor drift so the scene is alive before anyone scrolls. The script never references a class name, so reskinning the hero is a matter of replacing what sits inside the layers.

Parallax Hero - GSAP animation effect preview

What's included

8 items
  • Data-speed API: assign data-parallax-speed to any text, image, SVG, card, or decorative wrapper; negative values move against the scroll
  • Multi-channel response combines y translation with optional x travel, rotation, scale, blur, and opacity from the same progress
  • Optional pointer drift on fine-pointer devices, driven by the same depth values, tweened on separate transform components so it never fights the scroll
  • Measured pinned runway gives every depth layer time to separate without a dead intro or filler outro
  • Responsive travel and runway attributes keep the same depth hierarchy restrained on mobile
  • Two shipped layer sets in one scene: ink silhouettes and baked photographs, switched by one attribute
  • One scrubbed ScrollTrigger per hero coordinates every layer and the optional scroll meter
  • Lenis integration shares GSAP's ticker; reduced-motion and no-JavaScript fallbacks preserve the complete static composition

Perfect for

5 use cases
  • Outdoor, travel, and destination heroes built around a landscape, illustrated or photographed
  • Agency and studio landing pages that need depth on the first screen without a video
  • Product launches with a layered headline and foreground detail panels
  • Editorial features that need a reversible pinned opening moment
  • Any hero where the headline should sit inside the scene rather than in front of it

How it works

2 sections

Each data-parallax container creates one GSAP timeline with a pinned, scrubbed ScrollTrigger. Every data-parallax-speed layer receives a y translation calculated from its distance from speed 1: lower values lag toward the background, higher values move ahead as foreground, and a negative value travels against the scroll, which is how the sun sets while everything else rises. Optional data-parallax-x, data-parallax-rotate, data-parallax-scale, data-parallax-blur, and data-parallax-opacity attributes add synchronised depth cues, and an optional data-parallax-fill bar and data-parallax-progress readout track the same timeline.

With data-parallax-pointer set on the scene, fine-pointer devices also get a cursor drift: each layer is offset by its depth through gsap.quickTo on xPercent and yPercent, separate transform components from the scroll's pixel y, so the two inputs compose instead of competing. gsap.matchMedia selects shorter mobile values or a fully static reduced-motion state, and the Lenis ticker, refresh listener, pointer handlers, timeline, and ScrollTrigger are all removed during cleanup.

Plugins ScrollTrigger
Difficulty Intermediate
Smooth scroll Lenis integration
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Parallax Hero Demo | GSAP Vault</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Mona+Sans:wght@500;600;700&display=swap">
    <link rel="stylesheet" href="assets/style.css">
</head>
<body>
    <main>
        <section
            class="hero"
            data-parallax
            data-parallax-distance="260"
            data-parallax-mobile-distance="150"
            data-parallax-runway="720"
            data-parallax-mobile-runway="440"
            data-parallax-pointer="1.6"
            data-mode="illustration"
            aria-labelledby="hero-title">

            <!-- Photo mode, speed 0.5: a full-bleed photograph that recedes,
                 grows and softens. Hidden while the scene is in illustration mode. -->
            <div class="layer layer--photo" data-parallax-speed="0.5" data-parallax-scale="1.12" data-parallax-blur="3" aria-hidden="true">
                <img src="assets/img/backdrop.webp" alt="" width="1800" height="1125" decoding="async">
            </div>

            <!-- Photo mode, speed 0.6: the page-coloured wash that lets ink type sit on the photograph. -->
            <div class="layer layer--wash" data-parallax-speed="0.6" aria-hidden="true"></div>

            <!-- Speed -0.15: the sun moves against the scroll, so it sets behind the far ridge. -->
            <div class="layer layer--sun" data-parallax-speed="-0.15" aria-hidden="true">
                <span class="sun"></span>
            </div>

            <!-- Speed 0.5: the far ridge, lightest, drifts down and softens. -->
            <div class="layer layer--far" data-parallax-speed="0.5" data-parallax-blur="3" aria-hidden="true">
                <svg class="ridge" viewBox="0 0 1600 700" preserveAspectRatio="xMidYMax slice" focusable="false"><path d="M0 700 L0 318.6 L66.7 307.3 L133.3 284.7 L200.0 267.7 L266.7 276.1 L333.3 299.8 L400.0 313.2 L466.7 306.1 L533.3 290.7 L600.0 277.4 L666.7 277.2 L733.3 278.1 L800.0 276.1 L866.7 294.2 L933.3 317.0 L1000.0 318.8 L1066.7 300.8 L1133.3 263.3 L1200.0 238.2 L1266.7 251.1 L1333.3 275.4 L1400.0 295.5 L1466.7 313.9 L1533.3 338.2 L1600.0 352.7 L1600 1400 L0 1400 Z"/></svg>
            </div>

            <!-- Speed 0.7: the mid ridge. -->
            <div class="layer layer--mid" data-parallax-speed="0.7" aria-hidden="true">
                <svg class="ridge" viewBox="0 0 1600 700" preserveAspectRatio="xMidYMax slice" focusable="false"><path d="M0 700 L0 421.3 L50.0 416.4 L100.0 406.1 L150.0 396.7 L200.0 394.0 L250.0 400.8 L300.0 414.5 L350.0 432.3 L400.0 441.3 L450.0 433.7 L500.0 419.6 L550.0 411.0 L600.0 407.8 L650.0 415.9 L700.0 435.7 L750.0 458.8 L800.0 469.9 L850.0 464.0 L900.0 450.6 L950.0 436.6 L1000.0 433.7 L1050.0 439.5 L1100.0 444.7 L1150.0 450.6 L1200.0 453.5 L1250.0 448.9 L1300.0 440.3 L1350.0 434.4 L1400.0 430.9 L1450.0 437.8 L1500.0 457.1 L1550.0 479.4 L1600.0 490.2 L1600 1400 L0 1400 Z"/></svg>
            </div>

            <!-- Speed 0.8: a band of mist over the valley, thinning as the scene compresses. -->
            <div class="layer layer--mist" data-parallax-speed="0.8" data-parallax-opacity="0.35" aria-hidden="true"></div>

            <!-- Speed 0.9: the headline sits INSIDE the depth stack, behind the near ridge. -->
            <div class="layer layer--headline" data-parallax-speed="0.9">
                <h1 class="hero-title" id="hero-title">Higher<br>ground.</h1>
            </div>

            <!-- Speed 1.7: the near ridge and its trees climb over the headline. -->
            <div class="layer layer--near" data-parallax-speed="1.7" data-parallax-scale="1.06" aria-hidden="true">
                <svg class="ridge" viewBox="0 0 1600 700" preserveAspectRatio="xMidYMax slice" focusable="false">
                    <path d="M0 700 L0 535.8 C40.0 535.8 40.0 538.6 80.0 538.6 C120.0 538.6 120.0 545.1 160.0 545.1 C200.0 545.1 200.0 553.8 240.0 553.8 C280.0 553.8 280.0 564.8 320.0 564.8 C360.0 564.8 360.0 570.4 400.0 570.4 C440.0 570.4 440.0 564.6 480.0 564.6 C520.0 564.6 520.0 552.6 560.0 552.6 C600.0 552.6 600.0 543.5 640.0 543.5 C680.0 543.5 680.0 542.4 720.0 542.4 C760.0 542.4 760.0 543.5 800.0 543.5 C840.0 543.5 840.0 541.2 880.0 541.2 C920.0 541.2 920.0 537.0 960.0 537.0 C1000.0 537.0 1000.0 532.4 1040.0 532.4 C1080.0 532.4 1080.0 521.0 1120.0 521.0 C1160.0 521.0 1160.0 514.1 1200.0 514.1 C1240.0 514.1 1240.0 522.3 1280.0 522.3 C1320.0 522.3 1320.0 535.0 1360.0 535.0 C1400.0 535.0 1400.0 535.3 1440.0 535.3 C1480.0 535.3 1480.0 523.2 1520.0 523.2 C1560.0 523.2 1560.0 515.4 1600.0 515.4 L1600 1400 L0 1400 Z"/>
                    <path class="pines" d="M439.2 572.6 L452.6 540.5 L466.1 572.6 Z M442.1 554.4 L452.6 527.2 L463.1 554.4 Z M445.1 536.3 L452.6 513.8 L460.2 536.3 Z M948.2 542.8 L962.7 508.2 L977.3 542.8 Z M951.4 523.2 L962.7 493.8 L974.1 523.2 Z M954.6 503.6 L962.7 479.3 L970.9 503.6 Z M571.8 555.1 L590.9 509.5 L610.1 555.1 Z M576.0 529.3 L590.9 490.5 L605.9 529.3 Z M580.2 503.4 L590.9 471.5 L601.6 503.4 Z M40.6 543.7 L55.2 508.8 L69.9 543.7 Z M43.8 523.9 L55.2 494.3 L66.7 523.9 Z M47.0 504.2 L55.2 479.8 L63.4 504.2 Z M1357.1 541.1 L1370.7 508.6 L1384.3 541.1 Z M1360.1 522.7 L1370.7 495.1 L1381.3 522.7 Z M1363.1 504.3 L1370.7 481.6 L1378.3 504.3 Z M1114.5 526.0 L1132.0 484.3 L1149.5 526.0 Z M1118.3 502.4 L1132.0 466.9 L1145.6 502.4 Z M1122.2 478.8 L1132.0 449.6 L1141.8 478.8 Z M516.2 563.4 L527.6 536.4 L538.9 563.4 Z M518.7 548.1 L527.6 525.2 L536.4 548.1 Z M521.2 532.8 L527.6 513.9 L533.9 532.8 Z M951.7 542.3 L971.1 496.3 L990.4 542.3 Z M956.0 516.3 L971.1 477.1 L986.2 516.3 Z M960.3 490.2 L971.1 457.9 L981.9 490.2 Z M1348.1 541.1 L1369.8 489.5 L1391.5 541.1 Z M1352.9 511.8 L1369.8 468.0 L1386.7 511.8 Z M1357.7 482.6 L1369.8 446.5 L1381.9 482.6 Z M1467.5 534.7 L1483.5 496.4 L1499.6 534.7 Z M1471.0 513.0 L1483.5 480.5 L1496.1 513.0 Z M1474.5 491.3 L1483.5 464.6 L1492.5 491.3 Z M1467.9 535.0 L1481.4 502.9 L1494.9 535.0 Z M1470.9 516.8 L1481.4 489.5 L1491.9 516.8 Z M1473.8 498.6 L1481.4 476.1 L1489.0 498.6 Z M1175.4 521.0 L1190.2 485.8 L1204.9 521.0 Z M1178.7 501.1 L1190.2 471.2 L1201.7 501.1 Z M1181.9 481.2 L1190.2 456.6 L1198.4 481.2 Z M997.8 540.3 L1007.6 517.1 L1017.3 540.3 Z M1000.0 527.1 L1007.6 507.4 L1015.2 527.1 Z M1002.1 514.0 L1007.6 497.8 L1013.0 514.0 Z M500.3 564.9 L518.1 522.5 L535.9 564.9 Z M504.2 540.9 L518.1 504.9 L531.9 540.9 Z M508.1 516.9 L518.1 487.2 L528.0 516.9 Z M984.7 540.8 L998.0 509.1 L1011.3 540.8 Z M987.6 522.9 L998.0 496.0 L1008.3 522.9 Z M990.5 504.9 L998.0 482.8 L1005.4 504.9 Z M809.2 548.8 L824.4 512.5 L839.7 548.8 Z M812.6 528.3 L824.4 497.4 L836.3 528.3 Z M815.9 507.7 L824.4 482.3 L833.0 507.7 Z M1152.0 522.9 L1167.7 485.6 L1183.4 522.9 Z M1155.5 501.8 L1167.7 470.0 L1179.9 501.8 Z M1158.9 480.6 L1167.7 454.5 L1176.5 480.6 Z M516.1 561.6 L540.1 504.4 L564.1 561.6 Z M521.4 529.2 L540.1 480.5 L558.8 529.2 Z M526.6 496.7 L540.1 456.7 L553.6 496.7 Z M644.2 549.2 L662.0 506.9 L679.7 549.2 Z M648.1 525.3 L662.0 489.3 L675.8 525.3 Z M652.0 501.3 L662.0 471.7 L671.9 501.3 Z M1440.5 539.6 L1451.2 514.1 L1461.9 539.6 Z M1442.8 525.1 L1451.2 503.5 L1459.5 525.1 Z M1445.2 510.7 L1451.2 492.9 L1457.1 510.7 Z M1162.0 522.4 L1174.0 493.8 L1186.0 522.4 Z M1164.7 506.2 L1174.0 481.9 L1183.4 506.2 Z M1167.3 490.0 L1174.0 470.0 L1180.7 490.0 Z M1538.0 525.2 L1561.1 470.2 L1584.3 525.2 Z M1543.1 494.0 L1561.1 447.2 L1579.2 494.0 Z M1548.2 462.8 L1561.1 424.3 L1574.1 462.8 Z M1361.3 541.1 L1372.1 515.4 L1382.9 541.1 Z M1363.7 526.5 L1372.1 504.7 L1380.5 526.5 Z M1366.1 512.0 L1372.1 494.0 L1378.2 512.0 Z M619.0 549.5 L641.6 495.5 L664.3 549.5 Z M623.9 518.9 L641.6 473.1 L659.3 518.9 Z M628.9 488.3 L641.6 450.6 L654.3 488.3 Z M1217.8 523.8 L1235.9 480.8 L1254.0 523.8 Z M1221.8 499.4 L1235.9 462.8 L1250.0 499.4 Z M1225.8 475.0 L1235.9 444.9 L1246.0 475.0 Z M290.3 568.6 L304.2 535.7 L318.0 568.6 Z M293.4 550.0 L304.2 522.0 L314.9 550.0 Z M296.4 531.3 L304.2 508.3 L311.9 531.3 Z"/>
                </svg>
            </div>

            <!-- Photo mode, speeds 1.4 and 1.8: two photographs climb over the headline. -->
            <div class="layer layer--tile layer--tile-a" data-parallax-speed="1.4" data-parallax-rotate="-5" aria-hidden="true">
                <figure class="tile"><img src="assets/img/tile-1.webp" alt="" width="560" height="720" decoding="async"></figure>
            </div>
            <div class="layer layer--tile layer--tile-b" data-parallax-speed="1.8" data-parallax-x="28" data-parallax-rotate="4" aria-hidden="true">
                <figure class="tile"><img src="assets/img/tile-2.webp" alt="" width="560" height="720" decoding="async"></figure>
            </div>

            <!-- Speed 1.15: the lede and link ride in front of everything. -->
            <div class="layer layer--deck" data-parallax-speed="1.15">
                <div class="deck">
                    <p class="hero-lede">Four days over the Kintail ridge in a group of eight. Next departure in May.</p>
                    <a class="hero-link" href="#routes">See the route</a>
                </div>
            </div>

            <!-- Not a layer: the demo's own switch between the two layer sets. -->
            <div class="mode-switch" role="group" aria-label="Demo layers">
                <button type="button" data-mode-control="illustration" aria-pressed="true">Illustration</button>
                <button type="button" data-mode-control="photo" aria-pressed="false">Photo</button>
            </div>

            <!-- Not a layer: the cue stays put while the scene compresses behind it. -->
            <div class="scroll-cue" aria-hidden="true">
                <span>Scroll</span>
                <span class="scroll-cue__track"><span data-parallax-fill></span></span>
            </div>
        </section>

        <section class="after" id="routes">
            <h2>Six routes, one valley</h2>
            <p>Every walk starts and ends at the lodge. Boots dry by the stove overnight, breakfast is at seven, and the last thing you hear before sleep is the river.</p>
        </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
    <script src="https://unpkg.com/lenis@1.3.17/dist/lenis.min.js"></script>
    <script src="assets/script.js"></script>
    <script>
        // Demo chrome, not the effect: the switch swaps which layer set is
        // shown. Every layer is always animated; the hidden set just does not
        // paint, so nothing has to be rebuilt.
        (function () {
            var scene = document.querySelector('[data-parallax]');
            var controls = document.querySelectorAll('[data-mode-control]');
            if (!scene || !controls.length) return;
            controls.forEach(function (control) {
                control.addEventListener('click', function () {
                    var mode = control.getAttribute('data-mode-control');
                    scene.setAttribute('data-mode', mode);
                    controls.forEach(function (other) {
                        other.setAttribute('aria-pressed', String(other === control));
                    });
                });
            });
        })();
    </script>
</body>
</html>
/**
 * Parallax Hero
 *
 * A pinned, scrubbed depth scene. Add data-parallax to a hero and give each
 * moving layer a data-parallax-speed value: values below 1 recede while values
 * above 1 move into the foreground. On a fine pointer the same depth values
 * also drive an optional cursor drift (data-parallax-pointer on the scene).
 *
 * @plugins ScrollTrigger
 * @techniques parallax, scrub, pinning
 */

gsap.registerPlugin(ScrollTrigger);

(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
        init();
    }
})(function initParallaxHero() {
    const wantsSmooth = (new URLSearchParams(location.search).get('smooth')
        || document.documentElement.dataset.smooth) !== 'off'
        && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    let lenis = null;
    let lenisTick = null;
    let refreshLenis = null;

    if (wantsSmooth && typeof Lenis !== 'undefined') {
        lenis = new Lenis({ autoRaf: false, duration: 1.05, smoothWheel: true });
        lenis.on('scroll', ScrollTrigger.update);
        lenisTick = function tickLenis(time) { lenis.raf(time * 1000); };
        refreshLenis = function syncLenisAfterRefresh() {
            lenis.scrollTo(window.scrollY, { immediate: true, force: true });
        };
        gsap.ticker.add(lenisTick);
        gsap.ticker.lagSmoothing(0);
        ScrollTrigger.addEventListener('refresh', refreshLenis);
    }

    // A layer's depth is its distance from speed 1: negative recedes, positive
    // advances. Both the scroll travel and the pointer drift read it.
    function layerSpeed(layer) {
        const parsed = parseFloat(layer.dataset.parallaxSpeed);
        return Number.isFinite(parsed) ? parsed : 1;
    }

    const ctx = gsap.context(function effectContext() {
        const mm = gsap.matchMedia();

        mm.add({
            desktop: '(min-width: 769px) and (prefers-reduced-motion: no-preference)',
            mobile: '(max-width: 768px) and (prefers-reduced-motion: no-preference)',
            pointer: '(hover: hover) and (pointer: fine)',
            reduced: '(prefers-reduced-motion: reduce)'
        }, function responsiveBranch(context) {
            const conditions = context.conditions;
            const scenes = gsap.utils.toArray('[data-parallax]');

            if (conditions.reduced) {
                gsap.set('[data-parallax-speed]', {
                    clearProps: 'transform,filter,opacity,visibility'
                });
                return;
            }

            const isMobile = conditions.mobile;
            const triggers = [];
            const pointerHandlers = new Map();

            scenes.forEach(function createScene(scene) {
                const layers = gsap.utils.toArray('[data-parallax-speed]', scene);
                if (!layers.length) return;

                const distance = parseFloat(isMobile
                    ? scene.dataset.parallaxMobileDistance
                    : scene.dataset.parallaxDistance) || (isMobile ? 128 : 236);
                const runway = parseFloat(isMobile
                    ? scene.dataset.parallaxMobileRunway
                    : scene.dataset.parallaxRunway) || (isMobile ? 340 : 560);
                const progressValue = scene.querySelector('[data-parallax-progress]');
                const fill = scene.querySelector('[data-parallax-fill]');
                const state = { progress: 0 };

                const timeline = gsap.timeline({
                    defaults: { duration: 1, ease: 'none', force3D: true },
                    scrollTrigger: {
                        trigger: scene,
                        start: 'top top',
                        end: function endPosition() { return '+=' + runway; },
                        pin: true,
                        scrub: isMobile ? 0.35 : 0.5,
                        invalidateOnRefresh: true
                    }
                });

                layers.forEach(function animateLayer(layer) {
                    const speed = layerSpeed(layer);
                    const y = (1 - speed) * distance;
                    const xTo = parseFloat(layer.dataset.parallaxX);
                    const rotateTo = parseFloat(layer.dataset.parallaxRotate);
                    const scaleTo = parseFloat(layer.dataset.parallaxScale);
                    const blurTo = parseFloat(layer.dataset.parallaxBlur);
                    const opacityTo = parseFloat(layer.dataset.parallaxOpacity);
                    const toVars = { y: y };

                    if (Number.isFinite(xTo)) toVars.x = isMobile ? xTo * 0.58 : xTo;
                    if (Number.isFinite(rotateTo)) toVars.rotation = isMobile ? rotateTo * 0.62 : rotateTo;
                    if (Number.isFinite(scaleTo)) toVars.scale = scaleTo;
                    if (Number.isFinite(blurTo)) toVars.filter = 'blur(' + blurTo + 'px)';
                    if (Number.isFinite(opacityTo)) toVars.autoAlpha = opacityTo;
                    timeline.to(layer, toVars, 0);
                });

                if (fill) timeline.to(fill, { scaleX: 1 }, 0);
                if (progressValue) {
                    timeline.to(state, {
                        progress: 100,
                        onUpdate: function updateReadout() {
                            if (progressValue.isConnected) {
                                progressValue.textContent = String(Math.round(state.progress)).padStart(3, '0');
                            }
                        }
                    }, 0);
                }

                triggers.push(timeline.scrollTrigger);

                // Pointer drift. The scroll timeline owns x and y in pixels, so
                // the drift tweens xPercent and yPercent instead: separate
                // transform components, no fight over one property. Strength
                // is the percentage a layer at depth 1 travels for a cursor at
                // the scene's edge; deeper and nearer layers scale with depth,
                // and move against each other, which is what reads as a window.
                const drift = parseFloat(scene.dataset.parallaxPointer);
                if (!conditions.pointer || !(drift > 0)) return;

                const setters = layers.map(function makeSetter(layer) {
                    return {
                        depth: layerSpeed(layer) - 1,
                        x: gsap.quickTo(layer, 'xPercent', { duration: 1.1, ease: 'power3' }),
                        y: gsap.quickTo(layer, 'yPercent', { duration: 1.1, ease: 'power3' })
                    };
                });

                function onPointerMove(event) {
                    const rect = scene.getBoundingClientRect();
                    const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
                    const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
                    setters.forEach(function driftLayer(setter) {
                        setter.x(-nx * setter.depth * drift);
                        setter.y(-ny * setter.depth * drift * 0.6);
                    });
                }

                function onPointerLeave() {
                    setters.forEach(function settleLayer(setter) {
                        setter.x(0);
                        setter.y(0);
                    });
                }

                scene.addEventListener('pointermove', onPointerMove);
                scene.addEventListener('pointerleave', onPointerLeave);
                pointerHandlers.set(scene, { move: onPointerMove, leave: onPointerLeave });
            });

            return function cleanupMotion() {
                triggers.forEach(function killTrigger(trigger) { trigger.kill(); });
                pointerHandlers.forEach(function removeHandlers(handlers, scene) {
                    scene.removeEventListener('pointermove', handlers.move);
                    scene.removeEventListener('pointerleave', handlers.leave);
                });
                pointerHandlers.clear();
            };
        });
    });

    function teardown() {
        window.removeEventListener('beforeunload', teardown);
        if (ctx) ctx.revert();
        if (refreshLenis) ScrollTrigger.removeEventListener('refresh', refreshLenis);
        if (lenisTick) gsap.ticker.remove(lenisTick);
        if (lenis) lenis.destroy();
    }

    window.gsapContext = ctx;
    window.addEventListener('beforeunload', teardown, { once: true });
});
gsap.registerPlugin(ScrollTrigger),function(e){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}(function(){const e="off"!==(new URLSearchParams(location.search).get("smooth")||document.documentElement.dataset.smooth)&&!window.matchMedia("(prefers-reduced-motion: reduce)").matches;let t=null,a=null,r=null;function n(e){const t=parseFloat(e.dataset.parallaxSpeed);return Number.isFinite(t)?t:1}e&&"undefined"!=typeof Lenis&&(t=new Lenis({autoRaf:!1,duration:1.05,smoothWheel:!0}),t.on("scroll",ScrollTrigger.update),a=function(e){t.raf(1e3*e)},r=function(){t.scrollTo(window.scrollY,{immediate:!0,force:!0})},gsap.ticker.add(a),gsap.ticker.lagSmoothing(0),ScrollTrigger.addEventListener("refresh",r));const o=gsap.context(function(){gsap.matchMedia().add({desktop:"(min-width: 769px) and (prefers-reduced-motion: no-preference)",mobile:"(max-width: 768px) and (prefers-reduced-motion: no-preference)",pointer:"(hover: hover) and (pointer: fine)",reduced:"(prefers-reduced-motion: reduce)"},function(e){const t=e.conditions,a=gsap.utils.toArray("[data-parallax]");if(t.reduced)return void gsap.set("[data-parallax-speed]",{clearProps:"transform,filter,opacity,visibility"});const r=t.mobile,o=[],i=new Map;return a.forEach(function(e){const a=gsap.utils.toArray("[data-parallax-speed]",e);if(!a.length)return;const s=parseFloat(r?e.dataset.parallaxMobileDistance:e.dataset.parallaxDistance)||(r?128:236),l=parseFloat(r?e.dataset.parallaxMobileRunway:e.dataset.parallaxRunway)||(r?340:560),c=e.querySelector("[data-parallax-progress]"),d=e.querySelector("[data-parallax-fill]"),p={progress:0},u=gsap.timeline({defaults:{duration:1,ease:"none",force3D:!0},scrollTrigger:{trigger:e,start:"top top",end:function(){return"+="+l},pin:!0,scrub:r?.35:.5,invalidateOnRefresh:!0}});a.forEach(function(e){const t=(1-n(e))*s,a=parseFloat(e.dataset.parallaxX),o=parseFloat(e.dataset.parallaxRotate),i=parseFloat(e.dataset.parallaxScale),l=parseFloat(e.dataset.parallaxBlur),c=parseFloat(e.dataset.parallaxOpacity),d={y:t};Number.isFinite(a)&&(d.x=r?.58*a:a),Number.isFinite(o)&&(d.rotation=r?.62*o:o),Number.isFinite(i)&&(d.scale=i),Number.isFinite(l)&&(d.filter="blur("+l+"px)"),Number.isFinite(c)&&(d.autoAlpha=c),u.to(e,d,0)}),d&&u.to(d,{scaleX:1},0),c&&u.to(p,{progress:100,onUpdate:function(){c.isConnected&&(c.textContent=String(Math.round(p.progress)).padStart(3,"0"))}},0),o.push(u.scrollTrigger);const f=parseFloat(e.dataset.parallaxPointer);if(!(t.pointer&&f>0))return;const g=a.map(function(e){return{depth:n(e)-1,x:gsap.quickTo(e,"xPercent",{duration:1.1,ease:"power3"}),y:gsap.quickTo(e,"yPercent",{duration:1.1,ease:"power3"})}});function m(t){const a=e.getBoundingClientRect(),r=(t.clientX-a.left)/a.width*2-1,n=(t.clientY-a.top)/a.height*2-1;g.forEach(function(e){e.x(-r*e.depth*f),e.y(-n*e.depth*f*.6)})}function h(){g.forEach(function(e){e.x(0),e.y(0)})}e.addEventListener("pointermove",m),e.addEventListener("pointerleave",h),i.set(e,{move:m,leave:h})}),function(){o.forEach(function(e){e.kill()}),i.forEach(function(e,t){t.removeEventListener("pointermove",e.move),t.removeEventListener("pointerleave",e.leave)}),i.clear()}})});window.gsapContext=o,window.addEventListener("beforeunload",function e(){window.removeEventListener("beforeunload",e),o&&o.revert(),r&&ScrollTrigger.removeEventListener("refresh",r),a&&gsap.ticker.remove(a),t&&t.destroy()},{once:!0})});
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

:root {
    --page: #ececeb;
    --surface: #f7f7f6;
    --ink: #141412;
    --chalk: #f1efea;
    --muted: rgba(20, 20, 18, 0.72);
    --chalk-muted: rgba(241, 239, 234, 0.72);
    --rule: rgba(20, 20, 18, 0.14);
    --chalk-rule: rgba(241, 239, 234, 0.22);
    /* Atmospheric perspective: each ridge nearer the viewer is darker, until
       the nearest is the page's ink. */
    --ridge-far: #c6c6c3;
    --ridge-mid: #7a7a76;
    --ridge-near: #141412;
    /* One accent: the sun the scene sets, the link's mark, the focus ring,
       the scroll fill and ::selection. */
    --accent: #e5322d;
    --accent-ink: #ffffff;
    color-scheme: light;
}

html {
    background: var(--page);
    scroll-behavior: auto;
}

body {
    min-width: 320px;
    overflow-x: hidden;
    background: var(--page);
    color: var(--ink);
    font-family: 'Mona Sans', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
}

::selection {
    background: var(--accent);
    color: var(--accent-ink);
}

/* ---------------------------------------------------------------
   The scene
   --------------------------------------------------------------- */

.hero {
    position: relative;
    isolation: isolate;
    width: 100%;
    height: 100svh;
    min-height: 560px;
    overflow: clip;
    /* The sky: the page, lifting slightly toward the horizon. It is the
       section's own background, so it is the one thing that never moves. */
    background: linear-gradient(180deg, var(--page) 0%, var(--page) 34%, var(--surface) 62%, #dcdcda 100%);
}

.layer {
    position: absolute;
    inset: 0;
    pointer-events: none;
    will-change: transform;
}

/* --- The ridges ------------------------------------------------- */

/* Each ridge is an SVG whose fill runs on to twice the viewBox height, and
   overflow is visible, so a ridge that rises 180px never exposes the sky
   beneath its own foot. The viewBox is sliced to the layer's width, so the
   silhouette keeps its shape on any aspect ratio. */
.ridge {
    position: absolute;
    inset: 0;
    display: block;
    width: 100%;
    height: 100%;
    overflow: visible;
}

.layer--far {
    z-index: 1;
    transform-origin: 50% 100%;
}

.layer--far .ridge {
    fill: var(--ridge-far);
}

.layer--mid {
    z-index: 2;
}

.layer--mid .ridge {
    fill: var(--ridge-mid);
}

.layer--near {
    z-index: 5;
    /* Scaling from the foot pushes the ridge up as well as out. */
    transform-origin: 50% 100%;
}

.layer--near .ridge {
    fill: var(--ridge-near);
}

/* --- Photo mode: the photograph, its wash, and two tiles ------------ */

/* A photograph that recedes and grows needs slack past the scene, or its
   travel and its ending scale drag an edge into view. */
.layer--photo {
    inset: -24%;
    z-index: 0;
    transform-origin: 50% 55%;
}

.layer--photo img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: 50% 50%;
}

.layer--wash {
    inset: -22%;
    z-index: 1;
    background:
        linear-gradient(0deg, rgba(20, 20, 18, 0.78) 0%, rgba(20, 20, 18, 0.5) 22%, rgba(20, 20, 18, 0) 44%),
        linear-gradient(90deg, rgba(236, 236, 235, 0.9) 0%, rgba(236, 236, 235, 0.72) 30%, rgba(236, 236, 235, 0.2) 58%, rgba(236, 236, 235, 0) 78%),
        linear-gradient(180deg, rgba(236, 236, 235, 0.55) 0%, rgba(236, 236, 235, 0) 30%);
}

.layer--tile {
    z-index: 5;
}

.tile {
    position: absolute;
    width: clamp(150px, 19vw, 250px);
    aspect-ratio: 7 / 9;
    overflow: hidden;
    border-radius: 6px;
    box-shadow: 0 1px 0 rgba(20, 20, 18, 0.12), 0 24px 48px -20px rgba(20, 20, 18, 0.45);
}

.tile img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.layer--tile-a .tile {
    top: 44%;
    left: 67%;
}

.layer--tile-b .tile {
    top: 58%;
    left: 42%;
}

/* Which set paints. Both sets are always in the scene and always animated;
   the switch only decides which one is visible. */
.hero[data-mode="illustration"] .layer--photo,
.hero[data-mode="illustration"] .layer--wash,
.hero[data-mode="illustration"] .layer--tile,
.hero[data-mode="photo"] .layer--sun,
.hero[data-mode="photo"] .layer--far,
.hero[data-mode="photo"] .layer--mid,
.hero[data-mode="photo"] .layer--mist,
.hero[data-mode="photo"] .layer--near {
    display: none;
}

/* --- The sun ---------------------------------------------------- */

.layer--sun {
    z-index: 0;
}

.sun {
    position: absolute;
    top: 12%;
    left: 61%;
    width: clamp(88px, 12vw, 168px);
    height: clamp(88px, 12vw, 168px);
    border-radius: 50%;
    background: var(--accent);
}

/* --- The mist --------------------------------------------------- */

/* A page-coloured band across the valley floor. It is a gradient, not a
   filter, so it composites for free while it travels. */
.layer--mist {
    top: 48%;
    bottom: 14%;
    z-index: 3;
    background: linear-gradient(180deg, rgba(236, 236, 235, 0) 0%, rgba(236, 236, 235, 0.85) 38%, rgba(236, 236, 235, 0.85) 62%, rgba(236, 236, 235, 0) 100%);
}

/* --- The headline, inside the stack ------------------------------ */

.layer--headline {
    z-index: 4;
    padding: clamp(4.5rem, 12vh, 7.5rem) clamp(1.5rem, 6vw, 6rem) 0;
}

.hero-title {
    font-size: clamp(4.4rem, 11.4vw, 10.5rem);
    font-weight: 700;
    letter-spacing: -0.045em;
    line-height: 0.88;
    text-wrap: balance;
}

/* --- The deck, in front of the stack ------------------------------ */

/* The deck stands on the near ridge, so it is set in chalk. */
.layer--deck {
    z-index: 6;
    display: grid;
    align-content: end;
    padding: 0 clamp(1.5rem, 6vw, 6rem) clamp(2.75rem, 7vh, 4rem);
    color: var(--chalk);
}

.deck {
    max-width: 22rem;
    pointer-events: auto;
}

.hero-lede {
    color: var(--chalk-muted);
    font-size: clamp(0.95rem, 1.05vw, 1.05rem);
    font-weight: 500;
    line-height: 1.55;
    text-wrap: pretty;
}

.hero-link {
    display: inline-block;
    margin-top: 1.1rem;
    padding-bottom: 2px;
    border-bottom: 1px solid var(--accent);
    color: var(--chalk);
    font-size: 0.95rem;
    font-weight: 600;
    text-decoration: none;
    transition: border-color 0.2s ease, color 0.2s ease;
}

.hero-link:hover,
.hero-link:focus-visible {
    border-color: var(--chalk);
}

.hero-link:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 4px;
}

/* --- The switch ------------------------------------------------- */

.mode-switch {
    position: absolute;
    top: clamp(1.25rem, 3vw, 2rem);
    right: clamp(1.5rem, 6vw, 6rem);
    z-index: 8;
    display: inline-flex;
    padding: 3px;
    border: 1px solid var(--rule);
    border-radius: 999px;
    background: rgba(247, 247, 246, 0.82);
    backdrop-filter: blur(8px);
}

.mode-switch button {
    padding: 0.4rem 0.9rem;
    border: 0;
    border-radius: 999px;
    background: transparent;
    color: var(--muted);
    font: inherit;
    font-size: 0.8rem;
    font-weight: 600;
    cursor: pointer;
    transition: background-color 0.2s ease, color 0.2s ease;
}

.mode-switch button[aria-pressed="true"] {
    background: var(--ink);
    color: var(--chalk);
}

.mode-switch button:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* --- The one cue, and the line the timeline fills --------------- */

.scroll-cue {
    position: absolute;
    right: clamp(1.5rem, 6vw, 6rem);
    bottom: clamp(1.5rem, 3.4vw, 2.4rem);
    z-index: 7;
    display: flex;
    gap: 0.85rem;
    align-items: center;
    color: var(--chalk-muted);
    font-size: 0.8rem;
    font-weight: 500;
    pointer-events: none;
}

.scroll-cue__track {
    display: block;
    width: min(140px, 24vw);
    height: 1px;
    overflow: hidden;
    background: var(--chalk-rule);
}

.scroll-cue__track span {
    display: block;
    width: 100%;
    height: 100%;
    transform: scaleX(0);
    transform-origin: left;
    background: var(--accent);
}

/* --- What the hero releases into -------------------------------- */

.after {
    padding: clamp(3.5rem, 11vh, 7rem) clamp(1.5rem, 6vw, 6rem);
    background: var(--ridge-near);
    color: var(--chalk);
}

.after h2 {
    font-size: clamp(1.5rem, 2.6vw, 2.1rem);
    font-weight: 600;
    letter-spacing: -0.025em;
    line-height: 1.15;
}

.after p {
    max-width: 34rem;
    margin-top: 0.9rem;
    color: var(--chalk-muted);
    font-size: 1rem;
    line-height: 1.7;
}

/* ---------------------------------------------------------------
   Responsive
   --------------------------------------------------------------- */

@media (max-width: 768px) {
    .hero {
        min-height: 600px;
    }

    .sun {
        top: 27%;
        left: 60%;
    }

    .layer--headline {
        padding-top: 20%;
    }

    .hero-title {
        font-size: clamp(4.2rem, 19vw, 5.6rem);
    }

    .layer--mist {
        top: 54%;
        bottom: 18%;
    }

    .layer--deck {
        padding-bottom: 5rem;
    }

    .layer--tile-a .tile {
        top: 40%;
        left: 54%;
    }

    .layer--tile-b .tile {
        top: 50%;
        left: 18%;
    }

    .mode-switch {
        right: 1.5rem;
    }

    .scroll-cue {
        right: 1.5rem;
    }
}

@media (max-height: 620px) and (min-width: 769px) {
    .hero {
        min-height: 480px;
    }

    .hero-title {
        font-size: clamp(3.6rem, 9.5vw, 7rem);
    }
}

/* ---------------------------------------------------------------
   Reduced motion: no pin, no travel, the composition stays whole
   --------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        scroll-behavior: auto !important;
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }

    [data-parallax-speed] {
        transform: none !important;
        filter: none !important;
        opacity: 1 !important;
        visibility: visible !important;
        will-change: auto;
    }

    .scroll-cue {
        display: none;
    }
}

A pinned hero that pulls its layers into depth from one scrubbed ScrollTrigger. The headline sits inside the stack rather than on top of it, so the near layers climb over it as you scroll, while the far layers sink and a sun sets behind the ridge. The demo ships two layer sets, ink silhouettes and photographs, driven by the same script.

Quick Start

1. Add to your HTML <head>:

<link rel="stylesheet" href="path/to/style.css">

2. Add the hero to your <body>:

<section class="hero" data-parallax data-parallax-runway="720" data-parallax-pointer="1.6">
  <!-- Far: sinks. Negative speeds move against the scroll. -->
  <div class="layer" data-parallax-speed="-0.15" aria-hidden="true">
    <span class="sun"></span>
  </div>
  <div class="layer" data-parallax-speed="0.5" data-parallax-blur="3" aria-hidden="true">
    <svg class="ridge" viewBox="0 0 1600 700" preserveAspectRatio="xMidYMax slice">…</svg>
  </div>

  <!-- The headline, inside the stack -->
  <div class="layer layer--headline" data-parallax-speed="0.9">
    <h1>Higher ground.</h1>
  </div>

  <!-- Near: rises over the headline -->
  <div class="layer" data-parallax-speed="1.7" data-parallax-scale="1.06" aria-hidden="true">
    <svg class="ridge" viewBox="0 0 1600 700" preserveAspectRatio="xMidYMax slice">…</svg>
  </div>

  <!-- Whatever must stay readable, in front of everything -->
  <div class="layer layer--deck" data-parallax-speed="1.15">
    <p>Your lede.</p>
    <a href="#routes">See the route</a>
  </div>
</section>

3. Add before the closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script src="path/to/script.js"></script>

The script discovers every [data-parallax] scene, builds one scrubbed timeline for its layers, pins it for the configured runway, and then releases it. With data-parallax-pointer set, fine-pointer devices also get a cursor drift from the same depth values.

Using It With Your Own Design

The effect makes no demands on how the hero looks. It only needs two things:

  1. A positioning context. The scene is position: relative; overflow: clip, and each layer is position: absolute; inset: 0 inside it so the layers stack in source order.
  2. A data-parallax-speed on anything you want to move. That is the whole API. The element can be an SVG, an image wrapper, a <div> holding your existing headline markup, a card, a gradient band; the script never looks at what is inside it.

Nothing in script.js refers to .hero, .ridge, .tile, or any other class name in style.css. To reskin the demo, keep the .layer wrappers and replace their contents.

What is the demo's, and can go:

  • The ridge SVGs, the pine paths, the sun and the mist band are one layer set. The photograph, its wash and the two photo tiles are the other. Keep either, both, or neither; style.css shows which layers each data-mode value hides.
  • The Illustration / Photo switch and the tiny inline script under script.js in index.html exist to show both sets in one demo. Delete them and the data-mode attribute together, or leave one set in the markup and drop the switch.
  • The photographs in assets/img/ are demo placeholders, baked for this demo. Replace them with your own.

The CSS the effect depends on:

  • Overscan the layers that paint to an edge. A photograph that recedes and grows needs slack past the scene, or its travel and its ending scale drag an edge into view; the demo gives .layer--photo inset: -24%. The ridge SVGs solve the same problem differently: their fill runs on to twice the viewBox height and the SVG is overflow: visible, so a ridge that rises 180px never exposes the ground beneath its own foot. Layers that only carry content stay at inset: 0, otherwise the padding you lay that content out with starts off-screen.
  • Put interactive content back in the flow. .layer sets pointer-events: none so the stacked layers do not block each other, and so the pointer drift reads the cursor over the whole scene; the demo restores pointer-events: auto on .deck so the link inside it still works.
  • Do not put the scroll's transform on the same element you transform in CSS. The script owns transform on every [data-parallax-speed] element. Position a layer's contents with a child (.sun, .tile) rather than by transforming the layer itself.
  • Set transform-origin where the scale should grow from. The near ridge scales from 50% 100% so it grows upward; the photograph from 50% 55% so it grows outward.

Options

Attribute Values Default Description
data-parallax marker required Identifies a parallax scene
data-parallax-speed number 1 Controls relative depth. Below 1 lags into the background; above 1 advances into the foreground; below 0 moves against the scroll
data-parallax-x pixels unchanged Optional horizontal travel; automatically reduced on mobile
data-parallax-rotate degrees unchanged Optional ending rotation; automatically reduced on mobile
data-parallax-scale number unchanged Optional ending scale for a layer
data-parallax-blur pixels unchanged Optional ending blur amount, entered without px
data-parallax-opacity 0–1 unchanged Optional ending opacity for a layer
data-parallax-distance pixels 236 Desktop translation multiplier for the scene
data-parallax-mobile-distance pixels 128 Translation multiplier at 768px and below
data-parallax-runway pixels 560 Desktop pinned scroll distance
data-parallax-mobile-runway pixels 340 Mobile pinned scroll distance
data-parallax-pointer number off Cursor drift strength on fine-pointer devices, as the percentage a layer at depth 1 travels for a cursor at the scene's edge. Omit to disable
data-parallax-progress marker optional Displays timeline progress from 000 to 100
data-parallax-fill marker optional Scales a progress line from left to right

Invalid or omitted speeds fall back to 1, which leaves the layer at the page's base speed.

Choosing Layer Speeds

Travel is (1 - speed) × distance, so the further a speed sits from 1, the further the layer moves, and the sign decides the direction.

  • -0.3–0: something that should move against everything else, such as a sun that sets while the ridges rise
  • 0.3–0.6: the far ridge or a background photograph
  • 0.6–0.85: mid layers, atmosphere, a mist band
  • 0.85–0.95: the headline, if it is to sit inside the stack
  • 1: follows the scene without additional travel
  • 1.1–1.3: the copy that must stay readable, labels, interface details
  • 1.4–1.8: foreground ridges, tiles, frames, anything meant to climb over the headline

Keep the deepest and nearest values far apart so the separation develops clearly across the pinned runway. The demo's illustration set uses -0.15 / 0.5 / 0.7 / 0.8 / 0.9 / 1.7 / 1.15; the photo set uses 0.5 / 0.6 / 0.9 / 1.4 / 1.8 / 1.15.

Examples

Put the Headline Inside the Stack

The demo's signature. Give the headline a speed just under 1 and place at least one layer after it in source order with a speed well over 1:

Inside your [data-parallax] scene:

<div class="layer layer--headline" data-parallax-speed="0.9">
  <h1>Higher ground.</h1>
</div>
<div class="layer" data-parallax-speed="1.7" data-parallax-scale="1.06" aria-hidden="true">
  <img src="foreground.png" alt="">
</div>

The foreground rises about (1.7 - 1) × distance pixels over the runway, so at the default distance it covers the lower 165px of whatever sits behind it. Position the headline so that its second line, not its first, is what gets covered.

Add a Focus Pull to a Background Photograph

Inside your [data-parallax] scene:

<div
  class="layer layer--photo"
  data-parallax-speed="0.5"
  data-parallax-scale="1.12"
  data-parallax-blur="3"
  data-parallax-opacity="0.85"
  aria-hidden="true"
>
  <img src="your-photo.jpg" alt="">
</div>

Decorative images should use an empty alt attribute. The layer moves, expands, softens, and dims from the same scrubbed progress, so the photograph recedes as the copy travels over it.

Add Cursor Drift

On the scene:

<section class="hero" data-parallax data-parallax-pointer="1.6">

Every layer drifts by its depth: layers below speed 1 move with the cursor, layers above it move against, which is what reads as looking through a window. The drift tweens xPercent and yPercent, separate transform components from the scroll's pixel y, so the two inputs compose instead of competing. It is only attached on (hover: hover) and (pointer: fine); touch devices never get it.

Add a Scroll Progress Cue

Inside your [data-parallax] scene:

<div class="scroll-cue" aria-hidden="true">
  <span>Scroll</span>
  <span class="scroll-cue__track"><span data-parallax-fill></span></span>
  <span><span data-parallax-progress>000</span>%</span>
</div>

[data-parallax-fill] is scaled from scaleX(0) to scaleX(1) and [data-parallax-progress] counts 000–100, both on the same timeline. Both are optional; omit them and the script skips them. The demo ships the filling line only, without the numeric readout.

Disable Lenis

If Lenis is loaded but a page should use native scrolling, set the option on <html>:

<html data-smooth="off">

You can also append ?smooth=off to the URL. The ScrollTrigger effect works with or without Lenis.

How It Works

A scene gets one GSAP timeline whose ScrollTrigger starts at top top, pins the hero, and ends after the selected runway. For each layer, the y destination is calculated as (1 - speed) × distance. A 0.5 ridge therefore drifts down, a -0.15 sun drifts down further still, and a 1.7 foreground ridge travels up, creating visible separation across a deliberate but still self-contained hero sequence.

Horizontal travel, rotation, scale, blur, and opacity attributes are added to the same tween, as are the optional scroll cue responses, so every element stays synchronized, scrubbed, and reversible. invalidateOnRefresh recalculates the runway after layout changes.

The pointer drift is a gsap.quickTo pair per layer on xPercent and yPercent, fed from a single pointermove listener on the scene and returned to zero on pointerleave.

Composition Notes

  • Give moving layers enough overscan to prevent an edge appearing during travel.
  • Keep semantic content such as the headline in normal heading markup even when it is absolutely layered.
  • Set decorative layers to aria-hidden="true".
  • Anything that must stay readable for the whole runway belongs in front of the near layers, with a speed above 1 so it lifts with them.
  • A page can contain multiple independent [data-parallax] scenes, but a hero normally needs only one.

Accessibility

  • Reduced motion: the JavaScript gsap.matchMedia branch creates no pin, animation, or pointer listener. CSS removes transforms, filters, and transitions so the complete layered hero remains visible as a strong static composition.
  • No JavaScript: all layers are visible in their designed starting state; no content depends on JavaScript to be revealed.
  • Keyboard: the effect is scroll-driven and introduces no custom controls or keyboard traps. The demo's layer switch is a pair of real <button>s with aria-pressed. Links inside layers keep their focus styles because only the layer wrapper is pointer-events: none.
  • Semantics: keep meaningful copy outside aria-hidden decorative layers and connect the scene to its heading with aria-labelledby.

Cleanup

The script stores its GSAP context on window.gsapContext for SPA use. Calling window.gsapContext.revert() removes the scene timelines, ScrollTriggers, and pointer listeners. On page unload, the script also removes the Lenis ticker callback and ScrollTrigger refresh listener before destroying Lenis.

Performance Notes

  • Translation and scale remain compositor-friendly.
  • The mist band is a gradient rather than a filter, so it composites for free while it travels.
  • Blur is optional because large filtered layers can be expensive on low-power devices; the demo blurs one simple SVG ridge and one photograph.
  • One ScrollTrigger coordinates every layer in a scene.
  • Mobile defaults reduce both travel and pin distance while preserving the same depth order.

Dependencies

Required:

  • GSAP 3.12+
  • ScrollTrigger

Optional:

  • Lenis 1.x for smooth scrolling. If present, the script runs Lenis from GSAP's ticker so both systems share one clock.