# Clip Reveal

> Images slide open like a curtain as you scroll.

Clip Reveal is an image animation ready for your AI assistant, React or plain JavaScript, with no dependencies. Also called image reveal, mask reveal or curtain reveal.

As a picture scrolls into view, a hard edge sweeps across it and uncovers the whole frame while the image eases back from a slight zoom. Choose the edge or open from the center. Reduced motion gets a soft fade instead.

- Page: https://foundry44.dev/components/clip-reveal/
- Category: [Images](https://foundry44.dev/library/images/)
- Interactions: scroll
- Dependencies: none
- Licence: [MIT, free for any project](https://foundry44.dev/license/)

## Settings

| Attribute | Default | What it is |
| --- | --- | --- |
| `data-f44-direction` | `up` | Which way the visible area grows: "up" opens from the bottom edge, "center" from the middle. |
| `data-f44-duration` | `1000` | Time in ms for the clip to open fully. |
| `data-f44-zoom` | `1.1` | Starting scale of the element's first child, which settles to 1 as it is revealed. 1 turns it off. |
| `data-f44-once` | `true` | Play only the first time the element enters the viewport. Otherwise it closes again once fully offscreen and replays. |

## AI prompt

# Foundry44 · Clip Reveal

Image is revealed by an expanding clip-path when scrolled into view.

Add this component to my project. Pick the setup below that matches my project and follow only that one. When it uses the code at the end, keep its logic as written (it is self-contained, with no dependencies) and adapt only the integration: JavaScript instead of TypeScript if the project has none, file names, imports, how it starts.

## Markup it expects

The element marked `data-f44="clip-reveal"`, with this structure inside. Texts, images and the number of items are the demo's: use mine. The `data-f44-*` attributes set options for the ready-made script only; with the code, pass the same options to `clipReveal()` or as props.

```html
<div data-f44="clip-reveal">
  <img src="image-1.jpg" alt="Cushion moss on grey stone">
</div>
```

## Options

| Option | Attribute | Type | Default | Description |
|---|---|---|---|---|
| `direction` | `data-f44-direction` | `ClipRevealDirection` | `up` | Which way the visible area grows: "up" opens from the bottom edge, "center" from the middle. |
| `duration` | `data-f44-duration` | `number` | `1000` | Time in ms for the clip to open fully. |
| `zoom` | `data-f44-zoom` | `number` | `1.1` | Starting scale of the element's first child, which settles to 1 as it is revealed. 1 turns it off. |
| `once` | `data-f44-once` | `boolean` | `true` | Play only the first time the element enters the viewport. Otherwise it closes again once fully offscreen and replays. |

## Setup

- **Plain HTML, no build step**: don't paste the code below. Load the ready-made script once, at the end of `<body>`: `<script src="https://foundry44.dev/demos/clip-reveal/clip-reveal.js" defer></script>` (or download that file and serve it with the page). It starts itself on every `data-f44="clip-reveal"` element and reads the `data-f44-*` attributes above.
- **React / Next.js with shadcn/ui** (the project has a `components.json`; otherwise, see the next line): `npx shadcn@latest add https://foundry44.dev/r/clip-reveal.json` writes `lib/clip-reveal-core.ts` and `components/ui/clip-reveal.tsx`, then use `<ClipReveal />` with the options as props.
- **React / Next.js from npm** (no shadcn/ui, or the user prefers a package): `npm install @foundry44/react` (or the project's package manager), then `import { ClipReveal } from "@foundry44/react"` with the options as props. With a bundler and no React: `@foundry44/core`, then `import { clipReveal } from "@foundry44/core/clip-reveal"`.
- **React / Next.js by hand**: save the code below as `lib/clip-reveal-core.ts` and import it from there (`@/lib/clip-reveal-core` where that alias exists). Don't name it `clip-reveal.ts` next to a `ClipReveal.tsx`: on macOS and Windows the two names collide. In a component marked `"use client"`, call `clipReveal(ref.current, options)` in a `useEffect` and return `instance.destroy` as its cleanup; call `instance.update(options)` when options change.
- **Any other stack**: create on mount with `clipReveal(element, options)`, `update(options)` when they change, `destroy()` on unmount.

Only the ready-made script finds `data-f44` elements and starts them by itself. Pasted code does not: call `clipReveal(element)` on each element.

## Pitfalls

- Respects prefers-reduced-motion: keep that behaviour.
- Wrap the image: the zoom applies to the element's first child, never to the element itself (that would scale the clip too). Give the wrapper `overflow: hidden` so the zoomed child stays inside its rounded corners.
- Without JavaScript, or until the script runs, the element is fully visible.
- The element hides as soon as the script runs and opens once it is about 15% inside the viewport. On the first screen it plays on load, so load the script early to avoid a flash of the image.

## Code: `clip-reveal-core.ts`

```ts
// Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT)
/**
 * Foundry44 — clip-reveal
 * When the element scrolls into view, a clip opens from one edge (or from the
 * center) until the whole element shows, like a curtain pulled away. The media
 * inside settles from a slight zoom at the same time, so the reveal has depth.
 *
 * Self-contained: no imports, so this file can be copied as-is.
 */

export type ClipRevealDirection = "up" | "down" | "left" | "right" | "center";

export interface ClipRevealOptions {
  /** Which way the visible area grows: "up" opens from the bottom edge, "center" from the middle. */
  direction?: ClipRevealDirection;
  /** Time in ms for the clip to open fully. @range 200 3000 50 */
  duration?: number;
  /** Starting scale of the element's first child, which settles to 1 as it is revealed. 1 turns it off. @range 1 1.5 0.01 */
  zoom?: number;
  /** Play only the first time the element enters the viewport. Otherwise it closes again once fully offscreen and replays. */
  once?: boolean;
}

export interface ClipRevealInstance {
  update(options: ClipRevealOptions): void;
  destroy(): void;
}

export const clipRevealDefaults: Required<ClipRevealOptions> = {
  direction: "up",
  duration: 1000,
  zoom: 1.1,
  once: true,
};

const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
const REDUCED_DURATION = 300;
const SHOWN_CLIP = "inset(0 0 0 0)";
// inset(top right bottom left): the side that starts at 100% is the one the reveal moves away from.
const HIDDEN_CLIPS: Record<ClipRevealDirection, string> = {
  up: "inset(100% 0 0 0)",
  down: "inset(0 0 100% 0)",
  left: "inset(0 0 0 100%)",
  right: "inset(0 100% 0 0)",
  center: "inset(50% 50% 50% 50%)",
};

export function clipReveal(el: HTMLElement, options: ClipRevealOptions = {}): ClipRevealInstance {
  let opts = { ...clipRevealDefaults, ...options };

  if (typeof window === "undefined") return { update() {}, destroy() {} };
  // Reduced motion: no moving edge and no zoom, the element simply fades in.
  const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;

  // The zoom goes on the child, not the element: scaling the element would scale its clip too.
  const media = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : null;
  const original = {
    clipPath: el.style.clipPath,
    opacity: el.style.opacity,
    transition: el.style.transition,
    mediaTransform: media?.style.transform ?? "",
    mediaTransition: media?.style.transition ?? "",
  };
  let observer: IntersectionObserver | null = null;
  let shown = false;
  let played = false;

  const hiddenClip = () => HIDDEN_CLIPS[opts.direction] ?? HIDDEN_CLIPS.up;

  const applyHidden = () => {
    el.style.transition = "none";
    if (reduced) {
      el.style.opacity = "0";
      return;
    }
    el.style.clipPath = hiddenClip();
    if (media) {
      media.style.transition = "none";
      media.style.transform = opts.zoom === 1 ? original.mediaTransform : `scale(${opts.zoom})`;
    }
  };

  const show = () => {
    if (shown) return;
    shown = true;
    played = true;
    // Flush the hidden state so the transition has a start value.
    void el.offsetWidth;
    if (reduced) {
      el.style.transition = `opacity ${REDUCED_DURATION}ms ease`;
      el.style.opacity = original.opacity;
      return;
    }
    el.style.transition = `clip-path ${opts.duration}ms ${EASE_OUT}`;
    el.style.clipPath = SHOWN_CLIP;
    if (media && opts.zoom !== 1) {
      media.style.transition = `transform ${opts.duration}ms ${EASE_OUT}`;
      media.style.transform = original.mediaTransform;
    }
  };

  const hide = () => {
    if (!shown) return;
    shown = false;
    applyHidden();
  };

  const bind = () => {
    if (opts.once && played) return;
    if (typeof IntersectionObserver === "undefined") {
      show();
      return;
    }
    observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            show();
            if (opts.once) unbind();
            continue;
          }
          // The observed area is shrunk at the bottom, so "not intersecting" can still be
          // partly on screen: close only once the element is entirely out of sight.
          const { top, bottom } = entry.boundingClientRect;
          if (bottom <= 0 || top >= window.innerHeight) hide();
        }
      },
      // Wait until the element is a little inside the viewport so the opening is actually seen.
      { rootMargin: "0px 0px -15% 0px" },
    );
    observer.observe(el);
  };

  const unbind = () => {
    observer?.disconnect();
    observer = null;
  };

  applyHidden();
  bind();

  return {
    update(next) {
      const previous = opts;
      opts = { ...opts, ...next };
      // duration is read when a reveal starts; direction and zoom only matter while hidden.
      if (!shown) applyHidden();
      if (opts.once !== previous.once) {
        unbind();
        bind();
      }
    },
    destroy() {
      unbind();
      el.style.clipPath = original.clipPath;
      el.style.opacity = original.opacity;
      el.style.transition = original.transition;
      if (media) {
        media.style.transform = original.mediaTransform;
        media.style.transition = original.mediaTransition;
      }
    },
  };
}
```
