f05

Image Clip Reveal

Free

A cinematic image reveal where a directional polygon aperture opens as the photograph settles from a restrained Ken Burns scale and its caption lands.

ScrollTrigger Lenis beginner
4 more details
scroll-revealclip-pathken-burnsstagger

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

Updated

About this effect

A cinematic image reveal effect that opens photographs through a directional polygon aperture while GSAP settles the inner image from a restrained scale. Optional caption, rule and atmosphere hooks extend the same timeline into a complete editorial entrance without changing the core image API. Direction, duration, delay, replay behaviour, and staggered groups remain configurable through data attributes.

Image Clip Reveal - GSAP animation effect preview

What's included

8 items
  • Architectural polygon aperture with four directional reveal options
  • Restrained inner-image scale settle synchronized with the mask
  • Optional caption, mark and atmosphere timeline hooks
  • Per-image direction, duration, delay, and replay data attributes
  • Stagger groups preserved for sequencing multiple image wrappers
  • Semantic replay control and a small programmatic replay API
  • No-JavaScript fallback that shows the full image and caption
  • Reduced-motion branch with no clipping, scale, or ScrollTrigger

Perfect for

5 use cases
  • Architecture portfolios with a precise editorial image entrance
  • Travel stories with cinematic location photography and captions
  • Case-study heroes that need one composed visual reveal
  • Magazine layouts with directional image wipes and metadata
  • Campaign landing pages with restrained, replayable photography

How it works

1 section

Each data-clip-reveal wrapper receives a GSAP timeline and ScrollTrigger. The wrapper's six-point clip-path interpolates from a narrow directional wedge to the full frame while its inner image scales from 1.14 to 1; optional atmosphere, rule, and caption targets enter just after the image becomes legible. Data attributes preserve direction, duration, delay, once/replay, and group stagger controls, while window.imageClipReveal exposes replay() and destroy() methods. A gsap.matchMedia() reduced-motion branch clears all animated states, and initial hidden styles are gated by a head-added has-js class so the no-JavaScript fallback remains complete.

Plugins ScrollTrigger
Difficulty Beginner
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">
    <meta name="robots" content="noindex, nofollow">
    <title>Image Clip Reveal Demo | GSAP Vault</title>
    <!-- The has-js gate: reveal start states only apply when JavaScript is
         running, so the photograph and caption stay visible without it. -->
    <script>document.documentElement.classList.add('has-js');</script>
    <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=Mona+Sans:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
    <main class="feature">
        <!-- Toolbar: the one prompt line and the documented replay button. -->
        <header class="feature-bar">
            <p class="prompt">
                <span class="prompt-dot" aria-hidden="true"></span>
                <strong>Watch the aperture open</strong>
                <span>then scroll for the second photograph</span>
            </p>
            <button class="btn" type="button" data-clip-replay>Replay</button>
        </header>

        <!-- Photograph one. data-clip-reveal is the effect; the polygon
             aperture opens from the left edge, then the caption lands. -->
        <figure class="photo">
            <div class="photo-media"
                 data-clip-reveal
                 data-reveal-direction="right"
                 data-reveal-duration="1.45"
                 data-reveal-once="true">
                <img src="assets/img/feature.webp"
                     alt="Corner of a concrete brutalist building, its stacked window bays lit by low sun against a clear sky"
                     width="1600"
                     height="1067"
                     decoding="async">
            </div>

            <!-- Caption: an accent mark, then three lines that rise through
                 their masks in sequence. All three hooks are optional. -->
            <figcaption class="photo-caption" data-reveal-caption>
                <span class="caption-mark" data-reveal-rule aria-hidden="true"></span>
                <span class="caption-mask caption-place"><span data-reveal-copy>Ottawa, Canada</span></span>
                <span class="caption-mask caption-title"><span data-reveal-copy>The library that turned its back on the street.</span></span>
                <span class="caption-mask caption-note"><span data-reveal-copy>Photograph by Ana Ferreira</span></span>
            </figcaption>
        </figure>

        <!-- Body copy: plain page content between the two photographs. -->
        <section class="passage" aria-label="Feature text">
            <p>Built in 1971 and threatened with demolition twice since, the building keeps its windows in deep concrete bays that shade the reading rooms from the afternoon. Critics called it hostile. The librarians, who have never needed blinds, call it sensible.</p>
        </section>

        <!-- Photograph two. Same effect, opening from the bottom, and it
             replays every time it scrolls back into view. -->
        <figure class="photo photo--second">
            <div class="photo-media"
                 data-clip-reveal
                 data-reveal-direction="up"
                 data-reveal-duration="1.3"
                 data-reveal-once="false">
                <img src="assets/img/second.webp"
                     alt="A woven metal facade sweeping in a curve against a blue sky"
                     width="1600"
                     height="1067"
                     decoding="async"
                     loading="lazy">
            </div>
            <figcaption class="photo-caption" data-reveal-caption>
                <span class="caption-mark" data-reveal-rule aria-hidden="true"></span>
                <span class="caption-mask caption-place"><span data-reveal-copy>Basel, Switzerland</span></span>
                <span class="caption-mask caption-title"><span data-reveal-copy>Across the river, the newer answer.</span></span>
                <span class="caption-mask caption-note"><span data-reveal-copy>Photograph by Ana Ferreira</span></span>
            </figcaption>
        </figure>
    </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>
</body>
</html>
/**
 * Image Clip Reveal
 *
 * A directional polygon aperture opens while the image settles from a
 * restrained scale. Optional caption, rule, and atmosphere elements follow.
 *
 * @plugins ScrollTrigger
 * @techniques scroll-reveal, image-reveal, clip-path, ken-burns
 */

gsap.registerPlugin(ScrollTrigger);

(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initImageClipReveal() {
    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 syncLenisOnRefresh = null;
    let beforeUnload = null;

    function initLenis() {
        if (!wantsSmooth || lenis || typeof Lenis === 'undefined') return;

        const hasST = typeof ScrollTrigger !== 'undefined';
        lenis = new Lenis({ autoRaf: !hasST });
        if (hasST) {
            lenis.on('scroll', ScrollTrigger.update);
            lenisTick = function (time) { lenis.raf(time * 1000); };
            gsap.ticker.add(lenisTick);
            gsap.ticker.lagSmoothing(0);
            syncLenisOnRefresh = function () {
                if (lenis) lenis.scrollTo(window.scrollY, { immediate: true, force: true });
            };
            ScrollTrigger.addEventListener('refresh', syncLenisOnRefresh);
        }
    }

    function destroyLenis() {
        if (lenisTick) gsap.ticker.remove(lenisTick);
        if (syncLenisOnRefresh) ScrollTrigger.removeEventListener('refresh', syncLenisOnRefresh);
        if (lenis) lenis.destroy();
        lenis = null;
        lenisTick = null;
        syncLenisOnRefresh = null;
    }

    /* Six points keep interpolation stable while the leading edge forms a
       narrow architectural wedge. Direction names retain the original API. */
    const CLOSED_CLIPS = {
        up: 'polygon(0% 100%, 42% 100%, 50% 94%, 58% 100%, 100% 100%, 0% 100%)',
        down: 'polygon(0% 0%, 42% 0%, 50% 6%, 58% 0%, 100% 0%, 0% 0%)',
        left: 'polygon(100% 0%, 100% 42%, 94% 50%, 100% 58%, 100% 100%, 100% 0%)',
        right: 'polygon(0% 0%, 0% 42%, 6% 50%, 0% 58%, 0% 100%, 0% 0%)'
    };
    const OPEN_CLIP = 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 100%, 0% 0%)';
    const IMG_START_SCALE = 1.14;
    const timelines = [];
    const triggers = [];
    const handlers = new Map();

    function numberOr(value, fallback) {
        const parsed = parseFloat(value);
        return Number.isFinite(parsed) ? parsed : fallback;
    }

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

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

    function registerTimeline(timeline) {
        timelines.push(timeline);
        if (timeline.scrollTrigger) triggers.push(timeline.scrollTrigger);
        return timeline;
    }

    function addReveal(timeline, wrapper, config, position) {
        const image = wrapper.querySelector('img');
        const figure = wrapper.closest('figure');
        const caption = figure && figure.querySelector('[data-reveal-caption]');
        const rule = caption && caption.querySelector('[data-reveal-rule]');
        const captionItems = caption
            ? gsap.utils.toArray('[data-reveal-copy]', caption)
            : [];
        const atmosphere = figure && figure.querySelector('[data-reveal-atmosphere]');
        const captionAt = position + config.delay + (config.duration * 0.72);

        timeline.fromTo(wrapper, {
            clipPath: CLOSED_CLIPS[config.direction]
        }, {
            clipPath: OPEN_CLIP,
            duration: config.duration,
            delay: config.delay,
            ease: 'expo.inOut'
        }, position);

        if (image) {
            timeline.fromTo(image, {
                scale: IMG_START_SCALE
            }, {
                scale: 1,
                duration: config.duration + 0.18,
                delay: config.delay,
                ease: 'power3.out'
            }, position);
        }

        if (atmosphere) {
            timeline.fromTo(atmosphere, {
                opacity: 0,
                scale: 0.84
            }, {
                opacity: 0.7,
                scale: 1,
                duration: config.duration * 0.9,
                ease: 'sine.out'
            }, position + config.delay + 0.12);
        }

        if (rule) {
            timeline.fromTo(rule, {
                scaleX: 0,
                transformOrigin: config.direction === 'left' ? 'right center' : 'left center'
            }, {
                scaleX: 1,
                duration: 0.65,
                ease: 'expo.out'
            }, captionAt);
        }

        if (captionItems.length) {
            timeline.fromTo(captionItems, {
                yPercent: 115,
                y: 0,
                opacity: 0
            }, {
                yPercent: 0,
                y: 0,
                opacity: 1,
                duration: 0.7,
                stagger: 0.07,
                ease: 'expo.out'
            }, captionAt + 0.08);
        }
    }

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

        if (once) {
            settings.once = true;
        } else {
            settings.toggleActions = 'restart none none reset';
        }

        return settings;
    }

    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 wrappers = gsap.utils.toArray('[data-clip-reveal]');
            const groups = gsap.utils.toArray('[data-clip-reveal-group]');

            if (!isMotion) {
                gsap.set(wrappers, { clipPath: 'none' });
                wrappers.forEach(function showStatic(wrapper) {
                    const image = wrapper.querySelector('img');
                    const figure = wrapper.closest('figure');
                    if (image) gsap.set(image, { scale: 1 });
                    if (figure) {
                        gsap.set(figure.querySelectorAll('[data-reveal-caption], [data-reveal-copy], [data-reveal-rule]'), {
                            clearProps: 'all'
                        });
                        const atmosphere = figure.querySelector('[data-reveal-atmosphere]');
                        if (atmosphere) gsap.set(atmosphere, { opacity: 0.45, scale: 1 });
                    }
                });
                return;
            }

            initLenis();

            groups.forEach(function initGroup(group) {
                const children = gsap.utils.toArray('[data-clip-reveal]', group);
                if (!children.length) return;

                const groupConfig = readConfig(group);
                const stagger = numberOr(group.dataset.revealStagger, 0.12);
                const timeline = registerTimeline(gsap.timeline({
                    scrollTrigger: buildScrollTrigger(group, groupConfig.once)
                }));

                children.forEach(function addChild(wrapper, index) {
                    addReveal(timeline, wrapper, readConfig(wrapper, groupConfig), index * stagger);
                });
            });

            wrappers.forEach(function initWrapper(wrapper) {
                if (wrapper.closest('[data-clip-reveal-group]')) return;
                const config = readConfig(wrapper);
                const timeline = registerTimeline(gsap.timeline({
                    scrollTrigger: buildScrollTrigger(wrapper, config.once)
                }));
                addReveal(timeline, wrapper, config, 0);
            });

            gsap.utils.toArray('[data-clip-replay]').forEach(function initReplay(button) {
                const handleReplay = function () {
                    timelines.forEach(function replayTimeline(timeline) {
                        if (timeline && timeline.scrollTrigger) timeline.restart(true);
                    });
                };
                button.addEventListener('click', handleReplay);
                handlers.set(button, handleReplay);
            });

            return function cleanupMotion() {
                handlers.forEach(function removeHandler(handler, element) {
                    element.removeEventListener('click', handler);
                });
                handlers.clear();
                triggers.forEach(function killTrigger(trigger) { trigger.kill(); });
                timelines.forEach(function killTimeline(timeline) { timeline.kill(); });
                triggers.length = 0;
                timelines.length = 0;
                destroyLenis();
            };
        });
    });

    window.gsapContext = ctx;
    window.imageClipReveal = {
        replay: function replay() {
            timelines.forEach(function restartTimeline(timeline) { timeline.restart(true); });
        },
        destroy: function destroy() {
            ctx.revert();
            destroyLenis();
        }
    };

    beforeUnload = function cleanupBeforeUnload() {
        window.imageClipReveal.destroy();
        window.removeEventListener('beforeunload', beforeUnload);
    };
    window.addEventListener('beforeunload', beforeUnload);
});
gsap.registerPlugin(ScrollTrigger),function(e){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e):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,o=null,r=null,n=null;function a(){o&&gsap.ticker.remove(o),r&&ScrollTrigger.removeEventListener("refresh",r),t&&t.destroy(),t=null,o=null,r=null}const i={up:"polygon(0% 100%, 42% 100%, 50% 94%, 58% 100%, 100% 100%, 0% 100%)",down:"polygon(0% 0%, 42% 0%, 50% 6%, 58% 0%, 100% 0%, 0% 0%)",left:"polygon(100% 0%, 100% 42%, 94% 50%, 100% 58%, 100% 100%, 100% 0%)",right:"polygon(0% 0%, 0% 42%, 6% 50%, 0% 58%, 0% 100%, 0% 0%)"},c=[],l=[],s=new Map;function u(e,t){const o=parseFloat(e);return Number.isFinite(o)?o:t}function d(e,t){t=t||{};const o=e.dataset.revealDirection||t.direction||"up",r=e.dataset.revealOnce;return{direction:i[o]?o:"up",duration:u(e.dataset.revealDuration,t.duration||1.1),delay:u(e.dataset.revealDelay,t.delay||0),once:void 0!==r?"false"!==r:!1!==t.once}}function g(e){return c.push(e),e.scrollTrigger&&l.push(e.scrollTrigger),e}function f(e,t,o,r){const n=t.querySelector("img"),a=t.closest("figure"),c=a&&a.querySelector("[data-reveal-caption]"),l=c&&c.querySelector("[data-reveal-rule]"),s=c?gsap.utils.toArray("[data-reveal-copy]",c):[],u=a&&a.querySelector("[data-reveal-atmosphere]"),d=r+o.delay+.72*o.duration;e.fromTo(t,{clipPath:i[o.direction]},{clipPath:"polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 100%, 0% 0%)",duration:o.duration,delay:o.delay,ease:"expo.inOut"},r),n&&e.fromTo(n,{scale:1.14},{scale:1,duration:o.duration+.18,delay:o.delay,ease:"power3.out"},r),u&&e.fromTo(u,{opacity:0,scale:.84},{opacity:.7,scale:1,duration:.9*o.duration,ease:"sine.out"},r+o.delay+.12),l&&e.fromTo(l,{scaleX:0,transformOrigin:"left"===o.direction?"right center":"left center"},{scaleX:1,duration:.65,ease:"expo.out"},d),s.length&&e.fromTo(s,{yPercent:115,y:0,opacity:0},{yPercent:0,y:0,opacity:1,duration:.7,stagger:.07,ease:"expo.out"},d+.08)}function p(e,t){const o={trigger:e,start:"top 85%"};return t?o.once=!0:o.toggleActions="restart none none reset",o}const y=gsap.context(function(){gsap.matchMedia().add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(n){const{isMotion:i}=n.conditions,y=gsap.utils.toArray("[data-clip-reveal]"),m=gsap.utils.toArray("[data-clip-reveal-group]");return i?(function(){if(!e||t||"undefined"==typeof Lenis)return;const n="undefined"!=typeof ScrollTrigger;t=new Lenis({autoRaf:!n}),n&&(t.on("scroll",ScrollTrigger.update),o=function(e){t.raf(1e3*e)},gsap.ticker.add(o),gsap.ticker.lagSmoothing(0),r=function(){t&&t.scrollTo(window.scrollY,{immediate:!0,force:!0})},ScrollTrigger.addEventListener("refresh",r))}(),m.forEach(function(e){const t=gsap.utils.toArray("[data-clip-reveal]",e);if(!t.length)return;const o=d(e),r=u(e.dataset.revealStagger,.12),n=g(gsap.timeline({scrollTrigger:p(e,o.once)}));t.forEach(function(e,t){f(n,e,d(e,o),t*r)})}),y.forEach(function(e){if(e.closest("[data-clip-reveal-group]"))return;const t=d(e);f(g(gsap.timeline({scrollTrigger:p(e,t.once)})),e,t,0)}),gsap.utils.toArray("[data-clip-replay]").forEach(function(e){const t=function(){c.forEach(function(e){e&&e.scrollTrigger&&e.restart(!0)})};e.addEventListener("click",t),s.set(e,t)}),function(){s.forEach(function(e,t){t.removeEventListener("click",e)}),s.clear(),l.forEach(function(e){e.kill()}),c.forEach(function(e){e.kill()}),l.length=0,c.length=0,a()}):(gsap.set(y,{clipPath:"none"}),void y.forEach(function(e){const t=e.querySelector("img"),o=e.closest("figure");if(t&&gsap.set(t,{scale:1}),o){gsap.set(o.querySelectorAll("[data-reveal-caption], [data-reveal-copy], [data-reveal-rule]"),{clearProps:"all"});const e=o.querySelector("[data-reveal-atmosphere]");e&&gsap.set(e,{opacity:.45,scale:1})}}))})});window.gsapContext=y,window.imageClipReveal={replay:function(){c.forEach(function(e){e.restart(!0)})},destroy:function(){y.revert(),a()}},n=function(){window.imageClipReveal.destroy(),window.removeEventListener("beforeunload",n)},window.addEventListener("beforeunload",n)});
/* ============================================
   IMAGE CLIP REVEAL: DEMO STYLES

   An editorial photo feature on a light grey ground: one ink, hairlines,
   Mona Sans, and one red accent for the caption mark, the prompt dot, the
   focus ring and selection. The photographs supply the rest of the colour.

   Rules the effect depends on are marked "contract" below. Everything else
   is the demo's page and can be replaced with your own design.
   ============================================ */

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

:root {
    color-scheme: light;

    --page: #ececeb;
    --surface: #f7f7f6;
    --surface-sunk: #e2e2e0;
    --ink: #141412;
    --ink-dim: rgba(20, 20, 18, 0.72);
    --ink-faint: rgba(20, 20, 18, 0.64);
    --rule: rgba(20, 20, 18, 0.14);
    --rule-strong: rgba(20, 20, 18, 0.3);

    --font-sans: 'Mona Sans', 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;

    --accent: #e5322d;          /* red: the caption mark, the focus ring, ::selection */
    --accent-ink: #ffffff;

    --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
    --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
}

html,
body {
    min-height: 100%;
    background: var(--page);
}

html {
    scroll-behavior: auto;
    overflow-x: clip;
}

body {
    min-height: 100svh;
    overflow-x: clip;
    color: var(--ink);
    font-family: var(--font-sans);
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

img {
    display: block;
}

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

/* ============================================
   PAGE SHELL
   ============================================ */
.feature {
    width: min(100%, 1120px);
    margin: 0 auto;
    padding: clamp(0.9rem, 2vw, 1.25rem) clamp(1rem, 3vw, 2rem) clamp(2rem, 6vw, 4rem);
}

.feature-bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    min-height: 2.25rem;
    margin-bottom: clamp(0.75rem, 1.6vw, 1rem);
}

.prompt {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    color: var(--ink-faint);
    font-size: 0.8125rem;
    line-height: 1.35;
}

.prompt strong {
    color: var(--ink);
    font-weight: 500;
}

.prompt-dot {
    flex: 0 0 auto;
    width: 0.4rem;
    height: 0.4rem;
    border-radius: 50%;
    background: var(--accent);
}

.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0.45rem 0.9rem;
    font: inherit;
    font-size: 0.8125rem;
    font-weight: 500;
    border: 1px solid var(--rule-strong);
    border-radius: 6px;
    background: transparent;
    color: var(--ink);
    cursor: pointer;
    transition: background-color 0.2s var(--ease-out-quart);
}

.btn:hover {
    background: var(--surface-sunk);
}

.btn:focus-visible {
    background: var(--surface-sunk);
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* ============================================
   PHOTOGRAPH
   ============================================ */
.photo {
    margin: 0;
}

.photo--second {
    margin-top: clamp(2rem, 6vw, 4rem);
}

/* CONTRACT: the wrapper must clip. The polygon reveals the wrapper, and the
   inner image starts scaled up, so anything that overflows would show
   around the aperture. The neutral plate shows until the image has loaded. */
.photo-media {
    position: relative;
    width: 100%;
    height: min(62svh, 620px);
    min-height: 260px;
    overflow: hidden;
    background: var(--surface-sunk);
    isolation: isolate;
}

/* CONTRACT: the closed aperture, one per direction, gated under .has-js so
   the photograph is visible without JavaScript. The six points match the
   open polygon in script.js point for point, which keeps the interpolation
   stable. */
.has-js [data-clip-reveal] {
    clip-path: polygon(0% 100%, 42% 100%, 50% 94%, 58% 100%, 100% 100%, 0% 100%);
}

.has-js [data-clip-reveal][data-reveal-direction='down'] {
    clip-path: polygon(0% 0%, 42% 0%, 50% 6%, 58% 0%, 100% 0%, 0% 0%);
}

.has-js [data-clip-reveal][data-reveal-direction='left'] {
    clip-path: polygon(100% 0%, 100% 42%, 94% 50%, 100% 58%, 100% 100%, 100% 0%);
}

.has-js [data-clip-reveal][data-reveal-direction='right'] {
    clip-path: polygon(0% 0%, 0% 42%, 6% 50%, 0% 58%, 0% 100%, 0% 0%);
}

.photo-media img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center 45%;
    transform-origin: center 55%;
}

/* CONTRACT: the Ken Burns start state. script.js tweens scale from 1.14 to
   1; without this the first frame would paint at 1 and jump. */
.has-js [data-clip-reveal] img {
    transform: scale(1.14);
    will-change: transform;
}

/* ============================================
   CAPTION
   ============================================ */
.photo-caption {
    display: grid;
    grid-template-columns: minmax(7rem, 0.9fr) minmax(14rem, 2.4fr) minmax(9rem, 1fr);
    gap: 0.35rem clamp(1rem, 3vw, 2.5rem);
    align-items: start;
    padding-top: 0.9rem;
}

/* The accent mark: a short bar that draws in ahead of the caption copy. */
.caption-mark {
    grid-column: 1 / -1;
    display: block;
    width: 2.5rem;
    height: 3px;
    margin-bottom: 0.35rem;
    background: var(--accent);
}

.has-js .caption-mark {
    transform: scaleX(0);
    transform-origin: left center;
}

/* CONTRACT: each caption line rises through an overflow mask, so the mask
   must clip and the inner span must be a block. */
.caption-mask {
    display: block;
    overflow: hidden;
}

.caption-mask > span {
    display: block;
}

.has-js .caption-mask > span {
    opacity: 0;
    transform: translateY(115%);
}

.caption-place,
.caption-note {
    color: var(--ink-faint);
    font-size: 0.8125rem;
    line-height: 1.4;
    padding-top: 0.2rem;
}

.caption-title {
    font-size: clamp(1.05rem, 1.9vw, 1.5rem);
    font-weight: 600;
    line-height: 1.15;
    letter-spacing: -0.02em;
    text-wrap: balance;
}

.caption-note {
    justify-self: end;
    text-align: right;
}

/* ============================================
   BODY COPY
   ============================================ */
.passage {
    width: min(100%, 40rem);
    margin: clamp(2rem, 6vw, 4rem) auto 0;
    color: var(--ink-dim);
    font-size: 1.0625rem;
    line-height: 1.6;
}

/* ============================================
   RESPONSIVE
   ============================================ */
@media (max-width: 700px) {
    .feature-bar {
        align-items: flex-start;
    }

    .prompt span:last-child {
        display: none;
    }

    .photo-media {
        height: auto;
        aspect-ratio: 4 / 5;
        max-height: 60svh;
    }

    .photo-caption {
        grid-template-columns: 1fr;
        gap: 0.25rem;
    }

    .caption-title {
        font-size: clamp(1.1rem, 5.5vw, 1.4rem);
    }

    .caption-note {
        justify-self: start;
        text-align: left;
    }
}

/* ============================================
   REDUCED MOTION
   CONTRACT: every start state above is undone here, so the page is complete
   without a single tween. script.js also skips the timelines.
   ============================================ */
@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-clip-reveal],
    [data-clip-reveal][data-reveal-direction] {
        clip-path: none !important;
    }

    [data-clip-reveal] img,
    [data-reveal-copy],
    [data-reveal-rule] {
        opacity: 1 !important;
        transform: none !important;
        will-change: auto !important;
    }
}

A directional polygon aperture opens while the inner photograph settles from a restrained scale. Optional caption, mark and atmosphere hooks turn the image wipe into a complete editorial entrance.

Quick Start

1. Add to your HTML <head>:

<script>document.documentElement.classList.add('has-js');</script>
<link rel="stylesheet" href="path/to/style.css">

The small has-js gate is important: animated elements are hidden only when JavaScript is available, so the full image and caption remain visible without JavaScript.

2. Add to your <body>:

<figure>
  <div data-clip-reveal data-reveal-direction="right">
    <img src="architecture.jpg" alt="Glass towers beneath a stormy sky">
  </div>

  <figcaption data-reveal-caption>
    <span data-reveal-rule aria-hidden="true"></span>
    <span class="caption-mask"><span data-reveal-copy>Ottawa, Canada</span></span>
    <span class="caption-mask"><span data-reveal-copy>Constitution Square</span></span>
  </figcaption>
</figure>

data-reveal-caption, data-reveal-rule, and data-reveal-copy are optional. The image reveal works without caption markup.

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>

Using It With Your Own Design

The effect only needs the data-* hooks and a handful of CSS rules; the rest of the demo is a magazine-style photo feature you can delete.

What the effect requires of your markup:

  • A wrapper with data-clip-reveal containing one <img>. That is the whole API for the image.
  • If you want the caption, a <figcaption data-reveal-caption> inside the same <figure>, with each animated line wrapped in a block element that has overflow: hidden (the demo calls it .caption-mask) around a data-reveal-copy span. The script finds the caption through the closest figure, so keep the wrapper and caption inside one.
  • The has-js class added to <html> in the <head>, before the stylesheet loads.

CSS the effect depends on (marked "CONTRACT" in style.css; copy these into your own stylesheet):

  • overflow: hidden on [data-clip-reveal]. The inner image starts at scale(1.14), so without it the overscan shows around the aperture.
  • The closed polygon start states under .has-js [data-clip-reveal], one per data-reveal-direction. They pre-hide the image so it never paints full and then snaps closed.
  • transform: scale(1.14) on .has-js [data-clip-reveal] img, matching the script's start scale.
  • opacity: 0; transform: translateY(115%) on the masked caption spans under .has-js, and transform: scaleX(0) on the data-reveal-rule element.
  • The prefers-reduced-motion block that clears clip, scale, opacity and translation, so the page is complete without a single tween.

What is only the demo's design: the light grey page, Mona Sans, the toolbar and its Replay button, the caption grid with its red mark, the body copy between the two photographs and the second photograph itself. The wrapper's height, aspect ratio and object-position are yours to set; the effect works at any size. The photographs are baked from assets/img-manifest.json; put your own image in the <img> and the manifest is irrelevant.

Options

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

Attribute Values Default Description
data-reveal-direction up, down, left, right up Edge and travel direction of the polygon aperture
data-reveal-duration Seconds 1.1 Duration of the clip-path wipe; the image settle runs slightly longer
data-reveal-delay Seconds 0 Delay before the reveal begins
data-reveal-once true, false true Play once, or reset and replay when the trigger re-enters

Optional descendant hooks:

Attribute Element Description
data-reveal-atmosphere Element inside the closest figure Fades and scales an ambient element in with the image (not used by the demo)
data-reveal-caption figcaption inside the closest figure Locates caption animation targets
data-reveal-rule Element inside the caption Scales a rule or mark in shortly after the image
data-reveal-copy Elements inside the caption Reveals caption lines with a short stagger
data-clip-replay button Semantically replays every active reveal timeline

Examples

Direction, Duration, and Replay

Add to your HTML <body>:

<div data-clip-reveal
     data-reveal-direction="left"
     data-reveal-duration="1.45"
     data-reveal-delay="0.1"
     data-reveal-once="false">
  <img src="travel.jpg" alt="Mountain lodge at dusk">
</div>

<button type="button" data-clip-replay>Replay reveal</button>

Direction names retain the original API. right grows from the left edge toward the right, left grows from the right edge, up grows from the bottom, and down grows from the top.

Staggered Group

The group API remains available when a project needs several images to enter together; the demo uses two standalone wrappers, each with its own ScrollTrigger.

Add to your HTML <body>:

<div data-clip-reveal-group
     data-reveal-stagger="0.15"
     data-reveal-direction="right"
     data-reveal-duration="1.2">
  <div data-clip-reveal><img src="one.jpg" alt="First location"></div>
  <div data-clip-reveal data-reveal-direction="up">
    <img src="two.jpg" alt="Second location">
  </div>
</div>
Group Attribute Values Default Description
data-clip-reveal-group Presence n/a Builds one ScrollTrigger timeline for all child reveals
data-reveal-stagger Seconds 0.12 Timeline offset between child reveals

Direction, duration, delay, and once/replay attributes on the group become defaults. A child's own values override them.

Styling Notes

The supplied demo CSS is an editorial photo feature on a light grey ground in Mona Sans, with one red accent for the caption mark. For integration, preserve these functional rules while adapting the visual design:

  • overflow: hidden on [data-clip-reveal]
  • matching six-point closed polygon states under .has-js
  • the transform: scale(1.14) image start state under .has-js
  • overflow masks around any data-reveal-copy elements
  • reduced-motion overrides that clear clip, scale, opacity, and translation

The open and closed polygon strings use the same number of points, which keeps browser interpolation stable.

How It Works

Each standalone wrapper gets a timeline triggered at top 85%. GSAP interpolates the wrapper from a narrow directional six-point polygon to the full frame while scaling the inner image from 1.14 to 1. The optional atmosphere element begins with the image; the caption mark and copy enter at 72% of the configured reveal duration, giving the photograph time to become legible first.

With data-reveal-once="false", ScrollTrigger uses toggleActions: 'restart none none reset'. Group wrappers share one timeline and offset child reveals by data-reveal-stagger.

Programmatic API

Add to your JavaScript after the effect has initialized:

// Replay every active reveal timeline.
window.imageClipReveal.replay();

// Kill timelines, ScrollTriggers, listeners, context, and Lenis integration.
window.imageClipReveal.destroy();

// The original GSAP context handle remains available.
window.gsapContext.revert();

Accessibility

  • The full image, mark, and caption are visible when JavaScript is unavailable.
  • CSS and gsap.matchMedia() both honor prefers-reduced-motion; no ScrollTrigger is created in the reduced branch.
  • The replay control is a native button with a visible keyboard focus state.
  • Keep meaningful image descriptions in alt; the decorative mark and any atmosphere element should use aria-hidden="true".
  • The effect is scroll-triggered and does not require a pointer.

Dependencies

Required:

  • GSAP 3.12+
  • ScrollTrigger

Optional:

  • Lenis for smooth scrolling. The included integration is disabled by data-smooth="off", ?smooth=off, or reduced-motion preference, and the effect works without Lenis.