# Image Clip Reveal

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

Canonical: https://gsapvault.com/effects/image-clip-reveal
Live demo: https://gsapvault.com/demos/image-clip-reveal/index.html

| Property | Value |
|----------|-------|
| Type | effect |
| Tier | free |
| Price | Free |
| Difficulty | beginner |
| Plugins | ScrollTrigger |
| Techniques | scroll-reveal, clip-path, ken-burns, stagger |
| Uses Lenis | Yes |

## Overview

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.

## Features

- Architectural polygon aperture with four directional reveal options
- Restrained inner-image scale settle synchronized with the mask
- Optional editorial caption, rule, 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

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

## Vibe-Code Ready Setup

This effect includes `START-HERE-AI.md`, a product-specific copy-paste setup prompt for Cursor, Claude Code, ChatGPT, GitHub Copilot, Windsurf, and other coding assistants. It tells the assistant to inspect the existing stack, integrate the supplied files, preserve the design, scope selectors, retain accessibility and responsive behaviour, add framework-appropriate GSAP cleanup, and report what it tested.

[How AI-assisted setup works](https://gsapvault.com/vibe-coding)

## How It Works

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.

## Documentation

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

## Quick Start

**1. Add to your HTML `<head>`:**

```html
<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>`:**

```html
<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:**

```html
<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>
```

## 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 subtle ambient color with the image |
| `data-reveal-caption` | `figcaption` inside the closest `figure` | Locates caption animation targets |
| `data-reveal-rule` | Element inside the caption | Scales a rule 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>`:**

```html
<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 multiple images, even though the included demo intentionally uses one cinematic composition.

**Add to your HTML `<body>`:**

```html
<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 intentionally editorial. 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 begins with the image; the caption rule 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:**

```javascript
// 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, rule, 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`; decorative atmosphere and rules 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.

## Source Code

This effect is free. The complete source is included below.

### index.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Image Clip Reveal Demo | GSAP Vault</title>
    <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=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600&family=Syne:wght@500;600;700&display=swap" rel="stylesheet">
</head>
<body>
    <main class="showcase-shell">
        <header class="showcase-toolbar">
            <p class="showcase-prompt">
                <span class="showcase-prompt__dot" aria-hidden="true"></span>
                <strong>Directional aperture</strong>
                <span>Image and caption settle in sequence</span>
            </p>
            <button class="replay-button" type="button" data-clip-replay aria-label="Replay image clip reveal">
                <span aria-hidden="true">↻</span>
                Replay
            </button>
        </header>

        <section class="showcase-stage" aria-label="Cinematic image clip reveal demonstration">
            <figure class="editorial-frame">
                <div class="frame-atmosphere" data-reveal-atmosphere aria-hidden="true"></div>
                <span class="frame-coordinate frame-coordinate--top" aria-hidden="true">45.4215° N</span>
                <span class="frame-coordinate frame-coordinate--side" aria-hidden="true">75.6972° W</span>

                <div class="clip-media"
                     data-clip-reveal
                     data-reveal-direction="right"
                     data-reveal-duration="1.45"
                     data-reveal-once="true">
                    <img src="assets/brutalist-architecture.jpg"
                         alt="Glass towers of Constitution Square rising into a stormy sky in Ottawa"
                         width="1800"
                         height="1200">
                    <span class="image-wash" aria-hidden="true"></span>
                    <span class="image-index" aria-hidden="true">ARCH / 01</span>
                </div>

                <figcaption class="editorial-caption" data-reveal-caption>
                    <span class="caption-rule" data-reveal-rule aria-hidden="true"></span>
                    <span class="caption-mask caption-location">
                        <span data-reveal-copy>Ottawa, Canada</span>
                    </span>
                    <span class="caption-mask caption-title">
                        <span data-reveal-copy>Constitution Square<br>under weather.</span>
                    </span>
                    <span class="caption-mask caption-note">
                        <span data-reveal-copy>Glass, steel &amp; a northern sky.</span>
                    </span>
                </figcaption>
            </figure>
        </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>
```

### assets/style.css

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

:root {
    --black: #07090a;
    --surface: #111517;
    --border: #283036;
    --text: #f4f1e9;
    --text-secondary: #aeb5b4;
    --text-muted: #737d80;
    --orange: #ff7a1a;
    --cyan: #42d9e8;
    --accent: var(--orange);
    --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
    color-scheme: dark;
}

html,
body {
    min-height: 100%;
}

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

body {
    min-height: 100svh;
    overflow-x: clip;
    background:
        radial-gradient(circle at 78% 18%, rgba(66, 217, 232, 0.07), transparent 28rem),
        linear-gradient(145deg, #050708 0%, #0b0e10 58%, #07090a 100%);
    color: var(--text);
    font-family: 'Space Grotesk', system-ui, sans-serif;
    line-height: 1.4;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

body::before {
    position: fixed;
    inset: 0;
    z-index: -1;
    background-image: linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px);
    background-size: 100% 5rem;
    content: '';
    pointer-events: none;
}

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

::-webkit-scrollbar {
    width: 10px;
}

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

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

.showcase-shell {
    display: grid;
    grid-template-rows: auto minmax(0, 1fr);
    min-height: 100svh;
    padding: clamp(1rem, 2.5vw, 1.75rem) clamp(1rem, 4vw, 3.5rem) clamp(1.25rem, 3vw, 2.25rem);
}

.showcase-toolbar {
    position: relative;
    z-index: 5;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    min-height: 2.5rem;
    padding-bottom: 0.75rem;
    border-bottom: 1px solid rgba(255, 255, 255, 0.09);
}

.showcase-prompt {
    display: flex;
    align-items: center;
    gap: 0.65rem;
    color: var(--text-muted);
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.65rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
}

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

.showcase-prompt__dot {
    width: 0.42rem;
    height: 0.42rem;
    flex: 0 0 auto;
    border-radius: 50%;
    background: var(--orange);
    box-shadow: 0 0 1rem rgba(255, 122, 26, 0.7);
}

.replay-button {
    display: inline-flex;
    align-items: center;
    gap: 0.45rem;
    border: 1px solid rgba(66, 217, 232, 0.38);
    border-radius: 100vmax;
    padding: 0.48rem 0.78rem;
    background: rgba(8, 12, 14, 0.72);
    color: var(--cyan);
    font: 500 0.65rem/1 'JetBrains Mono', monospace;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    cursor: pointer;
    transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease;
}

.replay-button:hover,
.replay-button:focus-visible {
    border-color: var(--cyan);
    outline: none;
    background: rgba(66, 217, 232, 0.1);
    color: #dffcff;
}

.showcase-stage {
    display: grid;
    min-height: 0;
    place-items: center;
    padding-top: clamp(1rem, 2.8vh, 1.5rem);
}

.editorial-frame {
    position: relative;
    width: min(74vw, 61rem);
    margin: 0;
}

.frame-atmosphere {
    position: absolute;
    right: -8%;
    bottom: 10%;
    width: 42%;
    aspect-ratio: 1;
    z-index: -1;
    border-radius: 50%;
    opacity: 0.45;
    background: radial-gradient(circle, rgba(66, 217, 232, 0.24), rgba(255, 122, 26, 0.08) 42%, transparent 70%);
    filter: blur(38px);
    pointer-events: none;
}

.has-js .frame-atmosphere {
    opacity: 0;
}

.frame-coordinate {
    position: absolute;
    z-index: 3;
    color: rgba(255, 255, 255, 0.43);
    font: 500 0.55rem/1 'JetBrains Mono', monospace;
    letter-spacing: 0.14em;
}

.frame-coordinate--top {
    top: 0;
    left: 0;
    transform: translateY(-1.35rem);
}

.frame-coordinate--side {
    right: 0;
    top: 0;
    writing-mode: vertical-rl;
    transform: translateX(1.35rem);
}

.clip-media {
    position: relative;
    width: 100%;
    aspect-ratio: 2.05 / 1;
    overflow: hidden;
    background: #121719;
    clip-path: none;
    isolation: isolate;
}

.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%);
}

.clip-media img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center 57%;
    transform: none;
    transform-origin: center 58%;
}

.has-js [data-clip-reveal] img {
    transform: scale(1.14);
    will-change: transform;
}

.image-wash {
    position: absolute;
    inset: 0;
    z-index: 1;
    background:
        linear-gradient(180deg, rgba(5, 9, 11, 0.05) 45%, rgba(5, 8, 9, 0.42) 100%),
        linear-gradient(90deg, rgba(255, 122, 26, 0.08), transparent 25%, transparent 72%, rgba(66, 217, 232, 0.1));
    pointer-events: none;
}

.image-index {
    position: absolute;
    right: 1rem;
    top: 1rem;
    z-index: 2;
    border-top: 1px solid var(--orange);
    padding-top: 0.4rem;
    color: rgba(255, 255, 255, 0.8);
    font: 500 0.55rem/1 'JetBrains Mono', monospace;
    letter-spacing: 0.15em;
}

.editorial-caption {
    display: grid;
    grid-template-columns: minmax(4.75rem, 0.8fr) minmax(13rem, 2.1fr) minmax(9rem, 1fr);
    gap: clamp(0.8rem, 2.5vw, 2rem);
    align-items: start;
    padding-top: 0.85rem;
}

.caption-rule {
    grid-column: 1 / -1;
    display: block;
    height: 1px;
    margin-bottom: 0.05rem;
    background: linear-gradient(90deg, var(--orange) 0 13%, rgba(255, 255, 255, 0.24) 13% 78%, var(--cyan) 78% 100%);
}

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

.caption-mask {
    display: block;
    overflow: hidden;
}

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

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

.caption-location,
.caption-note {
    color: var(--text-muted);
    font: 500 0.58rem/1.35 'JetBrains Mono', monospace;
    letter-spacing: 0.09em;
    text-transform: uppercase;
}

.caption-title {
    color: var(--text);
    font-family: 'Syne', system-ui, sans-serif;
    font-size: clamp(1.05rem, 2.2vw, 1.65rem);
    font-weight: 600;
    line-height: 1.02;
    letter-spacing: -0.035em;
}

.caption-note {
    justify-self: end;
    color: var(--cyan);
    text-align: right;
}

@media (max-width: 700px) {
    .showcase-shell {
        padding: 0.9rem 1rem 1.2rem;
    }

    .showcase-toolbar {
        align-items: flex-start;
    }

    .showcase-prompt {
        align-items: flex-start;
        max-width: 12rem;
        line-height: 1.35;
    }

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

    .showcase-stage {
        place-items: start center;
        padding-top: 3.75rem;
    }

    .editorial-frame {
        width: min(100%, 27rem);
    }

    .clip-media {
        aspect-ratio: 4 / 5;
        max-height: 58svh;
    }

    .clip-media img {
        object-position: 50% 52%;
    }

    .frame-atmosphere {
        right: 0;
    }

    .frame-coordinate--side {
        display: none;
    }

    .editorial-caption {
        grid-template-columns: 0.7fr 1.7fr;
        gap: 0.65rem 1rem;
    }

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

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

@media (max-height: 690px) and (min-width: 701px) {
    .clip-media {
        max-height: 62svh;
    }

    .editorial-frame {
        width: min(70vw, 55rem);
    }
}

@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;
    }

    .frame-atmosphere {
        opacity: 0.45 !important;
        transform: none !important;
    }
}
```

### assets/script.js

```js
/**
 * 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);
});
```

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
