# Block image reveal

A solid panel sweeps across an image on scroll and reveals it behind the panel.

Framework: React (variant `react`)

In the Digital Design monorepo, copy the files; don't retype them. From the repo root, with <project> as the target project's folder:

```sh
mkdir -p <project>/src/components <project>/src/scripts <project>/src/styles
cp library/src/content/snippets/block-image-reveal/files/src/components/BlockImageReveal.tsx <project>/src/components/BlockImageReveal.tsx
cp library/src/content/snippets/block-image-reveal/files/src/scripts/media-ready.ts <project>/src/scripts/media-ready.ts
cp library/src/content/snippets/block-image-reveal/files/src/styles/block-image-reveal.css <project>/src/styles/block-image-reveal.css
```

## Dependencies

gsap. Install them with the project's own package manager; in this repo, `yarn workspace @cdd/<project> add gsap`.

Expects: react >=19.

## Setup

A native React version: it shares `block-image-reveal.css` and `media-ready.ts` with the other versions, and not their script. It renders the panel, and builds the sweep in an effect, so it is safe to render on the server.


## Usage

```astro
---
// client:load, not client:only: the component renders on the server and only
// reaches for the browser in its effect.
import { ReactDemo } from "./parts/ReactDemo";
---

<div class="spacer">Scroll down</div>
<main>
  <ReactDemo client:load />
</main>

```

## API

Shared by every variant. Wrapper props use the same names in camelCase.

### Attributes

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `data-block-image-reveal` |  |  | Marks the element. Its children are the content, and the last one is the panel. |
| `data-block-image-reveal-panel` |  |  | Marks the panel, a direct child of the element. Author it in the markup. Without it the script stands down and the content shows. |
| `data-block-image-reveal-direction` | `"right" \| "left"` | `"right"` | Which way the panel travels. `"right"` enters from the left edge. Any value other than `"left"` is read as `"right"`. The Astro and React versions take a `direction` prop. |
| `data-block-image-reveal-duration` | `seconds` | `1.2` | The whole sweep, from the panel entering to it leaving. The Astro and React versions take a `duration` prop. |
| `data-block-image-reveal-trigger-start` | `ScrollTrigger start` | `"top 85%"` | Where the sweep begins, as the element enters from the bottom. The Astro and React versions take a `triggerStart` prop. |
| `data-block-image-reveal-trigger-end` | `ScrollTrigger end` | `"top 30%"` | Where the sweep finishes. The Astro and React versions take a `triggerEnd` prop. |
| `data-block-image-reveal-state` | `"pending" \| "armed" \| "off"` |  | Set by the script on the element. `pending` means it is waiting for the media to load, `armed` means the sweep is ready, and `off` means there is no panel or the user prefers reduced motion, so the content simply shows. |

### Custom properties

| Name | Type | Description |
| --- | --- | --- |
| `--block-image-reveal-color` | `color` | The panel colour. Set it to the section background so the panel reads as the page, not as a block. The Astro and React versions set it from the `color` prop. |

## Accessibility

The content and the image's alt text stay in the DOM throughout, so screen readers are unaffected by the sweep. Reduced motion skips the panel and shows the content. The animation plays once per scroll into view and lasts about a second, so it needs no pause control. The page still supplies meaningful alt text, or `alt=""` for a decorative image.

## SEO

The image is server-rendered with its alt text. The rule that hides it only applies when scripting is on, so the image shows without JavaScript, and a 3 second failsafe shows it if the script never runs. The content sits at `opacity: 0` until the sweep, and Chrome does not count it as painted. The page still has to keep this off the LCP image and anything above the fold, and sets `width`, `height`, `loading="lazy"` and `decoding="async"` on the image.

## Notes

The panel starts at `opacity: 0` and the script owns its transform from the first frame. A CSS `transform` on the panel, such as parking it off canvas, is folded into GSAP's starting matrix, and the sweep ends with the panel over the image.

The HTML and Astro versions share one small script, `initBlockImageReveals()`, found by the `data-block-image-reveal` attribute. The React version is its own implementation, so it follows React's rules rather than wrapping the script. All three share `block-image-reveal.css`.

## Files

### src/components/BlockImageReveal.tsx

```tsx
/**
 * Block image reveal for React: a native version, not a wrapper around the
 * script. It shares `block-image-reveal.css` and `media-ready.ts` with the
 * other versions, and not their script. The timeline is built in one effect,
 * so server rendering is safe: nothing touches `window` during render.
 *
 *   <BlockImageReveal direction="left">
 *     <img src="/photo.jpg" alt="..." width={1200} height={800} loading="lazy" decoding="async" />
 *   </BlockImageReveal>
 */
import { useEffect, useRef, type CSSProperties, type HTMLAttributes, type ReactNode } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { whenMediaReady } from "../scripts/media-ready";
// A CSS import, so the bundler puts it in the head before the content paints.
import "../styles/block-image-reveal.css";

interface BlockImageRevealProps extends HTMLAttributes<HTMLDivElement> {
  /** Side the panel sweeps towards. "right" (default) enters from the left. */
  direction?: "right" | "left";
  /** Panel colour. Sets --block-image-reveal-color. */
  color?: string;
  /** The whole sweep, in seconds. Default 1.2. */
  duration?: number;
  /** ScrollTrigger start position. Default "top 85%". */
  triggerStart?: string;
  /** ScrollTrigger end position. Default "top 30%". */
  triggerEnd?: string;
  children?: ReactNode;
}

const STATE = "data-block-image-reveal-state";

export function BlockImageReveal({
  direction = "right",
  color,
  duration = 1.2,
  triggerStart = "top 85%",
  triggerEnd = "top 30%",
  style,
  children,
  ...rest
}: BlockImageRevealProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const root = rootRef.current;
    const panel = panelRef.current;
    if (!root || !panel) return;

    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      root.setAttribute(STATE, "off");
      return () => root.removeAttribute(STATE);
    }

    gsap.registerPlugin(ScrollTrigger);

    const sign = direction === "left" ? -1 : 1;
    let timeline: gsap.core.Timeline | undefined;
    let cancelled = false;

    // Claim the element now. This switches off the CSS failsafe. The state
    // lives on the element, not in React state, so nothing re-renders.
    root.setAttribute(STATE, "pending");

    whenMediaReady(root).then(() => {
      if (cancelled) return;

      const content = [...root.children].filter((child) => child !== panel);
      // Scale the images themselves: scaling a wrapper would scale its clipping box too.
      const media = root.querySelectorAll("img, video");

      timeline = gsap.timeline({
        scrollTrigger: {
          trigger: root,
          start: triggerStart,
          end: triggerEnd,
          toggleActions: "play none none reverse",
        },
      });

      timeline
        // In: the panel crosses the frame until it covers it. `x: 0` claims the
        // transform outright, because GSAP folds any computed CSS translate into
        // the matrix it animates from and the sweep would stack on top of it.
        .fromTo(
          panel,
          { xPercent: -101 * sign, x: 0, y: 0 },
          { xPercent: 0, duration: duration * 0.5, ease: "power3.inOut" },
          0,
        )
        // The swap, hidden under the covering panel.
        .set(content, { opacity: 1 }, duration * 0.5)
        // Out: the panel continues off the far side.
        .to(
          panel,
          { xPercent: 101 * sign, duration: duration * 0.5, ease: "power3.inOut" },
          duration * 0.5,
        )
        .fromTo(
          media.length ? media : content,
          { scale: 1.08 },
          { scale: 1, duration: duration * 1.25, ease: "power4.out" },
          duration * 0.5,
        );

      // Only now is the panel somewhere worth looking at.
      root.setAttribute(STATE, "armed");
    });

    return () => {
      cancelled = true;
      timeline?.scrollTrigger?.kill();
      timeline?.kill();
      // Take back what GSAP set, and leave the page's own inline styles alone.
      const media = [...root.querySelectorAll("img, video")];
      gsap.set([panel, ...root.children, ...media], { clearProps: "opacity,transform" });
      root.removeAttribute(STATE);
    };
  }, [direction, duration, triggerStart, triggerEnd]);

  const rootStyle = color
    ? ({ "--block-image-reveal-color": color, ...style } as CSSProperties)
    : style;

  return (
    <div ref={rootRef} data-block-image-reveal="" style={rootStyle} {...rest}>
      {children}
      {/* In the markup, not created by the effect, so it exists before first paint. */}
      <div ref={panelRef} data-block-image-reveal-panel="" />
    </div>
  );
}

```

### src/scripts/media-ready.ts

```ts
/**
 * whenMediaReady(root): resolves once every <img> and <video> inside root has
 * loaded (and, for images, decoded off the main thread). Use it to hold off
 * building a scroll-triggered timeline until the media it covers can actually
 * paint, so a slow or lazy-loaded image is not still fetching when the effect
 * plays over it.
 *
 * An already-loaded <img> (cache hit, or read after the load event) still
 * gets decode()'d: decode() is safe to call any time and is what actually
 * guarantees the bitmap is ready to paint, not just downloaded.
 *
 * Media that fails to load also resolves, so a broken file cannot hold the
 * effect back for ever.
 */
export function whenMediaReady(root: ParentNode): Promise<void> {
  const media = [...root.querySelectorAll("img, video")];

  return Promise.all(
    media.map((element) => {
      if (element instanceof HTMLImageElement) {
        return element.decode().catch(
          () =>
            new Promise<void>((resolve) => {
              element.addEventListener("load", () => resolve(), { once: true });
              element.addEventListener("error", () => resolve(), { once: true });
            }),
        );
      }

      if (element instanceof HTMLVideoElement) {
        if (element.readyState >= 2) return Promise.resolve();
        return new Promise<void>((resolve) => {
          element.addEventListener("loadeddata", () => resolve(), { once: true });
          element.addEventListener("error", () => resolve(), { once: true });
        });
      }

      return Promise.resolve();
    }),
  ).then(() => undefined);
}

```

### src/styles/block-image-reveal.css

```css
/*
 * block-image-reveal. Load it in the head, not from the script: it hides the
 * content before first paint, and a stylesheet added when the module runs
 * lands after it, which flashes the very image the panel is meant to
 * introduce.
 *
 * Hiding is gated on (scripting: enabled), so without JavaScript the image
 * simply shows, and on reduced motion, so nothing is hidden for people who opt
 * out.
 *
 * data-block-image-reveal-state, set by the script:
 *   (none)   not set up yet. Content hidden, with a 3s failsafe in case the
 *            script never runs.
 *   pending  the script has claimed the element and waits for its media.
 *   armed    the timeline is built, so the panel is painted.
 *   off      no panel, or reduced motion: content shows.
 *
 * Custom property: --block-image-reveal-color (panel colour).
 */
[data-block-image-reveal] {
  position: relative;
  overflow: clip;
}

[data-block-image-reveal-panel] {
  position: absolute;
  inset: 0;
  z-index: 1;
  background: var(--block-image-reveal-color, CanvasText);
  pointer-events: none;
  /* Deliberately no transform. GSAP folds a computed translate into the
     matrix it animates from, so a panel parked off canvas in CSS sweeps in
     and stops on top of the image. The script owns the panel's position;
     until then the panel is simply not painted. */
  opacity: 0;
}

[data-block-image-reveal-state="armed"] > [data-block-image-reveal-panel] {
  opacity: 1;
}

@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
  /* Hidden until the panel carries it in, unless the script stood down. */
  [data-block-image-reveal]:not([data-block-image-reveal-state="off"])
    > :not([data-block-image-reveal-panel]) {
    opacity: 0;
  }

  /* The script has not claimed the element after 3s: it failed to load, so show the content. */
  [data-block-image-reveal]:not([data-block-image-reveal-state])
    > :not([data-block-image-reveal-panel]) {
    animation: block-image-reveal-failsafe 0s 3s forwards;
  }
}

@keyframes block-image-reveal-failsafe {
  to {
    opacity: 1;
  }
}

```
