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: the script for the HTML and Astro versions. A solid
* panel sweeps across the element and the content is revealed behind it.
*
* <link rel="stylesheet" href="/styles/block-image-reveal.css"> <!-- in the head -->
* <div data-block-image-reveal data-block-image-reveal-direction="right">
* <img src="/photo.jpg" alt="..." width="1200" height="800">
* <div data-block-image-reveal-panel></div>
* </div>
*
* import { initBlockImageReveals } from "./block-image-reveal";
* initBlockImageReveals(); // every [data-block-image-reveal] on the page
*
* Nothing is painted to begin with. The panel enters from one side, covers the
* frame, then exits the other side. The content flips visible at the covered
* moment, so the panel appears to carry it in, and settles from a slight scale
* while the panel leaves.
*
* The panel is part of the markup, not created by the script, so the markup is
* complete before first paint. Without it the script stands down and the
* content stays visible.
*
* Attributes (an option passed to initBlockImageReveal wins over an attribute):
* data-block-image-reveal-direction "left", or anything else for "right"
* (the default, enters from the left).
* data-block-image-reveal-duration The whole sweep in seconds. Default 1.2.
* data-block-image-reveal-trigger-start ScrollTrigger start. Default "top 85%".
* data-block-image-reveal-trigger-end ScrollTrigger end. Default "top 30%".
*
* Set by the script, for styling and for the CSS failsafe:
* data-block-image-reveal-state "off" (no panel, or reduced motion: content
* shows), "pending" (claimed, waiting for the media), "armed" (timeline built).
*
* Custom property: --block-image-reveal-color (panel colour).
*
* Requires src/styles/block-image-reveal.css in the head, because it hides the
* content before the script runs. Honours prefers-reduced-motion: no panel,
* content simply visible.
*
* The script must own the panel's transform. A CSS `transform` on a
* GSAP-animated element is folded into the starting matrix, and the sweep
* lands in the wrong place, with the panel stopping over the image.
*
* Arms once its media has loaded and decoded (see media-ready.ts), so a slow
* or lazy-loaded image cannot still be fetching when the sweep reaches it.
*
* Calling init twice on the same element does nothing. Each call returns a
* clean-up function that undoes it, for pages that swap content in and out.
*/
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { whenMediaReady } from "./media-ready";
gsap.registerPlugin(ScrollTrigger);
export interface BlockImageRevealOptions {
/** Side the panel sweeps towards. "right" (default) enters from the left. */
direction?: "right" | "left";
/** The whole sweep, in seconds. Default 1.2. */
duration?: number;
/** ScrollTrigger start. Default "top 85%". */
triggerStart?: string;
/** ScrollTrigger end. Default "top 30%". */
triggerEnd?: string;
}
const STATE = "data-block-image-reveal-state";
const active = new WeakMap<HTMLElement, () => void>();
/** Sets up one block image reveal. Returns a function that undoes it. */
export function initBlockImageReveal(
root: HTMLElement,
options: BlockImageRevealOptions = {},
): () => void {
const existing = active.get(root);
if (existing) return existing;
const panel = root.querySelector<HTMLElement>(":scope > [data-block-image-reveal-panel]");
if (!panel || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
root.setAttribute(STATE, "off");
const cleanUp = () => {
root.removeAttribute(STATE);
active.delete(root);
};
active.set(root, cleanUp);
return cleanUp;
}
const attribute = (name: string) => root.getAttribute(`data-block-image-reveal-${name}`);
const direction = options.direction ?? (attribute("direction") === "left" ? "left" : "right");
const sign = direction === "left" ? -1 : 1;
const duration = options.duration ?? (parseFloat(attribute("duration") ?? "") || 1.2);
const start = options.triggerStart ?? attribute("trigger-start") ?? "top 85%";
const end = options.triggerEnd ?? attribute("trigger-end") ?? "top 30%";
let timeline: gsap.core.Timeline | undefined;
let cancelled = false;
// Claim the element now. This switches off the CSS failsafe, which only
// applies while no state is set.
root.setAttribute(STATE, "pending");
// Waits for the media so the ScrollTrigger is not measuring, and the panel
// not covering, an image that has not painted yet. Without this the sweep
// can finish before a lazy-loaded image has decoded, and it lands on nothing.
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,
end,
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");
});
const cleanUp = () => {
cancelled = true;
timeline?.scrollTrigger?.kill();
timeline?.kill();
timeline = undefined;
// 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);
active.delete(root);
};
active.set(root, cleanUp);
return cleanUp;
}
/** Sets up every `[data-block-image-reveal]` inside `scope`. Returns one function that undoes them all. */
export function initBlockImageReveals(scope: ParentNode = document): () => void {
const cleanUps = Array.from(
scope.querySelectorAll<HTMLElement>("[data-block-image-reveal]"),
(root) => initBlockImageReveal(root),
);
return () => cleanUps.forEach((cleanUp) => cleanUp());
}

Goes in src/scripts/block-image-reveal.tsFrom the HTML core

/**
* 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.tsFrom the HTML core

/*
* 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.cssFrom the HTML core

---
/**
* Block image reveal for Astro: the HTML version as a component. Props map one
* to one onto the data attributes; see src/scripts/block-image-reveal.ts for
* the full API.
*
* <BlockImageReveal direction="left">
* <Image src={photo} alt="..." />
* </BlockImageReveal>
*/
import type { HTMLAttributes } from "astro/types";
// Imported in frontmatter so Astro bundles it into the head, before first paint.
import "../styles/block-image-reveal.css";
interface Props extends HTMLAttributes<"div"> {
/** 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. */
duration?: string | number;
/** ScrollTrigger start position, e.g. "top 85%". */
triggerStart?: string;
/** ScrollTrigger end position, e.g. "top 30%". */
triggerEnd?: string;
}
const { direction, color, duration, triggerStart, triggerEnd, style, ...rest } = Astro.props;
---
<div
data-block-image-reveal
data-block-image-reveal-direction={direction === "left" ? "left" : undefined}
data-block-image-reveal-duration={duration === undefined ? undefined : String(duration)}
data-block-image-reveal-trigger-start={triggerStart}
data-block-image-reveal-trigger-end={triggerEnd}
style={color ? [{ "--block-image-reveal-color": color }, style ?? ""] : style}
{...rest}
>
<slot />
<div data-block-image-reveal-panel></div>
</div>
<script>
import { initBlockImageReveals } from "../scripts/block-image-reveal";
// Safe to call again: each element is only set up once.
initBlockImageReveals();
document.addEventListener("astro:page-load", () => initBlockImageReveals());
</script>

Goes in src/components/BlockImageReveal.astro

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.