Block image reveal
A solid panel sweeps across an image on scroll and reveals it behind the panel.
Install
pnpm add gsapSetup
- Load
src/styles/block-image-reveal.cssin the head. - Author
<div data-block-image-reveal-panel></div>as the last child of<div data-block-image-reveal>. - Call
initBlockImageReveals()fromsrc/scripts/block-image-reveal.tsonce the page has loaded. It sets up every[data-block-image-reveal], and returns a clean-up function.
Without JavaScript the image simply shows.
Install
pnpm add gsapSetup
The component imports the CSS and the script, and renders the panel. Use astro:assets <Image> inside it.
Install
pnpm add gsapExpects 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.
---import { Image } from "astro:assets";import BlockImageReveal from "/src/components/BlockImageReveal.astro";import sample from "./assets/sample.svg";---
<div class="spacer">Scroll down</div><main> <BlockImageReveal> <Image src={sample} alt="Stylised mountains under a setting sun" /> </BlockImageReveal> <div class="spacer"></div> <BlockImageReveal> <Image src={sample} alt="Stylised mountains under a setting sun" /> </BlockImageReveal> <div class="spacer"></div> <BlockImageReveal> <Image src={sample} alt="Stylised mountains under a setting sun" /> </BlockImageReveal> <div class="spacer"></div></main>---// Plain markup, the way a page without a framework would use it: the CSS in// the head, the panel as the last child, and the script to set it up.import "/src/styles/block-image-reveal.css";---
<div class="spacer">Scroll down</div><main> <div data-block-image-reveal> <img src="/demo-assets/sample.svg" alt="Stylised mountains under a setting sun" width="1200" height="800" loading="lazy" decoding="async" /> <div data-block-image-reveal-panel></div> </div> <div class="spacer"></div> <div data-block-image-reveal> <img src="/demo-assets/sample.svg" alt="Stylised mountains under a setting sun" width="1200" height="800" loading="lazy" decoding="async" /> <div data-block-image-reveal-panel></div> </div> <div class="spacer"></div> <div data-block-image-reveal> <img src="/demo-assets/sample.svg" alt="Stylised mountains under a setting sun" width="1200" height="800" loading="lazy" decoding="async" /> <div data-block-image-reveal-panel></div> </div></main>
<script> import { initBlockImageReveals } from "/src/scripts/block-image-reveal"; initBlockImageReveals();</script>---// 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>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.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);}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
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
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
| 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 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: 0until 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 setswidth,height,loading="lazy"anddecoding="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.