Back to Gallery f05

Image Clip Reveal

FREE

Images reveal on scroll with an animated clip-path wipe and a Ken Burns settle, the inner image easing from 1.25 scale down to 1 as the mask opens.

ScrollTrigger scroll-revealimage-revealclip-pathstagger Lenis beginner

About This Effect

A scroll-triggered image reveal that wipes images into view with an animated clip-path inset while the inner image settles from an oversized scale to its natural size, a classic Ken Burns settle. Wipe direction, duration, delay, and replay behaviour are all configured through data attributes on the wrapper, and a group container staggers multiple images into an editorial-style sequence. Initial states live in CSS so images never flash before the script runs.

What's Included

  • Four wipe directions (up, down, left, right) via clip-path inset()
  • Counter-scaling inner image from 1.25 to 1 for a Ken Burns settle
  • Per-image duration and delay control through data attributes
  • Stagger groups: one container attribute sequences all child reveals
  • Play-once by default with an opt-in replay mode on re-entry
  • No-flash initial states defined in CSS before JavaScript loads
  • Full prefers-reduced-motion support showing images immediately
  • Optional Lenis smooth scroll integration, works without it

Perfect For

  • Editorial photo grids with staggered reveals that feel curated rather than mechanical
  • Portfolio case study pages with directional wipes that guide the reading flow
  • Full-width hero and banner images with a cinematic settle on first scroll
  • Product photography sections with polished entrances that draw attention
  • Agency and brochure sites with premium image reveals at near-zero cost

How It Works

Each wrapper gets its own ScrollTrigger that fires a timeline when it enters the viewport, once by default or with toggleActions for replay mode. The timeline uses gsap.fromTo() to tween the wrapper's clip-path from a fully closed inset() on the chosen edge to inset(0%), while a second fromTo() scales the inner img from 1.25 to 1 with the same expo.out ease so the two motions read as one. Containers marked data-clip-reveal-group build a single timeline and offset each child reveal by a configurable stagger. A gsap.matchMedia() reduce branch clears the clip and scale entirely for users who prefer reduced motion.

Plugins ScrollTrigger
Difficulty Beginner
Smooth Scroll Lenis Integration
Includes HTML + JS + CSS source, documentation, lifetime updates
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <title>Image Clip Reveal 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;600;700&family=Space+Grotesk:wght@400;500;600;700&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
    <main class="container">
        <header class="page-header">
            <h1 class="page-title">Image Clip Reveal</h1>
            <p class="page-subtitle">Images wipe into view with an animated clip-path while the inner image settles from 1.25 scale, a Ken Burns settle on scroll</p>
        </header>

        <!-- Example 1: Editorial grid with staggered, mixed-direction reveals -->
        <section class="example">
            <div class="example-header">
                <span class="example-number">01</span>
                <h2 class="example-title">Editorial Grid</h2>
            </div>
            <div class="example-content">
                <p class="example-instruction">A 2x2 grid inside a <code>data-clip-reveal-group</code> container. Each image sets its own <code>data-reveal-direction</code> and the group staggers them into sequence. Scroll down to trigger the reveal.</p>

                <div class="clip-grid" data-clip-reveal-group data-reveal-stagger="0.15">
                    <figure class="clip-figure">
                        <div class="clip-media" data-clip-reveal data-reveal-direction="up">
                            <img src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='800'%20height='600'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='0'%20y1='0'%20x2='1'%20y2='1'%3E%3Cstop%20offset='0'%20stop-color='%23ff8c00'/%3E%3Cstop%20offset='1'%20stop-color='%234a2800'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width='800'%20height='600'%20fill='url(%23g)'/%3E%3Ccircle%20cx='620'%20cy='170'%20r='180'%20fill='rgba(255,255,255,0.1)'/%3E%3Ccircle%20cx='190'%20cy='460'%20r='110'%20fill='rgba(0,0,0,0.15)'/%3E%3Ctext%20x='48'%20y='548'%20font-family='monospace'%20font-size='40'%20fill='rgba(255,255,255,0.8)'%3E01%3C/text%3E%3C/svg%3E" alt="Abstract orange gradient composition one">
                        </div>
                        <figcaption><span class="clip-caption-index">01</span> Amber Terrace, wipes up</figcaption>
                    </figure>
                    <figure class="clip-figure">
                        <div class="clip-media" data-clip-reveal data-reveal-direction="down">
                            <img src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='800'%20height='600'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='0'%20y1='0'%20x2='1'%20y2='1'%3E%3Cstop%20offset='0'%20stop-color='%23ffb347'/%3E%3Cstop%20offset='1'%20stop-color='%235c3410'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width='800'%20height='600'%20fill='url(%23g)'/%3E%3Ccircle%20cx='620'%20cy='170'%20r='180'%20fill='rgba(255,255,255,0.1)'/%3E%3Ccircle%20cx='190'%20cy='460'%20r='110'%20fill='rgba(0,0,0,0.15)'/%3E%3Ctext%20x='48'%20y='548'%20font-family='monospace'%20font-size='40'%20fill='rgba(255,255,255,0.8)'%3E02%3C/text%3E%3C/svg%3E" alt="Abstract orange gradient composition two">
                        </div>
                        <figcaption><span class="clip-caption-index">02</span> Dune Signal, wipes down</figcaption>
                    </figure>
                    <figure class="clip-figure">
                        <div class="clip-media" data-clip-reveal data-reveal-direction="left">
                            <img src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='800'%20height='600'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='0'%20y1='0'%20x2='1'%20y2='1'%3E%3Cstop%20offset='0'%20stop-color='%23ff7a1a'/%3E%3Cstop%20offset='1'%20stop-color='%233a1e00'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width='800'%20height='600'%20fill='url(%23g)'/%3E%3Ccircle%20cx='620'%20cy='170'%20r='180'%20fill='rgba(255,255,255,0.1)'/%3E%3Ccircle%20cx='190'%20cy='460'%20r='110'%20fill='rgba(0,0,0,0.15)'/%3E%3Ctext%20x='48'%20y='548'%20font-family='monospace'%20font-size='40'%20fill='rgba(255,255,255,0.8)'%3E03%3C/text%3E%3C/svg%3E" alt="Abstract orange gradient composition three">
                        </div>
                        <figcaption><span class="clip-caption-index">03</span> Copper Field, wipes left</figcaption>
                    </figure>
                    <figure class="clip-figure">
                        <div class="clip-media" data-clip-reveal data-reveal-direction="right">
                            <img src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='800'%20height='600'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='0'%20y1='0'%20x2='1'%20y2='1'%3E%3Cstop%20offset='0'%20stop-color='%23ffc069'/%3E%3Cstop%20offset='1'%20stop-color='%23663c12'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width='800'%20height='600'%20fill='url(%23g)'/%3E%3Ccircle%20cx='620'%20cy='170'%20r='180'%20fill='rgba(255,255,255,0.1)'/%3E%3Ccircle%20cx='190'%20cy='460'%20r='110'%20fill='rgba(0,0,0,0.15)'/%3E%3Ctext%20x='48'%20y='548'%20font-family='monospace'%20font-size='40'%20fill='rgba(255,255,255,0.8)'%3E04%3C/text%3E%3C/svg%3E" alt="Abstract orange gradient composition four">
                        </div>
                        <figcaption><span class="clip-caption-index">04</span> Saffron Drift, wipes right</figcaption>
                    </figure>
                </div>
            </div>
        </section>

        <!-- Example 2: Full-width single reveal that replays -->
        <section class="example">
            <div class="example-header">
                <span class="example-number">02</span>
                <h2 class="example-title">Full-Width Replay</h2>
            </div>
            <div class="example-content">
                <p class="example-instruction">A standalone full-width image with a slower left wipe and <code>data-reveal-once="false"</code>, so it replays every time it re-enters the viewport. Scroll past it and back to watch it run again.</p>

                <figure class="clip-figure clip-banner">
                    <div class="clip-media" data-clip-reveal data-reveal-direction="left" data-reveal-duration="1.4" data-reveal-once="false">
                        <img src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='1600'%20height='640'%3E%3Cdefs%3E%3ClinearGradient%20id='g'%20x1='0'%20y1='0'%20x2='1'%20y2='1'%3E%3Cstop%20offset='0'%20stop-color='%23ff8c00'/%3E%3Cstop%20offset='1'%20stop-color='%23331a00'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width='1600'%20height='640'%20fill='url(%23g)'/%3E%3Ccircle%20cx='1240'%20cy='180'%20r='220'%20fill='rgba(255,255,255,0.1)'/%3E%3Ccircle%20cx='320'%20cy='500'%20r='140'%20fill='rgba(0,0,0,0.15)'/%3E%3Ctext%20x='56'%20y='584'%20font-family='monospace'%20font-size='44'%20fill='rgba(255,255,255,0.8)'%3E05%3C/text%3E%3C/svg%3E" alt="Wide abstract orange gradient banner">
                    </div>
                    <figcaption><span class="clip-caption-index">05</span> Horizon Ember, replays on re-entry</figcaption>
                </figure>
            </div>
        </section>
    </main>

    <!-- GSAP Core -->
    <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>

    <!-- Lenis smooth scroll -->
    <script src="https://unpkg.com/lenis@1.3.17/dist/lenis.min.js"></script>

    <!-- Effect code -->
    <script src="assets/script.js"></script>
</body>
</html>
/**
 * Image Clip Reveal
 *
 * Images reveal on scroll with an animated clip-path wipe and a Ken Burns
 * settle: the inner image scales from 1.25 down to 1 as the clip opens.
 *
 * @plugins ScrollTrigger
 * @techniques scroll-reveal, image-reveal, clip-path, stagger
 */

gsap.registerPlugin(ScrollTrigger);

window.addEventListener('DOMContentLoaded', function initImageClipReveal() {
    const CLOSED_INSETS = {
        up: 'inset(100% 0% 0% 0%)',
        down: 'inset(0% 0% 100% 0%)',
        left: 'inset(0% 0% 0% 100%)',
        right: 'inset(0% 100% 0% 0%)'
    };
    const OPEN_INSET = 'inset(0% 0% 0% 0%)';
    const IMG_START_SCALE = 1.25;

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

        mm.add({
            isMotion: '(prefers-reduced-motion: no-preference)',
            isReduced: '(prefers-reduced-motion: reduce)'
        }, function matchMediaCallback(context) {
            const { isMotion } = context.conditions;

            const allWrappers = gsap.utils.toArray('[data-clip-reveal]');
            const groups = gsap.utils.toArray('[data-clip-reveal-group]');

            // ============================================
            // REDUCED MOTION: show images fully, no clip or scale
            // ============================================
            if (!isMotion) {
                allWrappers.forEach(function showStatic(wrapper) {
                    gsap.set(wrapper, { clipPath: 'none' });
                    const img = wrapper.querySelector('img');
                    if (img) gsap.set(img, { scale: 1 });
                });
                return;
            }

            // ============================================
            // CONFIGURATION FROM DATA ATTRIBUTES
            // ============================================
            function readConfig(el, fallback) {
                fallback = fallback || {};
                const direction = el.dataset.revealDirection || fallback.direction || 'up';
                const onceAttr = el.dataset.revealOnce;

                return {
                    direction: CLOSED_INSETS[direction] ? direction : 'up',
                    duration: parseFloat(el.dataset.revealDuration) || fallback.duration || 1.1,
                    delay: parseFloat(el.dataset.revealDelay) || fallback.delay || 0,
                    once: onceAttr !== undefined
                        ? onceAttr !== 'false'
                        : fallback.once !== false
                };
            }

            // ============================================
            // ANIMATION BUILDER
            // ============================================
            function addReveal(timeline, wrapper, config, position) {
                const img = wrapper.querySelector('img');

                timeline.fromTo(wrapper, {
                    clipPath: CLOSED_INSETS[config.direction]
                }, {
                    clipPath: OPEN_INSET,
                    duration: config.duration,
                    delay: config.delay,
                    ease: 'expo.out'
                }, position);

                if (img) {
                    timeline.fromTo(img, {
                        scale: IMG_START_SCALE
                    }, {
                        scale: 1,
                        duration: config.duration,
                        delay: config.delay,
                        ease: 'expo.out'
                    }, position);
                }
            }

            function buildScrollTrigger(triggerEl, once) {
                const st = {
                    trigger: triggerEl,
                    start: 'top 85%'
                };

                if (once) {
                    st.once = true;
                } else {
                    // Replay when re-entering from either direction
                    st.toggleActions = 'restart none none reset';
                }

                return st;
            }

            // ============================================
            // GROUPS: stagger child reveals on one trigger
            // ============================================
            groups.forEach(function initGroup(group) {
                const children = gsap.utils.toArray('[data-clip-reveal]', group);
                if (!children.length) return;

                const groupConfig = readConfig(group);
                const stagger = parseFloat(group.dataset.revealStagger) || 0.12;

                const tl = gsap.timeline({
                    scrollTrigger: buildScrollTrigger(group, groupConfig.once)
                });

                children.forEach(function addChild(wrapper, i) {
                    const config = readConfig(wrapper, groupConfig);
                    addReveal(tl, wrapper, config, i * stagger);
                });
            });

            // ============================================
            // STANDALONE WRAPPERS: one trigger each
            // ============================================
            allWrappers.forEach(function initWrapper(wrapper) {
                if (wrapper.closest('[data-clip-reveal-group]')) return;

                const config = readConfig(wrapper);

                const tl = gsap.timeline({
                    scrollTrigger: buildScrollTrigger(wrapper, config.once)
                });

                addReveal(tl, wrapper, config, 0);
            });

            // ============================================
            // CLEANUP FUNCTION
            // ============================================
            return function cleanup() {
                ScrollTrigger.getAll().forEach(function killTrigger(trigger) {
                    trigger.kill();
                });
            };
        });
    });

    // Store context for SPA cleanup
    window.gsapContext = ctx;
});

// ============================================
// OPTIONAL: Lenis smooth scroll integration
// ============================================
(function initLenis() {
    if (typeof Lenis === 'undefined') return;

    const lenis = new Lenis({
        duration: 1.2,
        smoothWheel: true
    });

    lenis.on('scroll', ScrollTrigger.update);

    gsap.ticker.add(function raf(time) {
        lenis.raf(time * 1000);
    });
    gsap.ticker.lagSmoothing(0);

    // Store for cleanup
    window.lenis = lenis;
})();
gsap.registerPlugin(ScrollTrigger),window.addEventListener("DOMContentLoaded",function(){const e={up:"inset(100% 0% 0% 0%)",down:"inset(0% 0% 100% 0%)",left:"inset(0% 0% 0% 100%)",right:"inset(0% 100% 0% 0%)"},t=gsap.context(function(){gsap.matchMedia().add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(t){const{isMotion:n}=t.conditions,o=gsap.utils.toArray("[data-clip-reveal]"),r=gsap.utils.toArray("[data-clip-reveal-group]");if(n)return r.forEach(function(e){const t=gsap.utils.toArray("[data-clip-reveal]",e);if(!t.length)return;const n=a(e),o=parseFloat(e.dataset.revealStagger)||.12,r=gsap.timeline({scrollTrigger:c(e,n.once)});t.forEach(function(e,t){const c=a(e,n);i(r,e,c,t*o)})}),o.forEach(function(e){if(e.closest("[data-clip-reveal-group]"))return;const t=a(e);i(gsap.timeline({scrollTrigger:c(e,t.once)}),e,t,0)}),function(){ScrollTrigger.getAll().forEach(function(e){e.kill()})};function a(t,n){n=n||{};const o=t.dataset.revealDirection||n.direction||"up",r=t.dataset.revealOnce;return{direction:e[o]?o:"up",duration:parseFloat(t.dataset.revealDuration)||n.duration||1.1,delay:parseFloat(t.dataset.revealDelay)||n.delay||0,once:void 0!==r?"false"!==r:!1!==n.once}}function i(t,n,o,r){const a=n.querySelector("img");t.fromTo(n,{clipPath:e[o.direction]},{clipPath:"inset(0% 0% 0% 0%)",duration:o.duration,delay:o.delay,ease:"expo.out"},r),a&&t.fromTo(a,{scale:1.25},{scale:1,duration:o.duration,delay:o.delay,ease:"expo.out"},r)}function c(e,t){const n={trigger:e,start:"top 85%"};return t?n.once=!0:n.toggleActions="restart none none reset",n}o.forEach(function(e){gsap.set(e,{clipPath:"none"});const t=e.querySelector("img");t&&gsap.set(t,{scale:1})})})});window.gsapContext=t}),function(){if("undefined"==typeof Lenis)return;const e=new Lenis({duration:1.2,smoothWheel:!0});e.on("scroll",ScrollTrigger.update),gsap.ticker.add(function(t){e.raf(1e3*t)}),gsap.ticker.lagSmoothing(0),window.lenis=e}();
/* ============================================
   GSAP VAULT DESIGN SYSTEM
   ============================================ */

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    /* Core colors */
    --black: #0a0a0a;
    --dark: #111111;
    --surface: #1a1a1a;
    --surface-light: #242424;
    --border: #2a2a2a;
    --border-light: #3a3a3a;

    /* Text hierarchy */
    --text: #ffffff;
    --text-secondary: #b0b0b0;
    --text-muted: #999999;

    /* Accent colors - CHOOSE ONE per effect */
    --orange: #ff8c00;      /* Warm, editorial */
    --orange-light: #ffb347;

    /* Set chosen accent (change per effect) */
    --accent: var(--orange);

    /* Easing - matches GSAP patterns */
    --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
    --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
    --ease-out-cubic: cubic-bezier(0.33, 1, 0.68, 1);
}

html {
    scroll-behavior: auto;
}

body {
    background: var(--black);
    color: var(--text);
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-weight: 400;
    overflow-x: hidden;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    line-height: 1.6;
}

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

/* ============================================
   CUSTOM SCROLLBAR
   ============================================ */
::-webkit-scrollbar {
    width: 10px;
}

::-webkit-scrollbar-track {
    background: var(--black);
}

::-webkit-scrollbar-thumb {
    background: var(--surface-light);
    border-radius: 5px;
    border: 2px solid var(--black);
}

::-webkit-scrollbar-thumb:hover {
    background: var(--border-light);
}

/* ============================================
   TYPOGRAPHY
   ============================================ */

/* Syne for display/headlines */
h1, h2, h3, h4, h5, h6 {
    font-family: 'Syne', system-ui, sans-serif;
    font-weight: 700;
    line-height: 1.1;
    letter-spacing: -0.03em;
}

/* JetBrains Mono for code/labels */
code {
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.85em;
    background: var(--surface);
    padding: 0.15em 0.4em;
    border-radius: 4px;
}

/* ============================================
   LAYOUT
   ============================================ */
.container {
    max-width: 1000px;
    margin: 0 auto;
    padding: 5rem 2rem;
}

/* ============================================
   PAGE HEADER
   ============================================ */
.page-header {
    text-align: center;
    margin-bottom: 6rem;
    padding-top: 2rem;
}

.page-title {
    font-size: clamp(2.5rem, 7vw, 4rem);
    font-weight: 700;
    letter-spacing: -0.03em;
    margin-bottom: 1rem;
}

.page-subtitle {
    font-size: 1.125rem;
    color: var(--text-secondary);
    max-width: 500px;
    margin: 0 auto;
}

/* ============================================
   EXAMPLE SECTIONS
   ============================================ */
.example {
    margin-bottom: 8rem;
    padding-bottom: 4rem;
    border-bottom: 1px solid var(--border);
}

.example:last-child {
    margin-bottom: 0;
    padding-bottom: 0;
    border-bottom: none;
}

.example-header {
    display: flex;
    align-items: center;
    gap: 1rem;
    margin-bottom: 2rem;
}

.example-number {
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--accent);
    letter-spacing: 0.1em;
}

.example-title {
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-size: 0.875rem;
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--text-muted);
}

.example-content {
    padding-left: 3rem;
}

.example-instruction {
    font-size: 1rem;
    color: var(--text-secondary);
    margin-bottom: 2rem;
    line-height: 1.7;
}

/* ============================================
   EFFECT-SPECIFIC STYLES
   Image Clip Reveal
   ============================================ */

/*
 * Initial states live here so images never flash before the
 * script runs: the wrapper starts fully clipped on the edge
 * matching its data-reveal-direction, and the inner image
 * starts oversized for the Ken Burns settle.
 */
[data-clip-reveal] {
    position: relative;
    overflow: hidden;
    border-radius: 12px;
    clip-path: inset(100% 0% 0% 0%);
}

[data-clip-reveal][data-reveal-direction="down"] {
    clip-path: inset(0% 0% 100% 0%);
}

[data-clip-reveal][data-reveal-direction="left"] {
    clip-path: inset(0% 0% 0% 100%);
}

[data-clip-reveal][data-reveal-direction="right"] {
    clip-path: inset(0% 100% 0% 0%);
}

[data-clip-reveal] img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    transform: scale(1.25);
    transform-origin: center center;
    will-change: transform;
}

/* Editorial 2x2 grid */
.clip-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 2rem 1.5rem;
}

.clip-figure {
    margin: 0;
}

.clip-figure .clip-media {
    aspect-ratio: 4 / 3;
}

.clip-figure figcaption {
    display: flex;
    align-items: baseline;
    gap: 0.75rem;
    margin-top: 0.85rem;
    font-size: 0.875rem;
    color: var(--text-secondary);
}

.clip-caption-index {
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.7rem;
    font-weight: 600;
    letter-spacing: 0.15em;
    color: var(--accent);
}

/* Full-width single reveal */
.clip-banner .clip-media {
    aspect-ratio: 21 / 9;
}

/* ============================================
   RESPONSIVE
   ============================================ */
@media (max-width: 768px) {
    .container {
        padding: 3rem 1.5rem;
    }

    .page-header {
        margin-bottom: 4rem;
    }

    .example {
        margin-bottom: 5rem;
        padding-bottom: 3rem;
    }

    .example-content {
        padding-left: 0;
    }

    .clip-grid {
        grid-template-columns: 1fr;
    }
}

/* ============================================
   REDUCED MOTION
   ============================================ */
@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }

    /* Show images fully: no clip, no scale */
    [data-clip-reveal],
    [data-clip-reveal][data-reveal-direction="down"],
    [data-clip-reveal][data-reveal-direction="left"],
    [data-clip-reveal][data-reveal-direction="right"] {
        clip-path: none !important;
    }

    [data-clip-reveal] img {
        transform: none !important;
        will-change: auto;
    }
}

/* ============================================
   SCREEN READER ONLY (for ARIA live regions)
   ============================================ */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

Images reveal on scroll with an animated clip-path wipe and a Ken Burns settle: the inner image eases from 1.25 scale down to 1 as the mask opens.

Quick Start

1. Add to your HTML <head>:

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

2. Add before 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>

3. Wrap any image in a data-clip-reveal element:

<div data-clip-reveal>
  <img src="photo.jpg" alt="Description of the photo">
</div>

The stylesheet gives the wrapper overflow: hidden and a fully closed clip-path as its initial state, so images never flash before the script runs. If you copy only the effect styles into your own stylesheet, make sure you bring the [data-clip-reveal] rules with you.

Options

All options are data attributes on the data-clip-reveal wrapper:

Attribute Values Default Description
data-reveal-direction up, down, left, right up Wipe direction of the clip-path inset
data-reveal-duration Seconds 1.1 Duration of the wipe and the scale settle
data-reveal-delay Seconds 0 Delay before the reveal starts
data-reveal-once true, false true Play once, or replay when re-entering the viewport

Examples

Direction Control

HTML:

<!-- Reveal upward from the bottom edge -->
<div data-clip-reveal data-reveal-direction="up">
  <img src="photo-a.jpg" alt="">
</div>

<!-- Reveal from the right edge, travelling left -->
<div data-clip-reveal data-reveal-direction="left">
  <img src="photo-b.jpg" alt="">
</div>

Slower Reveal That Replays

HTML:

<div data-clip-reveal
     data-reveal-direction="right"
     data-reveal-duration="1.6"
     data-reveal-delay="0.2"
     data-reveal-once="false">
  <img src="hero.jpg" alt="">
</div>

Staggered Groups

Wrap multiple reveals in a data-clip-reveal-group container. The group gets a single ScrollTrigger and its children reveal in sequence. Children can still set their own direction, duration, and delay; anything they leave out is inherited from the group.

HTML:

<div class="image-grid" data-clip-reveal-group data-reveal-stagger="0.15">
  <div data-clip-reveal data-reveal-direction="up">
    <img src="one.jpg" alt="">
  </div>
  <div data-clip-reveal data-reveal-direction="down">
    <img src="two.jpg" alt="">
  </div>
  <div data-clip-reveal data-reveal-direction="left">
    <img src="three.jpg" alt="">
  </div>
</div>
Group Attribute Values Default Description
data-clip-reveal-group (presence) n/a Marks a container whose child reveals are staggered
data-reveal-stagger Seconds 0.12 Delay between each child reveal

Group-level data-reveal-direction, data-reveal-duration, data-reveal-delay, and data-reveal-once act as defaults for every child.

How It Works

Each wrapper (or group) gets a ScrollTrigger that fires at top 85%. The timeline tweens the wrapper's clip-path from a fully closed inset() on the chosen edge to inset(0%) while simultaneously scaling the inner img from 1.25 to 1, both with expo.out, so the wipe and the settle read as a single motion. With data-reveal-once="false" the trigger uses toggleActions: 'restart none none reset' so the reveal resets when you scroll back above it and plays again on re-entry.

Accessibility

  • Reduced motion: respects prefers-reduced-motion in both CSS and JavaScript. Images display fully with no clip and no scale, and no ScrollTriggers are created
  • No flash of hidden content: initial states are set in CSS, so nothing pops or jumps while the script loads
  • Alt text: the wrapper is presentation-only; keep meaningful alt text on the img elements themselves
  • No pointer requirement: the effect is scroll-driven only, so keyboard and touch users get the identical experience

Reduced Motion Behavior

When prefers-reduced-motion: reduce is set:

  • The stylesheet clears the clip-path and scale with !important overrides
  • The script's reduce branch sets images to their final state and skips all animation

Programmatic Control

The GSAP context is exposed globally. Add this to your own JavaScript:

JavaScript:

// Revert everything the effect created
window.gsapContext.revert();

// Kill all ScrollTriggers manually
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());

Browser Support

Modern browsers (ES6+). clip-path: inset() is supported everywhere GSAP 3 runs, except IE11.

Dependencies

Required:

  • GSAP 3.12+
  • ScrollTrigger plugin

Optional:

  • Lenis (smooth scroll integration; the effect works without it)

Your Cart

Your cart is empty

Browse Effects