Scroll reveal

Reveal any element on scroll from a data attribute. Cards, images and sections fade, zoom or blur in with CSS; text can stagger by line, word or letter with GSAP, in order or at random.

Status
draft
Tags
  • motion
  • scroll
  • image
  • text

Install

pnpm add gsap

Setup

  1. Load src/styles/scroll-reveal.css in the head.
  2. Load src/styles/scroll-reveal-custom.css after it.
  3. Import src/scripts/scroll-reveal.ts once per page.

Put your own effects in scroll-reveal-custom.css. A newer version of the snippet replaces scroll-reveal.css but never that file.

If you move elements without adding or removing any, call refreshScrollReveal().

Claude Code reads .claude/skills/scroll-reveal/SKILL.md when asked for a scroll animation. To point other agents at it, add this line to AGENTS.md:

Scroll animations: follow .claude/skills/scroll-reveal/SKILL.md before writing any reveal code.

Wraps the HTML core, so the logic is shared.

Install

pnpm add gsap

Setup

The component imports the head CSS and the script. The attributes work on any element too, as long as one ScrollReveal (or a script import) is on the page.

Wraps the HTML core, so the logic is shared.

Install

pnpm add gsap

Expects in your project: react >=19.

Setup

The component imports the CSS and sets up the reveal in useEffect, so it is client only by design. Keep split children static: SplitText replaces the text nodes, so React cannot update them in place. Give the component a new key to change its text.

Demo

Files

/**
* scroll-reveal: scroll triggered reveals driven by data attributes. Put them on
* any element, text or not; no wrapper.
*
* <link rel="stylesheet" href="/styles/scroll-reveal.css"> <!-- in the head -->
* <div data-reveal="fade-up">A card that fades up</div>
* <img data-reveal="zoom-in" src="..." alt="..." width="800" height="600">
* <h2 data-reveal="mask-up">Wiped in from the bottom edge, no GSAP</h2>
* <h2 data-reveal="mask-up" data-reveal-split="lines">Each line slides up behind its own mask</h2>
* <p data-reveal="fade-up" data-reveal-split="words">Words fade up in turn</p>
* <h2 data-reveal="scatter">Letters appear one by one, in a random order</h2>
* <script type="module">import "/scripts/scroll-reveal.ts";</script>
*
* Two paths behind one API:
* - Whole element (no split): CSS transitions in scroll-reveal.css, toggled by
* IntersectionObserver. GSAP is never loaded for these, masks included.
* - Split (data-reveal-split): GSAP SplitText and a timeline, loaded on
* demand from scroll-reveal-split.ts.
*
* By default a reveal replays in both scroll directions: it plays when the
* element scrolls in from below or above, and resets only once it is fully off
* screen, so the reset is never seen.
*
* Attributes:
* data-reveal Effect. A base (fade, zoom-in, zoom-out, blur) plus an
* optional direction (up, down, left, right, up-left,
* up-right, down-left, down-right). Default fade-up.
* Also scale, the mask-up / mask-down clip wipes, and
* two letter effects that split into chars on their own:
* scatter (letters appear in a random order) and flicker
* (they blink on in a random order).
* data-reveal-split lines, words or chars. Masks slide each piece behind
* its own clip; without it a mask wipes the whole element.
* data-reveal-duration Seconds. Default 0.8 (masks 1).
* data-reveal-delay Seconds. Default 0.
* data-reveal-stagger Seconds between pieces. Default lines 0.1, words 0.04, chars 0.015.
* data-reveal-order Which piece goes first: start (default), end, center, edges, random.
* data-reveal-ease Named easing, ease-out-quint by default (masks ease-out-expo).
* The names work on both paths. A raw GSAP ease such as
* "back.out(1.7)" also works, on the split path only.
* data-reveal-start Percent of the viewport, from the bottom, before it plays. Default 15.
* data-reveal-once Play once and never reset.
* data-reveal-id Names the element in its events: scroll-reveal:in:<id>.
*
* Custom properties (beat the matching attribute): --reveal-distance,
* --reveal-duration, --reveal-delay, --reveal-blur, --reveal-scale,
* --reveal-zoom-in, --reveal-zoom-out, and --reveal-ease on the whole element path.
*
* Events, dispatched on the element and bubbling to document:
* scroll-reveal:in the reveal played
* scroll-reveal:out it reset, having scrolled fully off screen
* scroll-reveal:in:<id> the same, for an element with data-reveal-id
*
* Elements added to the page later are picked up by a MutationObserver, so a
* router, a CMS embed or a "load more" button needs no extra call. Everything
* is set up on load and on astro:page-load, and torn down on astro:before-swap.
* Honours prefers-reduced-motion: nothing is hidden or split.
*/
import { parseRevealOptions, wantsSplit, type RevealOptions } from "./scroll-reveal-effects";
export { registerRevealBase, registerRevealEffect, resolveEase } from "./scroll-reveal-effects";
type Cleanup = () => void;
type RevealState = "in" | "out";
const SELECTOR = "[data-reveal]";
const active = new Map<HTMLElement, Cleanup>();
let splitModule: Promise<typeof import("./scroll-reveal-split")> | undefined;
/* --------------------------------------------
Events
-------------------------------------------- */
const ids = new WeakMap<Element, string>();
/**
* Dispatched on the element rather than on document, so a component can listen
* to its own reveal. Bubbling means a document level listener still works.
*/
function notify(element: Element, state: RevealState) {
const id = ids.get(element) ?? null;
const detail = { id, element };
const options = { detail, bubbles: true };
element.dispatchEvent(new CustomEvent(`scroll-reveal:${state}`, options));
if (id) element.dispatchEvent(new CustomEvent(`scroll-reveal:${state}:${id}`, options));
}
/* --------------------------------------------
Whole element path: shared observers
-------------------------------------------- */
const playObservers = new Map<number, IntersectionObserver>();
const onceElements = new WeakSet<Element>();
let resetObserver: IntersectionObserver | undefined;
function play(element: Element) {
element.setAttribute("data-reveal-state", "in");
if (onceElements.has(element)) unobserve(element);
notify(element, "in");
}
/** One play observer per start value, so elements with the same trigger share it. */
function playObserver(start: number) {
let observer = playObservers.get(start);
if (!observer) {
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) if (entry.isIntersecting) play(entry.target);
},
{ rootMargin: `0px 0px -${start}% 0px` },
);
playObservers.set(start, observer);
}
return observer;
}
/** Resets only when fully outside the viewport, so the snap back is never visible. */
function getResetObserver() {
resetObserver ??= new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) continue;
// Only a reveal that actually played has something to reset, and only a
// played one should announce itself.
if (entry.target.getAttribute("data-reveal-state") !== "in") continue;
entry.target.setAttribute("data-reveal-state", "out");
notify(entry.target, "out");
}
});
return resetObserver;
}
function unobserve(element: Element) {
for (const observer of playObservers.values()) observer.unobserve(element);
resetObserver?.unobserve(element);
}
function setupCss(element: HTMLElement, options: RevealOptions): Cleanup {
// Attributes become private properties; scroll-reveal.css reads the public ones first.
const { revealDuration, revealDelay } = element.dataset;
if (revealDuration) element.style.setProperty("--_reveal-duration", `${options.duration}s`);
if (revealDelay) element.style.setProperty("--_reveal-delay", `${options.delay}s`);
// "out" is the hidden state without a transition, and ends the CSS failsafe.
element.setAttribute("data-reveal-state", "out");
if (options.once) onceElements.add(element);
playObserver(options.start).observe(element);
if (!options.once) getResetObserver().observe(element);
return () => {
unobserve(element);
onceElements.delete(element);
element.style.removeProperty("--_reveal-duration");
element.style.removeProperty("--_reveal-delay");
element.removeAttribute("data-reveal-state");
};
}
/* --------------------------------------------
Split path: GSAP, loaded on first use
-------------------------------------------- */
function setupSplit(element: HTMLElement): Cleanup {
let cancelled = false;
let teardown: Cleanup | undefined;
splitModule ??= import("./scroll-reveal-split");
splitModule
.then((module) => {
if (cancelled) return;
// Read here, once GSAP has loaded, rather than at setup. Loading takes at
// least a fetch, so by now every effect the page's scripts register with
// registerRevealBase is in place, whatever order those scripts ran in.
const options = readOptions(element);
if (options.id) ids.set(element, options.id);
teardown = module.createSplitReveal(element, options, () => cancelled, notify);
})
.catch((error: unknown) => {
// Show the content rather than leave it hidden until the CSS failsafe.
element.setAttribute("data-reveal-state", "off");
console.error("scroll-reveal: could not load GSAP.", error);
});
return () => {
cancelled = true;
teardown?.();
element.removeAttribute("data-reveal-state");
};
}
/* --------------------------------------------
Setting one element up
-------------------------------------------- */
function readProperty(styles: CSSStyleDeclaration, name: string) {
return styles.getPropertyValue(name).trim();
}
function readOptions(element: HTMLElement): RevealOptions {
const styles = getComputedStyle(element);
return parseRevealOptions(element.dataset, {
distance: readProperty(styles, "--reveal-distance"),
duration: readProperty(styles, "--reveal-duration"),
delay: readProperty(styles, "--reveal-delay"),
blur: readProperty(styles, "--reveal-blur"),
scale: readProperty(styles, "--reveal-scale"),
zoomIn: readProperty(styles, "--reveal-zoom-in"),
zoomOut: readProperty(styles, "--reveal-zoom-out"),
});
}
function setup(element: HTMLElement): Cleanup {
let teardown: Cleanup;
if (wantsSplit(element.dataset)) {
// Options are read once GSAP loads; see setupSplit.
teardown = setupSplit(element);
} else {
const options = readOptions(element);
if (options.id) ids.set(element, options.id);
teardown = setupCss(element, options);
}
return () => {
teardown();
ids.delete(element);
};
}
/* --------------------------------------------
Public API
-------------------------------------------- */
/**
* Sets up every [data-reveal] in `root`, including `root` itself. Elements
* already set up are left alone. The returned cleanup tears down every reveal
* found in `root`, whoever set it up, and restores the original markup.
*/
export function initScrollReveal(root: ParentNode = document): Cleanup {
const elements = [...root.querySelectorAll<HTMLElement>(SELECTOR)];
if (root instanceof HTMLElement && root.matches(SELECTOR)) elements.unshift(root);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
for (const element of elements) {
if (active.has(element)) continue;
if (reduced) {
element.setAttribute("data-reveal-state", "off");
active.set(element, () => element.removeAttribute("data-reveal-state"));
continue;
}
active.set(element, setup(element));
}
return () => {
for (const element of elements) {
active.get(element)?.();
active.delete(element);
}
};
}
/**
* Sets up anything new and forgets anything that has left the document. Called
* for you by the MutationObserver; worth calling by hand only if you turned that
* off, or moved elements about without adding or removing any.
*/
export function refreshScrollReveal() {
// Elements removed without their cleanup running would otherwise sit in the
// map for the life of the page, holding on to the detached node.
for (const [element, cleanup] of active) {
if (element.isConnected) continue;
cleanup();
active.delete(element);
}
initScrollReveal();
}
/** Tears every reveal down, wherever it was set up from. */
export function disableScrollReveal() {
for (const [element, cleanup] of active) {
cleanup();
active.delete(element);
}
}
/* --------------------------------------------
Page wiring
-------------------------------------------- */
if (typeof window !== "undefined") {
let sweepHandle: number | undefined;
let mutationObserver: MutationObserver | undefined;
/**
* One sweep per frame however many mutations arrive, so inserting a hundred
* cards costs a single pass. Only childList is observed, so the attributes
* and inline properties we set never trigger one.
*/
const scheduleSweep = () => {
if (sweepHandle !== undefined) return;
sweepHandle = requestAnimationFrame(() => {
sweepHandle = undefined;
refreshScrollReveal();
});
};
const start = () => {
disableScrollReveal();
initScrollReveal();
mutationObserver ??= new MutationObserver(scheduleSweep);
mutationObserver.observe(document.documentElement, { childList: true, subtree: true });
};
const stop = () => {
if (sweepHandle !== undefined) cancelAnimationFrame(sweepHandle);
sweepHandle = undefined;
mutationObserver?.disconnect();
disableScrollReveal();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
// A microtask, not a direct call. ES modules evaluate their imports first,
// so a direct call would run before the module that imported this one. The
// split path reads its effect later anyway, but choosing between the two
// paths happens here, and a registered effect with its own `split` default
// has to be known by then. The microtask runs once the import graph has
// finished evaluating.
queueMicrotask(start);
}
// Astro view transitions: the new page's elements need setting up, the old ones tearing down.
document.addEventListener("astro:page-load", start);
document.addEventListener("astro:before-swap", stop);
}

Goes in src/scripts/scroll-reveal.ts

/**
* The split path for scroll-reveal: GSAP SplitText and ScrollTrigger. Imported
* on demand by scroll-reveal.ts, so pages with only whole element reveals never
* download GSAP. Not meant to be imported directly.
*/
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import type { RevealOptions } from "./scroll-reveal-effects";
gsap.registerPlugin(ScrollTrigger, SplitText);
/*
* The flicker effect's ease: on/off rather than a curve, so a piece blinks
* three times, each blink longer than the last, then holds. Registered by name
* so the effect table stays a plain string, and so data-reveal-ease="flicker"
* works on any split effect. Each pair is when a blink starts and ends.
*/
const FLICKER: readonly (readonly [number, number])[] = [
[0.08, 0.16],
[0.3, 0.42],
[0.56, 0.74],
[0.86, 1],
];
gsap.registerEase("flicker", (progress) =>
FLICKER.some(([on, off]) => progress >= on && progress <= off) ? 1 : 0,
);
/** Announces a reveal. Passed in by scroll-reveal.ts so both paths fire the same events. */
type Notify = (element: Element, state: "in" | "out") => void;
export function createSplitReveal(
element: HTMLElement,
options: RevealOptions,
isCancelled: () => boolean,
notify: Notify,
) {
let split: SplitText | undefined;
let timeline: gsap.core.Timeline | undefined;
let triggers: ScrollTrigger[] = [];
// Lives outside build() so a re-split does not announce a reveal that already
// played: ScrollTrigger fires onEnter again when it refreshes into view.
let played = false;
const killAnimation = () => {
for (const trigger of triggers) trigger.kill();
triggers = [];
timeline?.kill();
timeline = undefined;
};
const build = (pieces: Element[]) => {
// A re-split (width change) rebuilds against the new pieces; keep where it was.
const progress = timeline?.progress() ?? 0;
killAnimation();
timeline = gsap.timeline({ paused: true }).fromTo(
pieces,
// x and y claim the transform outright, so a CSS transform is never folded in.
{ ...options.from },
{
...options.to,
duration: options.duration,
delay: options.delay,
ease: options.ease,
stagger: { each: options.stagger, from: options.order },
},
);
timeline.progress(progress);
const tl = timeline;
const once = options.once;
// ScrollTrigger can run onEnter while create() is still returning, so the
// trigger may not be assigned yet. Missing the kill only leaves a played
// timeline observed, which replays nothing.
let play: ScrollTrigger | undefined;
const enter = () => {
tl.play();
if (!played) {
played = true;
notify(element, "in");
}
if (once) play?.kill();
};
play = ScrollTrigger.create({
trigger: element,
start: `top ${100 - options.start}%`,
end: "bottom top",
onEnter: enter,
onEnterBack: enter,
});
triggers.push(play);
if (!once) {
const leave = () => {
tl.pause(0);
if (played) {
played = false;
notify(element, "out");
}
};
// Fully off screen in either direction, so the reset is never visible.
triggers.push(
ScrollTrigger.create({
trigger: element,
start: "top bottom",
end: "bottom top",
onLeave: leave,
onLeaveBack: leave,
}),
);
}
// The pieces carry the hidden state now (fromTo renders immediately), so the element can show.
element.setAttribute("data-reveal-state", "ready");
};
// Splitting before the web font lands measures the fallback and wraps lines wrongly.
document.fonts.ready.then(() => {
if (isCancelled() || !element.isConnected) return;
const type =
options.split === "chars" ? "words,chars" : options.split === "lines" ? "lines" : "words";
split = SplitText.create(element, {
type,
// Chars stay grouped in words, so a word never breaks across lines.
mask: options.mask ? (options.split ?? undefined) : undefined,
linesClass: "reveal-line",
wordsClass: "reveal-word",
charsClass: "reveal-char",
// Re-splits when line wrapping changes. The timeline is rebuilt in onSplit
// rather than returned, because its ScrollTriggers are managed here.
autoSplit: options.split === "lines",
onSplit: (self) => {
const pieces =
options.split === "chars"
? self.chars
: options.split === "lines"
? self.lines
: self.words;
build(pieces);
},
});
});
return () => {
killAnimation();
split?.revert();
split = undefined;
};
}

Goes in src/scripts/scroll-reveal-split.ts

/**
* Effect table and option parsing for scroll-reveal. Pure: no GSAP, no DOM, so
* it can be unit tested in node and imported without cost on CSS-only pages.
*
* Whole element effects are defined in src/styles/scroll-reveal.css. The table
* below only drives the split path, where GSAP animates each line, word or
* character. The two are kept in step by name: every effect here has a matching
* rule there, and every easing name here has a matching cubic-bezier there.
*
* Effect names are a base plus an optional direction:
* base fade, zoom-in, zoom-out, blur
* direction up, down, left, right, up-left, up-right, down-left, down-right
* so fade, fade-up, zoom-in-down and blur-up are all real names. Plus the ones
* that do not fit the grammar: scale, the mask-up / mask-down wipes, and the
* letter effects scatter and flicker.
*/
export type RevealSplit = "lines" | "words" | "chars";
export type RevealMode = "css" | "split";
/** Which piece goes first. Maps onto GSAP's stagger `from`. */
export type RevealOrder = "start" | "end" | "center" | "edges" | "random";
type TweenValue = string | number;
/** Resolved custom properties, handed to an effect so it can build its start values. */
export interface RevealEffectContext {
distance: string;
blur: string;
scale: number;
zoomIn: number;
zoomOut: number;
}
export interface RevealEffect {
/** Starting values for each split piece. Resolved against the custom properties. */
from: (context: RevealEffectContext) => Record<string, TweenValue>;
/** End values. Defaults to the neutral value of every key in `from`. */
to?: Record<string, TweenValue>;
/** Split used when data-reveal-split is not set, which forces the effect onto the GSAP path. */
split?: RevealSplit;
/** Wrap each piece in an overflow clip (SplitText `mask`). */
mask?: boolean;
/** Order used when data-reveal-order is not set. */
order?: RevealOrder;
/** Seconds between pieces, when data-reveal-stagger is not set. */
stagger?: number;
duration?: number;
ease?: string;
}
/** Raw inputs, shaped like `element.dataset`. */
export interface RevealAttributes {
reveal?: string;
revealSplit?: string;
revealDuration?: string;
revealDelay?: string;
revealStagger?: string;
revealOrder?: string;
revealEase?: string;
revealStart?: string;
revealOnce?: string;
revealId?: string;
}
/** Public custom properties, as read from getComputedStyle. Empty strings mean unset. */
export interface RevealCustomProperties {
distance?: string;
duration?: string;
delay?: string;
blur?: string;
scale?: string;
zoomIn?: string;
zoomOut?: string;
}
export interface RevealOptions {
mode: RevealMode;
effect: string;
split: RevealSplit | null;
mask: boolean;
duration: number;
delay: number;
stagger: number;
order: RevealOrder;
/** A GSAP ease string, already resolved from a named easing. Split path only. */
ease: string;
/** Percentage of the viewport height, from the bottom, the element must pass to play. */
start: number;
once: boolean;
/** data-reveal-id, used to name the scroll-reveal:in and :out events. */
id: string | null;
from: Record<string, TweenValue>;
to: Record<string, TweenValue>;
}
export const DEFAULT_EFFECT = "fade-up";
const DEFAULT_DURATION = 0.8;
export const DEFAULT_EASE = "ease-out-quint";
const MASK_EASE = "ease-out-expo";
const DEFAULT_START = 15;
const DEFAULT_DISTANCE = "1.5rem";
const DEFAULT_BLUR = "8px";
const DEFAULT_SCALE = 0.92;
const DEFAULT_ZOOM_IN = 0.6;
const DEFAULT_ZOOM_OUT = 1.2;
const SPLITS: readonly RevealSplit[] = ["lines", "words", "chars"];
const ORDERS: readonly RevealOrder[] = ["start", "end", "center", "edges", "random"];
const STAGGER: Record<RevealSplit, number> = { lines: 0.1, words: 0.04, chars: 0.015 };
const NEUTRAL: Record<string, TweenValue> = {
opacity: 1,
x: 0,
y: 0,
xPercent: 0,
yPercent: 0,
scale: 1,
rotate: 0,
filter: "blur(0px)",
};
const negate = (length: string) => (length.startsWith("-") ? length.slice(1) : `-${length}`);
/* --------------------------------------------
Effect table
-------------------------------------------- */
/**
* A direction is where the element travels to, so `up` starts below its resting
* place and rises into it, and `left` starts to the right. Same convention as AOS.
*/
const DIRECTIONS: Record<string, (distance: string) => Record<string, TweenValue>> = {
up: (distance) => ({ y: distance }),
down: (distance) => ({ y: negate(distance) }),
left: (distance) => ({ x: distance }),
right: (distance) => ({ x: negate(distance) }),
"up-left": (distance) => ({ x: distance, y: distance }),
"up-right": (distance) => ({ x: negate(distance), y: distance }),
"down-left": (distance) => ({ x: distance, y: negate(distance) }),
"down-right": (distance) => ({ x: negate(distance), y: negate(distance) }),
};
const BASES: Record<string, (context: RevealEffectContext) => Record<string, TweenValue>> = {
fade: () => ({ opacity: 0 }),
"zoom-in": ({ zoomIn }) => ({ opacity: 0, scale: zoomIn }),
"zoom-out": ({ zoomOut }) => ({ opacity: 0, scale: zoomOut }),
blur: ({ blur }) => ({ opacity: 0, filter: `blur(${blur})` }),
};
const effects = new Map<string, RevealEffect>();
/**
* Registers a base and every direction of it: `registerRevealBase("tilt", ...)`
* defines tilt, tilt-up, tilt-down-left and the other six, each composing the
* base's start values with the travel for that direction.
*
* This is the split path only. On the CSS path a custom base already gets its
* directions free, because the rules in scroll-reveal.css match the name's
* suffix rather than a list of known bases.
*
* Call it at the top level of the script that imports scroll-reveal.ts. A split
* element looks its effect up once GSAP has loaded, which takes at least a
* fetch, so any registration made while the page's scripts first run arrives in
* time. One made in a click handler, a timeout or after an await can miss, and
* the element falls back to fade-up with a console warning.
*
* An effect with its own `split` default is stricter: the choice between the CSS
* and split paths is made before GSAP loads, so register it in the same import
* graph as scroll-reveal.ts.
*/
export function registerRevealBase(
name: string,
from: RevealEffect["from"],
options: Omit<RevealEffect, "from"> = {},
) {
effects.set(name, { ...options, from });
for (const [direction, travel] of Object.entries(DIRECTIONS)) {
effects.set(`${name}-${direction}`, {
...options,
from: (context) => ({ ...from(context), ...travel(context.distance) }),
});
}
}
for (const [base, from] of Object.entries(BASES)) registerRevealBase(base, from);
effects.set("scale", { from: ({ scale }) => ({ opacity: 0, scale }) });
// Whole element masks are a clip wipe in scroll-reveal.css; these values are
// the per piece slide, used once data-reveal-split asks for one.
effects.set("mask-up", {
from: () => ({ yPercent: 100 }),
mask: true,
duration: 1,
ease: MASK_EASE,
});
effects.set("mask-down", {
from: () => ({ yPercent: -100 }),
mask: true,
duration: 1,
ease: MASK_EASE,
});
// Letter effects: always split into chars, played in a random order.
// scatter: each letter appears, one after another.
effects.set("scatter", {
from: () => ({ opacity: 0 }),
split: "chars",
order: "random",
stagger: 0.03,
duration: 0.2,
ease: "linear",
});
// flicker: each letter blinks on and off before it holds. The "flicker" ease
// is registered with GSAP in scroll-reveal-split.ts.
effects.set("flicker", {
from: () => ({ opacity: 0 }),
split: "chars",
order: "random",
stagger: 0.03,
duration: 0.5,
ease: "flicker",
});
/** Adds or replaces a split effect. Whole element effects are plain CSS rules instead. */
export function registerRevealEffect(name: string, effect: RevealEffect) {
effects.set(name, effect);
}
export function getRevealEffect(name: string) {
return effects.get(name);
}
/** Every effect name the split path knows, for tests and documentation. */
export function revealEffectNames() {
return [...effects.keys()];
}
/* --------------------------------------------
Easing names
-------------------------------------------- */
const GSAP_CURVES: Record<string, string> = {
sine: "sine",
quad: "power1",
cubic: "power2",
quart: "power3",
quint: "power4",
expo: "expo",
circ: "circ",
back: "back",
};
const EASES = new Map<string, string>([
["linear", "none"],
["ease", "power1.inOut"],
["ease-in", "power1.in"],
["ease-out", "power1.out"],
["ease-in-out", "power1.inOut"],
]);
for (const [curve, power] of Object.entries(GSAP_CURVES)) {
EASES.set(`ease-in-${curve}`, `${power}.in`);
EASES.set(`ease-out-${curve}`, `${power}.out`);
EASES.set(`ease-in-out-${curve}`, `${power}.inOut`);
}
/**
* Named easings work on both paths: scroll-reveal.css maps each name to a
* cubic-bezier, this maps it to the GSAP equivalent. Anything unrecognised is
* passed to GSAP as written, so raw GSAP eases such as "back.out(1.7)" still work.
*/
export function resolveEase(name: string) {
return EASES.get(name) ?? name;
}
/** Every named easing, for tests and documentation. */
export function revealEaseNames() {
return [...EASES.keys()];
}
/* --------------------------------------------
Option parsing
-------------------------------------------- */
/** Seconds from "0.8", "0.8s" or "800ms". Anything else is undefined. */
export function parseTime(value: string | undefined): number | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
const match = /^(-?\d*\.?\d+)(ms|s)?$/.exec(trimmed);
if (!match) return undefined;
const number = parseFloat(match[1]);
const seconds = match[2] === "ms" ? number / 1000 : number;
return seconds >= 0 ? seconds : undefined;
}
/** A plain number from a custom property, for the scale factors. */
function parseNumber(value: string | undefined, fallback: number) {
const number = parseFloat(value?.trim() ?? "");
return Number.isFinite(number) ? number : fallback;
}
function parseStart(value: string | undefined) {
const number = parseFloat(value ?? "");
return Number.isFinite(number) ? Math.min(100, Math.max(0, number)) : DEFAULT_START;
}
function isSplit(value: string | undefined): value is RevealSplit {
return (SPLITS as readonly string[]).includes(value ?? "");
}
function isOrder(value: string | undefined): value is RevealOrder {
return (ORDERS as readonly string[]).includes(value ?? "");
}
/**
* Whether an element takes the split path. Unlike parseRevealOptions it never
* warns about an unknown effect, so it is safe to ask before the page's own
* effects are registered.
*/
export function wantsSplit(attributes: RevealAttributes) {
if (isSplit(attributes.revealSplit)) return true;
const name = attributes.reveal?.trim() || DEFAULT_EFFECT;
return Boolean(effects.get(name)?.split);
}
/**
* Resolves an element's attributes and custom properties into one config.
* Order for anything with a public custom property: property, then attribute,
* then the effect's default, then the global default.
*/
export function parseRevealOptions(
attributes: RevealAttributes,
properties: RevealCustomProperties = {},
): RevealOptions {
const requested = attributes.reveal?.trim() || DEFAULT_EFFECT;
const known = effects.get(requested);
const split = isSplit(attributes.revealSplit) ? attributes.revealSplit : (known?.split ?? null);
const mode: RevealMode = split ? "split" : "css";
// Unknown names are fine on the CSS path (a consumer's own CSS rule), but
// the split path has nothing to animate without a table entry.
let effectName = requested;
let effect = known;
if (mode === "split" && !effect) {
console.warn(`scroll-reveal: unknown effect "${requested}", using "${DEFAULT_EFFECT}".`);
effectName = DEFAULT_EFFECT;
effect = effects.get(DEFAULT_EFFECT);
}
const context: RevealEffectContext = {
distance: properties.distance?.trim() || DEFAULT_DISTANCE,
blur: properties.blur?.trim() || DEFAULT_BLUR,
scale: parseNumber(properties.scale, DEFAULT_SCALE),
zoomIn: parseNumber(properties.zoomIn, DEFAULT_ZOOM_IN),
zoomOut: parseNumber(properties.zoomOut, DEFAULT_ZOOM_OUT),
};
const from = effect?.from(context) ?? {};
const to =
effect?.to ?? Object.fromEntries(Object.keys(from).map((key) => [key, NEUTRAL[key] ?? 0]));
const order = attributes.revealOrder?.trim();
// An id of "0" is a legitimate name, so only an absent or empty value is null.
const id = attributes.revealId?.trim();
return {
mode,
effect: effectName,
split,
mask: Boolean(effect?.mask && split),
duration:
parseTime(properties.duration) ??
parseTime(attributes.revealDuration) ??
effect?.duration ??
DEFAULT_DURATION,
delay: parseTime(properties.delay) ?? parseTime(attributes.revealDelay) ?? 0,
stagger:
parseTime(attributes.revealStagger) ?? effect?.stagger ?? (split ? STAGGER[split] : 0),
order: isOrder(order) ? order : (effect?.order ?? "start"),
ease: resolveEase(attributes.revealEase?.trim() || effect?.ease || DEFAULT_EASE),
start: parseStart(attributes.revealStart),
once: attributes.revealOnce !== undefined && attributes.revealOnce !== "false",
id: id ? id : null,
from,
to,
};
}

Goes in src/scripts/scroll-reveal-effects.ts

/*
* scroll-reveal. Load it in the head, not from the script: it hides content
* before first paint, and a stylesheet added when the module runs lands after
* it (a flash of visible content that then disappears).
*
* Hiding is gated on (scripting: enabled), so without JS everything shows,
* and on reduced motion, so nothing is hidden for people who opt out.
*
* data-reveal-state, set by the script:
* (none) not set up yet. Hidden, with a 3s failsafe in case the script never runs.
* out whole element: hidden, no transition (resets are instant and off screen).
* in whole element: shown, with the transition.
* ready split: the pieces carry the hidden state, so the element shows.
* off reduced motion or a load error: shown.
*
* An effect name is a base plus an optional direction, so the rules below come
* in two halves that combine: a base says how the element looks (faded, zoomed,
* blurred), a direction says where it travels from.
*
* base fade, zoom-in, zoom-out, blur
* direction up, down, left, right, up-left, up-right, down-left, down-right
*
* A direction is where the element travels to, so "up" starts below its resting
* place and rises into it. Plus two names outside the grammar: scale, and the
* mask-up / mask-down clip wipes.
*
* Whole element effects use the individual translate and scale properties,
* not transform, so a consumer's own transform still applies. They have no
* effect on inline elements: use them on block or inline-block elements.
* mask-up and mask-down also animate clip-path, so they clip to the border box.
*
* Add your own effect with one rule for its hidden state. The revealed state is
* the absence of that rule, so there is no second rule to write:
*
* [data-reveal="clip"]:not([data-reveal-state="in"], [data-reveal-state="off"]) {
* clip-path: inset(0 100% 0 0);
* }
*
* The direction rules below match on the name's suffix, not on a list of known
* bases, so a custom base gets all eight directions free: define "clip" and
* clip-up, clip-down-left and the rest already work, composing your rule with
* the travel. Anything outside the transitioned properties needs
* --reveal-properties set as well.
*
* Keep custom effects in src/styles/scroll-reveal-custom.css rather than here,
* so re-copying this snippet never overwrites them, and inside the same
* (scripting: enabled) and (prefers-reduced-motion: no-preference) gate as the
* rules below. That file already has the block; outside it, a page without
* JavaScript never leaves the hidden state.
*/
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
/* Whole element path */
[data-reveal]:not([data-reveal-split]) {
--_distance: var(--reveal-distance, 1.5rem);
/*
* The properties a reveal animates. A custom effect that animates
* something else (transform, rotate, colour) sets --reveal-properties to
* the full list it needs, or it snaps instead of transitioning. transform
* is left out on purpose: the built-in effects use translate and scale so
* that a consumer's own transform keeps working untouched.
*/
transition-property: var(
--reveal-properties,
opacity,
translate,
scale,
rotate,
filter,
clip-path
);
transition-duration: var(--reveal-duration, var(--_reveal-duration, 0.8s));
transition-delay: var(--reveal-delay, var(--_reveal-delay, 0s));
/* Public property, then the data-reveal-ease name, then the effect's own default. */
transition-timing-function: var(
--reveal-ease,
var(--_ease-attr, var(--_ease-effect, cubic-bezier(0.22, 1, 0.36, 1)))
);
/*
* The hidden state: before the reveal plays, and after a reset. Each rule
* below is the starting point the element transitions away from. "ready"
* is excluded for effects that split by default (scatter, flicker) without
* a data-reveal-split attribute: once split, the pieces carry the hidden
* state and the element itself must show.
*/
&:not([data-reveal-state="in"], [data-reveal-state="off"], [data-reveal-state="ready"]) {
opacity: 0;
/* Bases */
&[data-reveal^="zoom-in"] {
scale: var(--reveal-zoom-in, 0.6);
}
&[data-reveal^="zoom-out"] {
scale: var(--reveal-zoom-out, 1.2);
}
&[data-reveal^="blur"] {
filter: blur(var(--reveal-blur, 8px));
}
&[data-reveal="scale"] {
scale: var(--reveal-scale, 0.92);
}
/*
* Directions. An empty data-reveal is fade-up, the default. A diagonal
* ends in -left or -right as well, so the diagonals come last and win
* on source order rather than needing extra specificity.
*/
&:is([data-reveal=""], [data-reveal$="-up"]) {
translate: 0 var(--_distance);
}
&[data-reveal$="-down"] {
translate: 0 calc(-1 * var(--_distance));
}
&[data-reveal$="-left"] {
translate: var(--_distance) 0;
}
&[data-reveal$="-right"] {
translate: calc(-1 * var(--_distance)) 0;
}
&[data-reveal$="-up-left"] {
translate: var(--_distance) var(--_distance);
}
&[data-reveal$="-up-right"] {
translate: calc(-1 * var(--_distance)) var(--_distance);
}
&[data-reveal$="-down-left"] {
translate: var(--_distance) calc(-1 * var(--_distance));
}
&[data-reveal$="-down-right"] {
translate: calc(-1 * var(--_distance)) calc(-1 * var(--_distance));
}
}
/*
* Masks reveal with a moving clip edge rather than a fade: the visible
* band is anchored to one edge and grows across the element, while the
* element drifts the other way (the -up and -down travel above), so the
* content reads as rising out of, or dropping into, the edge. Split into
* lines with data-reveal-split and each line slides behind its own clip.
*
* The clip stays at inset(0) once revealed, so a masked element clips to
* its own box: keep focus outlines and shadows out of masked blocks.
*/
&[data-reveal^="mask"] {
--_reveal-duration: 1s;
--_ease-effect: cubic-bezier(0.16, 1, 0.3, 1);
&:not([data-reveal-state="off"]) {
clip-path: inset(0);
opacity: 1;
}
&[data-reveal="mask-up"]:not([data-reveal-state="in"], [data-reveal-state="off"]) {
clip-path: inset(100% 0 0 0);
}
&[data-reveal="mask-down"]:not([data-reveal-state="in"], [data-reveal-state="off"]) {
clip-path: inset(0 0 100% 0);
}
}
/*
* Named easings for data-reveal-ease. The same names map to GSAP eases in
* scroll-reveal-effects.ts, so one name works on both paths. Curves from
* easings.net; --reveal-ease still beats all of them.
*/
&[data-reveal-ease="linear"] {
--_ease-attr: linear;
}
&[data-reveal-ease="ease"] {
--_ease-attr: ease;
}
&[data-reveal-ease="ease-in"] {
--_ease-attr: ease-in;
}
&[data-reveal-ease="ease-out"] {
--_ease-attr: ease-out;
}
&[data-reveal-ease="ease-in-out"] {
--_ease-attr: ease-in-out;
}
&[data-reveal-ease="ease-in-sine"] {
--_ease-attr: cubic-bezier(0.12, 0, 0.39, 0);
}
&[data-reveal-ease="ease-out-sine"] {
--_ease-attr: cubic-bezier(0.61, 1, 0.88, 1);
}
&[data-reveal-ease="ease-in-out-sine"] {
--_ease-attr: cubic-bezier(0.37, 0, 0.63, 1);
}
&[data-reveal-ease="ease-in-quad"] {
--_ease-attr: cubic-bezier(0.11, 0, 0.5, 0);
}
&[data-reveal-ease="ease-out-quad"] {
--_ease-attr: cubic-bezier(0.5, 1, 0.89, 1);
}
&[data-reveal-ease="ease-in-out-quad"] {
--_ease-attr: cubic-bezier(0.45, 0, 0.55, 1);
}
&[data-reveal-ease="ease-in-cubic"] {
--_ease-attr: cubic-bezier(0.32, 0, 0.67, 0);
}
&[data-reveal-ease="ease-out-cubic"] {
--_ease-attr: cubic-bezier(0.33, 1, 0.68, 1);
}
&[data-reveal-ease="ease-in-out-cubic"] {
--_ease-attr: cubic-bezier(0.65, 0, 0.35, 1);
}
&[data-reveal-ease="ease-in-quart"] {
--_ease-attr: cubic-bezier(0.5, 0, 0.75, 0);
}
&[data-reveal-ease="ease-out-quart"] {
--_ease-attr: cubic-bezier(0.25, 1, 0.5, 1);
}
&[data-reveal-ease="ease-in-out-quart"] {
--_ease-attr: cubic-bezier(0.76, 0, 0.24, 1);
}
&[data-reveal-ease="ease-in-quint"] {
--_ease-attr: cubic-bezier(0.64, 0, 0.78, 0);
}
&[data-reveal-ease="ease-out-quint"] {
--_ease-attr: cubic-bezier(0.22, 1, 0.36, 1);
}
&[data-reveal-ease="ease-in-out-quint"] {
--_ease-attr: cubic-bezier(0.83, 0, 0.17, 1);
}
&[data-reveal-ease="ease-in-expo"] {
--_ease-attr: cubic-bezier(0.7, 0, 0.84, 0);
}
&[data-reveal-ease="ease-out-expo"] {
--_ease-attr: cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-reveal-ease="ease-in-out-expo"] {
--_ease-attr: cubic-bezier(0.87, 0, 0.13, 1);
}
&[data-reveal-ease="ease-in-circ"] {
--_ease-attr: cubic-bezier(0.55, 0, 1, 0.45);
}
&[data-reveal-ease="ease-out-circ"] {
--_ease-attr: cubic-bezier(0, 0.55, 0.45, 1);
}
&[data-reveal-ease="ease-in-out-circ"] {
--_ease-attr: cubic-bezier(0.85, 0, 0.15, 1);
}
&[data-reveal-ease="ease-in-back"] {
--_ease-attr: cubic-bezier(0.36, 0, 0.66, -0.56);
}
&[data-reveal-ease="ease-out-back"] {
--_ease-attr: cubic-bezier(0.34, 1.56, 0.64, 1);
}
&[data-reveal-ease="ease-in-out-back"] {
--_ease-attr: cubic-bezier(0.68, -0.6, 0.32, 1.6);
}
&[data-reveal-state="out"] {
transition: none;
}
}
/* Split path: hide until the pieces take over */
[data-reveal-split]:not([data-reveal-state]) {
visibility: hidden;
}
/* Neither path has started after 3s: the script failed, so show the content. */
[data-reveal]:not([data-reveal-state]) {
animation: scroll-reveal-failsafe 0s 3s forwards;
}
}
@keyframes scroll-reveal-failsafe {
to {
opacity: 1;
translate: none;
scale: none;
rotate: none;
filter: none;
clip-path: none;
visibility: visible;
}
}

Goes in src/styles/scroll-reveal.css

/*
* Your scroll-reveal effects. This file is yours: scroll-reveal.css is the
* snippet's and gets overwritten whenever you copy a newer version of it, so
* keep everything project specific here instead.
*
* Load it after scroll-reveal.css, in the head. Neither file uses a layer, so
* the later file wins wherever the two set the same property:
* <link rel="stylesheet" href="/styles/scroll-reveal.css">
* <link rel="stylesheet" href="/styles/scroll-reveal-custom.css">
*
* Write every effect inside the @media block below. It is the same gate
* scroll-reveal.css puts round its own effects: without it, a page with
* JavaScript off never gets past the hidden state, so the effect is stuck
* there, and reduced motion users see it until the script catches up.
*
* One rule defines an effect. It describes the hidden state, which the element
* transitions away from; the revealed state is the absence of that rule.
*
* [data-reveal="tilt"]:not([data-reveal-state="in"], [data-reveal-state="off"]) {
* rotate: -4deg;
* }
*
* Every reveal already starts at opacity 0, so add only what makes the effect
* its own. You also get tilt-up, tilt-down-left and the other directions free,
* because the direction rules in scroll-reveal.css match the end of the name.
*
* Anything outside the properties scroll-reveal transitions by default
* (opacity, translate, scale, rotate, filter, clip-path) has to be listed, or it
* snaps into place. Keep translate in the list if the effect takes directions:
*
* [data-reveal^="skew"] {
* --reveal-properties: opacity, transform, translate;
* }
*
* [data-reveal^="skew"]:not([data-reveal-state="in"], [data-reveal-state="off"]) {
* transform: skewY(6deg);
* }
*
* Splitting a custom effect into lines, words or characters needs GSAP values
* as well, because the pieces are animated in JavaScript rather than by these
* rules. Register them at the top level of the script that imports
* scroll-reveal, which also gives the split path all eight directions:
*
* import { registerRevealBase } from "./scroll-reveal";
* registerRevealBase("tilt", () => ({ opacity: 0, rotate: -4 }));
*
* Effects that earn their place across projects belong upstream in the snippet
* itself, not here. See the "How to extend scroll-reveal" guide.
*/
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
/* Your effects go here. */
}

Goes in src/styles/scroll-reveal-custom.css

---
name: scroll-reveal
description: Animate an element as it scrolls into view. Use for any request to fade in, slide in, zoom in, blur in, reveal or animate something on scroll, to stagger text in by line, word or letter, or to add a scroll-triggered entrance to a card, image, section or heading. Also use when asked to add or change a reveal animation, or to write an IntersectionObserver that shows content on scroll.
---
# Scroll reveal
This project already has a scroll reveal system. Use it. Do not write a new
IntersectionObserver, a new `.is-visible` class toggle, or a new CSS transition
for a scroll entrance.
## Check first
```bash
grep -rn "data-reveal" src/ --include=*.astro --include=*.tsx --include=*.html | head
```
If that returns nothing, the snippet is not installed here: check
`src/styles/scroll-reveal.css` and `src/scripts/scroll-reveal.ts` before
assuming it is missing, and only then build something yourself.
## Use it
Add an attribute. There is no wrapper component and no per-element JavaScript.
```html
<div data-reveal="fade-up">A card that rises in</div>
<img data-reveal="zoom-in" src="..." alt="..." width="800" height="600" />
<h2 data-reveal="mask-up">Wiped in from the bottom edge</h2>
<p data-reveal="fade-up" data-reveal-split="words">Words rise in turn</p>
```
The page needs `src/styles/scroll-reveal.css` in the head and one import of
`src/scripts/scroll-reveal.ts`. Both are usually already there. Elements added
later are picked up on their own, so dynamic content needs no extra call.
## Effect names
A base plus an optional direction, so any pairing is valid:
- Bases: `fade`, `zoom-in`, `zoom-out`, `blur`
- Directions: `up`, `down`, `left`, `right`, `up-left`, `up-right`, `down-left`, `down-right`
`fade`, `fade-up`, `zoom-in-down`, `blur-left` all work. A direction is where the
element travels **to**, so `up` starts below and rises. Outside the grammar:
`scale`, the `mask-up` and `mask-down` clip wipes, and two letter effects for
headings that split into characters on their own: `scatter` (letters appear one
at a time, in a random order) and `flicker` (each letter blinks on in a random
order).
```html
<h2 data-reveal="scatter">Letters appear in a random order</h2>
<h2 data-reveal="flicker">Letters blink on</h2>
```
## Options
| Attribute | Meaning |
| ---------------------- | ------------------------------------------------------- |
| `data-reveal-split` | `lines`, `words` or `chars`. Text only. Loads GSAP. |
| `data-reveal-duration` | Seconds. Default 0.8. |
| `data-reveal-delay` | Seconds. Default 0. |
| `data-reveal-stagger` | Seconds between split pieces. |
| `data-reveal-order` | `start`, `end`, `center`, `edges` or `random`. Split. |
| `data-reveal-ease` | Named easing, for example `ease-out-back`. |
| `data-reveal-start` | Percent up the viewport before it plays. Default 15. |
| `data-reveal-once` | Play once, never reset. |
| `data-reveal-id` | Names the element in its `scroll-reveal:in:<id>` event. |
Custom properties beat the matching attribute: `--reveal-distance`,
`--reveal-duration`, `--reveal-delay`, `--reveal-blur`, `--reveal-scale`,
`--reveal-zoom-in`, `--reveal-zoom-out`, `--reveal-ease`.
## Adding an effect that does not exist yet
Put it in `src/styles/scroll-reveal-custom.css`, never in `scroll-reveal.css`,
which is overwritten when the snippet is updated. One rule, describing the
hidden state, inside the `@media` block the file already has:
```css
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
[data-reveal="tilt"]:not([data-reveal-state="in"], [data-reveal-state="off"]) {
rotate: -4deg;
}
}
```
Never write the rule outside that `@media` block. Without it, a page with
JavaScript off is stuck in the hidden state.
That gives you `tilt-up`, `tilt-down-left` and the rest as well, because the
direction rules match the end of the name. Opacity 0 is already applied to every
reveal, so only add what makes the effect its own.
Animating a property outside `opacity, translate, scale, rotate, filter,
clip-path` needs `--reveal-properties` set to the full list, or it snaps.
The CSS rule does nothing for `data-reveal-split`, because split pieces are
animated by GSAP. To split a custom effect, also register its GSAP values at the
top level of the script that imports `scroll-reveal.ts`:
```ts
import { registerRevealBase } from "./scroll-reveal";
registerRevealBase("tilt", () => ({ opacity: 0, rotate: -4 }));
```
## Rules
- Never use it on the largest above-the-fold element. Hidden content is not
counted as painted, which pushes LCP later.
- An image still needs `width` and `height`, or an `aspect-ratio`. The reveal
reserves no space.
- Reduced motion is handled: nothing is hidden and nothing is split. Do not add
your own `prefers-reduced-motion` check around it.
- Content is visible without JavaScript. Do not move text into a script.
- Headings keep their own level; the reveal never chooses one.

Goes in .claude/skills/scroll-reveal/SKILL.md

API

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

Attributes

NameTypeDefaultDescription
data-revealeffect name"fade-up"A base (fade, zoom-in, zoom-out, blur) plus an optional direction (up, down, left, right, up-left, up-right, down-left, down-right), so fade, fade-up, zoom-in-down and blur-left are all valid. Also scale, mask-up and mask-down, and two letter effects that split into characters without data-reveal-split: scatter, where letters appear one at a time in a random order, and flicker, where each letter blinks on and off before it holds.
data-reveal-split"lines" | "words" | "chars"Splits the text and staggers the pieces. Loads GSAP; leave it off and the reveal is a CSS transition. Only for elements whose content is text.
data-reveal-durationseconds0.8How long one piece takes. Masks default to 1.
data-reveal-delayseconds0Wait before the reveal starts.
data-reveal-staggerseconds0.1 lines, 0.04 words, 0.015 chars (scatter and flicker 0.03)Delay between pieces. Split only.
data-reveal-order"start" | "end" | "center" | "edges" | "random""start" (scatter and flicker "random")Which piece goes first. Split only.
data-reveal-easeeasing name"ease-out-quint"linear, ease, ease-in, ease-out, ease-in-out, or ease-in, ease-out and ease-in-out of sine, quad, cubic, quart, quint, expo, circ and back (for example ease-out-quint). The same name works on both paths. Masks default to ease-out-expo. A raw GSAP ease such as back.out(1.7) also works, on the split path only.
data-reveal-startpercent15How far up the viewport, from the bottom, the element must be before it plays.
data-reveal-onceboolean attributePlay once. Without it, a reveal resets when the element is fully off screen and plays again when it returns, from either direction.
data-reveal-idstringNames the element in its events, as scroll-reveal:in:<id> and scroll-reveal:out:<id>.

Custom properties

NameTypeDefaultDescription
--reveal-distancelength"1.5rem"How far a directional effect travels. Diagonals use it on both axes.
--reveal-durationtime"0.8s"Beats the data-reveal-duration attribute.
--reveal-delaytime"0s"Beats the data-reveal-delay attribute.
--reveal-easeeasing functioncubic-bezier(0.22, 1, 0.36, 1)Whole element path only. Beats data-reveal-ease, and takes any CSS easing function, not just a named one. Masks default to cubic-bezier(0.16, 1, 0.3, 1). Split reveals take their ease from the attribute.
--reveal-blurlength"8px"Starting blur for the blur effects.
--reveal-scalenumber0.92Starting scale for the scale effect.
--reveal-zoom-innumber0.6Starting scale for the zoom-in effects. Below 1, so the element grows into place.
--reveal-zoom-outnumber1.2Starting scale for the zoom-out effects. Above 1, so the element shrinks into place.

Events

NameTypeDescription
scroll-reveal:inCustomEventThe reveal played. Dispatched on the element and bubbles, so listen on the element or on document. detail carries the element and its data-reveal-id.
scroll-reveal:outCustomEventThe reveal reset, having scrolled fully off screen. Never fires for an element that has not played, and never with data-reveal-once.
scroll-reveal:in:<id>CustomEventThe same pair, named after data-reveal-id, so one listener waits for one element. scroll-reveal:out:<id> matches it.

Accessibility and SEO

Accessibility

Content stays in the DOM throughout. Split elements carry the original text in an aria-label, so screen readers hear the whole sentence. Reduced motion hides and splits nothing. Each reveal lasts about a second and runs only on scroll, so it needs no pause control. Hidden elements still take pointer events, so a link works even if its reveal never plays. The page still chooses the element and its heading level, and should check split body copy with a screen reader: aria-label on a paragraph is not announced everywhere.

SEO

All content is server-rendered and visible without JS. Hiding is gated on the scripting media feature. The page still has to keep reveals off the LCP element and anything above the fold, because Chrome does not count hidden content as painted. It also has to give revealed images a width and height, or an aspect-ratio.

Notes

A direction names where the element travels to. fade-up starts below its resting place and rises into it, and fade-left starts to the right of it.

Whole element effects animate the translate and scale properties, so a transform of your own still applies. Neither property moves an inline element, and a bare span stays where it is.

GSAP loads on the first split element. A page with only whole element reveals, masks included, never downloads it.

scatter and flicker always split into characters, so they load GSAP. Any split effect can play in a random order with data-reveal-order="random", and data-reveal-ease="flicker" gives any split effect the flicker’s on and off blinks.

A masked element clips to its own box before and after it is revealed, because the wipe animates clip-path and holds it at inset(0). Its focus outline and shadow are clipped with it. A mask split into lines clips each line instead and leaves the element’s own outline alone.

Splitting waits for document.fonts.ready. Splitting against the fallback font measures the wrong line breaks, and they stay wrong once the web font arrives.

A CSS transform on a split piece is folded into GSAP’s starting matrix, and the piece lands in the wrong place.

Elements added after load are set up by a MutationObserver, which batches every change in a frame into one sweep.

Custom effects, directions for them, and hooking code onto a reveal are covered in Extending scroll-reveal.