Block image reveal

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

Status
stable
Tags
  • motion
  • image
  • scroll

Install

pnpm add gsap

Setup

  1. Load src/styles/block-image-reveal.css in the head.
  2. Author <div data-block-image-reveal-panel></div> as the last child of <div data-block-image-reveal>.
  3. Call initBlockImageReveals() from src/scripts/block-image-reveal.ts once the page has loaded. It sets up every [data-block-image-reveal], and returns a clean-up function.

Without JavaScript the image simply shows.

Wraps the HTML core, so the logic is shared.

Install

pnpm add gsap

Setup

The component imports the CSS and the script, and renders the panel. Use astro:assets <Image> inside it.

Parity with the default: full

Install

pnpm add gsap

Expects in your project: 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.

Demo

Files

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

Goes in src/components/BlockImageReveal.tsx

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

Goes in src/scripts/media-ready.ts

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

Goes in src/styles/block-image-reveal.css

API

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

Attributes

NameTypeDefaultDescription
data-block-image-revealMarks the element. Its children are the content, and the last one is the panel.
data-block-image-reveal-panelMarks 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-durationseconds1.2The whole sweep, from the panel entering to it leaving. The Astro and React versions take a duration prop.
data-block-image-reveal-trigger-startScrollTrigger 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-endScrollTrigger 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

NameTypeDescription
--block-image-reveal-colorcolorThe 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 and SEO

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.