Back to Gallery

Parallax Hero

FREE

A pinned hero that separates its background photograph, copy, and foreground card into distinct scroll depths from a single scrubbed ScrollTrigger.

ScrollTrigger Lenis intermediate
4 more details
parallaxscrubpinningscroll-progress

About This Effect

A layered parallax hero built the way a real hero is built: a background photograph, a wash of atmosphere, the headline and buttons, and a foreground price card. Each is a plain wrapper carrying one data-parallax-speed attribute, and a single pinned ScrollTrigger pulls them apart as you scroll. The script never references a class name, so reskinning the hero is a matter of replacing what sits inside the layers.

What's Included

8 items
  • Data-speed API: assign data-parallax-speed to any text, image, card, or decorative wrapper
  • Multi-channel response combines x/y translation with optional rotation, scale, blur, and opacity
  • 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
  • One scrubbed ScrollTrigger per hero coordinates every layer and the optional scroll meter
  • Fully reversible playback with a clean pin and immediate release
  • Lenis integration shares GSAP's ticker and removes ticker and refresh listeners during cleanup
  • Strong reduced-motion and no-JavaScript fallbacks preserve the complete static composition

Perfect For

5 use cases
  • Hotel, rental, and hospitality heroes with a photograph, a booking card, and a clear call to action
  • Agency and studio landing pages that need depth on the first screen without a video
  • Product launches with a layered headline and a foreground detail panel
  • Travel and destination pages built around one strong image
  • Editorial features that need a reversible pinned opening moment

How It Works

1 section

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 while higher values move ahead as foreground. Optional data-parallax-x, data-parallax-rotate, data-parallax-scale, data-parallax-blur, and data-parallax-opacity attributes add synchronized depth cues, and an optional data-parallax-fill bar and data-parallax-progress readout track the same timeline. gsap.matchMedia selects shorter mobile values or a fully static reduced-motion state, and the Lenis ticker, refresh listener, 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
<!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="stylesheet" href="assets/style.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet">
</head>
<body>
    <main>
        <section
            class="hero"
            data-parallax
            data-parallax-distance="220"
            data-parallax-mobile-distance="118"
            data-parallax-runway="620"
            data-parallax-mobile-runway="380"
            aria-labelledby="hero-title">

            <!-- Layer 1 · speed 0.20 — the background image. Swap the <img> for yours. -->
            <div class="layer layer--backdrop" data-parallax-speed="0.2" data-parallax-scale="1.12" data-parallax-blur="5" aria-hidden="true">
                <img src="assets/backdrop.webp" alt="" width="1600" height="1000" decoding="async">
                <div class="backdrop-scrim"></div>
            </div>

            <!-- Layer 2 · speed 0.55 — atmosphere sitting between the image and the copy. -->
            <div class="layer layer--glow" data-parallax-speed="0.55" data-parallax-opacity="0.75" aria-hidden="true"></div>

            <!-- Layer 3 · speed 0.82 — the hero copy. Ordinary markup, just placed on a layer. -->
            <div class="layer layer--copy" data-parallax-speed="0.82">
                <div class="copy-column">
                    <p class="eyebrow">Northshore · Six rooms on the water</p>
                    <h1 class="hero-title" id="hero-title">Wake up<br>to the tide</h1>
                    <p class="hero-lede">A small house at the quiet end of the bay. Breakfast on the sand, boats at eight, nothing else scheduled.</p>
                    <div class="hero-actions">
                        <a class="button" href="#stay">Check availability</a>
                        <a class="button button--ghost" href="#stay">See the rooms</a>
                    </div>
                </div>
            </div>

            <!-- Layer 4 · speed 1.30 — foreground card, moves toward the viewer. -->
            <div class="layer layer--front" data-parallax-speed="1.3" data-parallax-x="26">
                <aside class="rate-card">
                    <p class="rate-card__label">From</p>
                    <p class="rate-card__price">£180<span>/ night</span></p>
                    <p class="rate-card__note">Two-night minimum · Sea view in every room</p>
                </aside>
            </div>

            <!-- Not a layer: page chrome stays put while the layers separate behind it. -->
            <header class="hero-chrome">
                <a class="brand" href="#top">Northshore</a>
                <nav class="hero-nav" aria-label="Primary">
                    <a href="#stay">Rooms</a>
                    <a href="#stay">The bay</a>
                    <a href="#stay">Contact</a>
                </nav>
            </header>

            <div class="scroll-meter" aria-hidden="true">
                <span class="scroll-meter__label">Scroll</span>
                <span class="scroll-meter__track"><span data-parallax-fill></span></span>
                <span class="scroll-meter__value"><span data-parallax-progress>000</span>%</span>
            </div>
        </section>

        <section class="after" id="stay">
            <h2>The pin releases and the page carries on</h2>
            <p>Everything above is one <code>[data-parallax]</code> section. The four layers inside it each carry a <code>data-parallax-speed</code>, and nothing else about the markup is special.</p>
        </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/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>
</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.
 *
 * @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);
    }

    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)',
            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 = [];

            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 parsedSpeed = parseFloat(layer.dataset.parallaxSpeed);
                    const speed = Number.isFinite(parsedSpeed) ? parsedSpeed : 1;
                    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);
            });

            return function cleanupMotion() {
                triggers.forEach(function killTrigger(trigger) { trigger.kill(); });
            };
        });
    });

    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 a=null,t=null,r=null;e&&"undefined"!=typeof Lenis&&(a=new Lenis({autoRaf:!1,duration:1.05,smoothWheel:!0}),a.on("scroll",ScrollTrigger.update),t=function(e){a.raf(1e3*e)},r=function(){a.scrollTo(window.scrollY,{immediate:!0,force:!0})},gsap.ticker.add(t),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)",reduced:"(prefers-reduced-motion: reduce)"},function(e){const a=e.conditions,t=gsap.utils.toArray("[data-parallax]");if(a.reduced)return void gsap.set("[data-parallax-speed]",{clearProps:"transform,filter,opacity,visibility"});const r=a.mobile,o=[];return t.forEach(function(e){const a=gsap.utils.toArray("[data-parallax-speed]",e);if(!a.length)return;const t=parseFloat(r?e.dataset.parallaxMobileDistance:e.dataset.parallaxDistance)||(r?128:236),n=parseFloat(r?e.dataset.parallaxMobileRunway:e.dataset.parallaxRunway)||(r?340:560),i=e.querySelector("[data-parallax-progress]"),s=e.querySelector("[data-parallax-fill]"),l={progress:0},d=gsap.timeline({defaults:{duration:1,ease:"none",force3D:!0},scrollTrigger:{trigger:e,start:"top top",end:function(){return"+="+n},pin:!0,scrub:r?.35:.5,invalidateOnRefresh:!0}});a.forEach(function(e){const a=parseFloat(e.dataset.parallaxSpeed),o=(1-(Number.isFinite(a)?a:1))*t,n=parseFloat(e.dataset.parallaxX),i=parseFloat(e.dataset.parallaxRotate),s=parseFloat(e.dataset.parallaxScale),l=parseFloat(e.dataset.parallaxBlur),c=parseFloat(e.dataset.parallaxOpacity),p={y:o};Number.isFinite(n)&&(p.x=r?.58*n:n),Number.isFinite(i)&&(p.rotation=r?.62*i:i),Number.isFinite(s)&&(p.scale=s),Number.isFinite(l)&&(p.filter="blur("+l+"px)"),Number.isFinite(c)&&(p.autoAlpha=c),d.to(e,p,0)}),s&&d.to(s,{scaleX:1},0),i&&d.to(l,{progress:100,onUpdate:function(){i.isConnected&&(i.textContent=String(Math.round(l.progress)).padStart(3,"0"))}},0),o.push(d.scrollTrigger)}),function(){o.forEach(function(e){e.kill()})}})});window.gsapContext=o,window.addEventListener("beforeunload",function e(){window.removeEventListener("beforeunload",e),o&&o.revert(),r&&ScrollTrigger.removeEventListener("refresh",r),t&&gsap.ticker.remove(t),a&&a.destroy()},{once:!0})});
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

:root {
    --deep: #06171c;
    --shell: #f6f1e7;
    --text: #fdfbf7;
    --muted: rgba(253, 251, 247, 0.72);
    --sand: #e8c39a;
    --tide: #59c7c3;
    --mono: 'JetBrains Mono', monospace;
    color-scheme: dark;
}

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

body {
    min-width: 320px;
    overflow-x: hidden;
    background: var(--deep);
    color: var(--text);
    font-family: 'Space Grotesk', system-ui, sans-serif;
    -webkit-font-smoothing: antialiased;
}

::selection {
    background: var(--sand);
    color: var(--deep);
}

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

.hero {
    position: relative;
    isolation: isolate;
    width: 100%;
    height: 100svh;
    min-height: 560px;
    overflow: clip;
    background: var(--deep);
}

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

/* Layers that paint a background overscan the scene, so travel never
   drags an edge into view. Layers that only hold content do not, or the
   padding they lay content out with would start off-screen. */
.layer--backdrop,
.layer--glow {
    inset: -12%;
}

/* --- Layer 1: background image ---------------------------------- */

.layer--backdrop {
    z-index: 0;
    transform-origin: 50% 55%;
}

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

.backdrop-scrim {
    position: absolute;
    inset: 0;
    background:
        linear-gradient(180deg, rgba(4, 18, 22, 0.58) 0%, rgba(4, 18, 22, 0.18) 32%, rgba(4, 18, 22, 0.55) 80%, rgba(4, 18, 22, 0.85) 100%),
        linear-gradient(90deg, rgba(4, 18, 22, 0.9) 0%, rgba(4, 18, 22, 0.78) 24%, rgba(4, 18, 22, 0.34) 54%, rgba(4, 18, 22, 0) 82%);
}

/* --- Layer 2: atmosphere ---------------------------------------- */

.layer--glow {
    z-index: 1;
    background:
        radial-gradient(circle at 72% 22%, rgba(232, 195, 154, 0.3), transparent 44%),
        radial-gradient(circle at 88% 74%, rgba(89, 199, 195, 0.22), transparent 48%);
    mix-blend-mode: screen;
}

/* --- Layer 3: the hero copy ------------------------------------- */

.layer--copy {
    z-index: 2;
    display: grid;
    align-items: center;
    padding: 12% clamp(1.5rem, 7vw, 8.5rem);
}

.copy-column {
    max-width: 34rem;
    pointer-events: auto;
}

.eyebrow {
    margin-bottom: clamp(1rem, 2.4vh, 1.6rem);
    color: var(--sand);
    font-family: var(--mono);
    font-size: clamp(0.62rem, 0.82vw, 0.74rem);
    letter-spacing: 0.22em;
    text-transform: uppercase;
}

.hero-title {
    font-family: 'Syne', system-ui, sans-serif;
    font-size: clamp(3rem, 7.2vw, 6rem);
    font-weight: 700;
    letter-spacing: -0.035em;
    line-height: 0.92;
    text-shadow: 0 0.5rem 2.5rem rgba(3, 14, 18, 0.45);
}

.hero-lede {
    max-width: 27rem;
    margin-top: clamp(1.1rem, 2.6vh, 1.75rem);
    color: var(--muted);
    font-size: clamp(1rem, 1.15vw, 1.12rem);
    line-height: 1.6;
}

.hero-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.75rem;
    margin-top: clamp(1.6rem, 3.4vh, 2.4rem);
}

.button {
    display: inline-block;
    padding: 0.85rem 1.6rem;
    border: 1px solid transparent;
    border-radius: 999px;
    background: var(--shell);
    color: #08262c;
    font-size: 0.94rem;
    font-weight: 500;
    text-decoration: none;
    transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease;
}

.button:hover,
.button:focus-visible {
    background: var(--sand);
}

.button--ghost {
    border-color: rgba(253, 251, 247, 0.4);
    background: transparent;
    color: var(--text);
}

.button--ghost:hover,
.button--ghost:focus-visible {
    border-color: var(--text);
    background: rgba(253, 251, 247, 0.12);
    color: var(--text);
}

.button:focus-visible {
    outline: 2px solid var(--sand);
    outline-offset: 3px;
}

/* --- Layer 4: foreground card ----------------------------------- */

.layer--front {
    z-index: 3;
    display: grid;
    align-content: end;
    justify-items: end;
    padding: 0 clamp(1.5rem, 7vw, 8.5rem) 19%;
}

.rate-card {
    width: min(20rem, 74vw);
    padding: 1.6rem 1.75rem;
    border: 1px solid rgba(253, 251, 247, 0.18);
    border-radius: 1.25rem;
    backdrop-filter: blur(14px);
    background: rgba(6, 23, 28, 0.58);
    box-shadow: 0 2rem 4rem rgba(2, 12, 16, 0.4);
    pointer-events: auto;
}

.rate-card__label {
    color: var(--tide);
    font-family: var(--mono);
    font-size: 0.66rem;
    letter-spacing: 0.2em;
    text-transform: uppercase;
}

.rate-card__price {
    margin: 0.5rem 0 0.75rem;
    font-family: 'Syne', system-ui, sans-serif;
    font-size: 2.4rem;
    font-weight: 700;
    letter-spacing: -0.03em;
}

.rate-card__price span {
    margin-left: 0.4rem;
    color: var(--muted);
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-size: 0.9rem;
    font-weight: 400;
    letter-spacing: 0;
}

.rate-card__note {
    color: var(--muted);
    font-size: 0.82rem;
    line-height: 1.55;
}

/* --- Chrome (not a parallax layer) ------------------------------ */

.hero-chrome {
    position: absolute;
    top: 0;
    right: 0;
    left: 0;
    z-index: 6;
    display: flex;
    flex-wrap: wrap;
    gap: 1rem 2rem;
    align-items: center;
    justify-content: space-between;
    padding: clamp(1.25rem, 3vw, 2.5rem) clamp(1.5rem, 7vw, 8.5rem);
}

.brand {
    color: var(--text);
    font-family: 'Syne', system-ui, sans-serif;
    font-size: 1.05rem;
    font-weight: 700;
    letter-spacing: -0.01em;
    text-decoration: none;
}

.hero-nav {
    display: flex;
    gap: clamp(1rem, 2.4vw, 2rem);
}

.hero-nav a {
    color: var(--muted);
    font-size: 0.88rem;
    text-decoration: none;
    transition: color 0.22s ease;
}

.hero-nav a:hover,
.hero-nav a:focus-visible {
    color: var(--text);
}

.scroll-meter {
    position: absolute;
    bottom: clamp(1.4rem, 3.4vw, 2.6rem);
    left: clamp(1.5rem, 7vw, 8.5rem);
    z-index: 6;
    display: flex;
    gap: 0.9rem;
    align-items: center;
    color: var(--muted);
    font-family: var(--mono);
    font-size: 0.62rem;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    pointer-events: none;
}

.scroll-meter__track {
    display: block;
    width: min(180px, 26vw);
    height: 1px;
    overflow: hidden;
    background: rgba(253, 251, 247, 0.22);
}

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

.scroll-meter__value {
    color: var(--sand);
}

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

.after {
    padding: clamp(4rem, 12vh, 8rem) clamp(1.5rem, 7vw, 8.5rem);
    background: var(--deep);
}

.after h2 {
    max-width: 22rem;
    font-family: 'Syne', system-ui, sans-serif;
    font-size: clamp(1.5rem, 2.6vw, 2.1rem);
    font-weight: 700;
    letter-spacing: -0.02em;
    line-height: 1.15;
}

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

.after code {
    color: var(--sand);
    font-family: var(--mono);
    font-size: 0.88em;
}

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

@media (max-width: 900px) {
    .layer--copy {
        padding-top: 18%;
        align-items: start;
    }

    .layer--front {
        padding-bottom: 22%;
    }
}

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

    .layer--backdrop,
    .layer--glow {
        inset: -9%;
    }

    .layer--copy {
        padding: 30% 1.5rem 0;
    }

    .hero-lede {
        font-size: 0.98rem;
    }

    .layer--front {
        justify-items: start;
        padding: 0 1.5rem 20%;
    }

    .rate-card {
        padding: 1.15rem 1.25rem;
    }

    .rate-card__price {
        font-size: 1.9rem;
    }

    .hero-nav {
        display: none;
    }

    .scroll-meter {
        left: 1.5rem;
    }

    .scroll-meter__label {
        display: none;
    }
}

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

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

    .layer--front {
        padding-bottom: 12%;
    }
}

/* ---------------------------------------------------------------
   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-meter {
        display: none;
    }
}

A pinned hero that separates its background image, copy, and foreground card into distinct scroll depths from one scrubbed ScrollTrigger.

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="620">
  <!-- Background image: hangs back -->
  <div class="layer layer--backdrop" data-parallax-speed="0.2" data-parallax-scale="1.12" aria-hidden="true">
    <img src="your-photo.jpg" alt="">
  </div>

  <!-- Your normal hero copy -->
  <div class="layer layer--copy" data-parallax-speed="0.82">
    <h1>Wake up to the tide</h1>
    <p>Your lede.</p>
    <a class="button" href="#book">Check availability</a>
  </div>

  <!-- Anything that should come toward the viewer -->
  <div class="layer layer--front" data-parallax-speed="1.3">
    <aside class="rate-card">From £180 / night</aside>
  </div>
</section>

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

<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/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.

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.
  2. A data-parallax-speed on anything you want to move. That is the whole API. The element can be an image wrapper, a <div> holding your existing headline markup, a card, an SVG — the script never looks at what is inside it.

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

Two rules worth keeping:

  • Overscan the layers that paint something. A background image that travels 176px will drag its edge into view unless the layer extends past the scene. The demo gives .layer--backdrop and .layer--glow inset: -12%. 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; the demo restores pointer-events: auto on .copy-column and .rate-card so links and buttons still work.

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
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 01 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-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

  • 0.15–0.35: the background image or a large distant field
  • 0.4–0.65: atmosphere, texture, a light wash
  • 0.7–0.9: the headline and primary copy
  • 1: follows the scene without additional y travel
  • 1.1–1.25: labels and interface details
  • 1.3–1.5: foreground cards, frames, or objects

Keep the deepest and nearest values far apart so the separation develops clearly across the pinned runway. The demo uses 0.2 / 0.55 / 0.82 / 1.3.

Examples

Add a Focus Pull to the Background Image

Add inside your [data-parallax] scene:

<div
  class="layer layer--backdrop"
  data-parallax-speed="0.2"
  data-parallax-scale="1.12"
  data-parallax-blur="5"
  data-parallax-opacity="0.8"
  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 a Scroll Meter

Add inside your [data-parallax] scene:

<div class="scroll-meter" aria-hidden="true">
  <span class="scroll-meter__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 000100, both on the same timeline. Both are optional; omit them and the script skips them.

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.2 background therefore drifts down while a 1.3 foreground card 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 meter responses, so every element stays synchronized, scrubbed, and reversible. invalidateOnRefresh recalculates the runway after layout changes.

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".
  • 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 or animation. 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. 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 and ScrollTriggers. 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.
  • Blur is optional because large filtered layers can be expensive on low-power devices.
  • 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.

Your Cart

Your cart is empty

Browse Effects