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.
Install
pnpm add gsapSetup
- Load
src/styles/scroll-reveal.cssin the head. - Load
src/styles/scroll-reveal-custom.cssafter it. - Import
src/scripts/scroll-reveal.tsonce 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.
Install
pnpm add gsapSetup
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.
Install
pnpm add gsapExpects 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.
Try every setting on a live preview in the Scroll reveal playground.
---import { Image } from "astro:assets";import ScrollReveal from "/src/components/ScrollReveal.astro";import sample from "./assets/sample.svg";
const DIRECTIONS = [ "up", "down", "left", "right", "up-left", "up-right", "down-left", "down-right",];---
<div class="spacer">Scroll down, then back up</div><main class="demo"> <section> <p class="label">Any element, CSS only</p> <div class="cards"> <ScrollReveal class="card" effect="fade"> fade </ScrollReveal> <ScrollReveal class="card" effect="fade-up" delay={0.1}> fade-up </ScrollReveal> <ScrollReveal class="card" effect="scale" delay={0.2}> scale </ScrollReveal> <ScrollReveal class="card" effect="blur" delay={0.3}> blur </ScrollReveal> </div> </section>
<section> <p class="label">An image, wrapped rather than attributed</p> <ScrollReveal effect="zoom-in"> <Image class="image" src={sample} alt="Stylised mountains under a setting sun" /> </ScrollReveal> </section>
<section> <p class="label">Every direction, on fade</p> <div class="cards"> {DIRECTIONS.map((direction) => ( <ScrollReveal class="card small" effect={`fade-${direction}`}> {direction} </ScrollReveal> ))} </div> </section>
<section> <p class="label">Named easings, the same on both paths</p> <div class="cards"> <ScrollReveal class="card small" effect="fade-up" ease="ease-out-back" duration={1.2}> ease-out-back </ScrollReveal> <ScrollReveal class="card small" effect="fade-up" ease="ease-in-out-circ" duration={1.2}> ease-in-out-circ </ScrollReveal> </div> </section>
<section> <p class="label">mask-up, whole element, CSS only</p> <ScrollReveal as="h2" class="headline" effect="mask-up"> Wiped in from the bottom edge, no GSAP </ScrollReveal> </section>
<section> <p class="label">fade-up, words</p> <ScrollReveal as="p" class="lede" split="words"> Words rise and fade in one after another, then reset once the paragraph is fully off screen so they play again when you scroll back. </ScrollReveal> </section>
<section> <p class="label">blur, chars</p> <ScrollReveal as="h2" class="headline" effect="blur" split="chars"> Letter by letter </ScrollReveal> </section>
<section> <p class="label">mask-up, lines</p> <ScrollReveal as="h2" class="headline" effect="mask-up" split="lines"> Every line slides up from behind its own mask </ScrollReveal> </section>
<section> <p class="label">mask-down, once</p> <ScrollReveal as="h2" class="headline" effect="mask-down" once> This one plays once </ScrollReveal> </section></main><div class="spacer"></div>---import { Image } from "astro:assets";import sample from "./assets/sample.svg";import "/src/styles/scroll-reveal.css";
const DIRECTIONS = [ "up", "down", "left", "right", "up-left", "up-right", "down-left", "down-right",];const ZOOMS = ["zoom-in", "zoom-out", "zoom-in-up", "zoom-out-down"];const EASES = ["ease-out-quint", "ease-out-back", "ease-in-out-circ", "linear"];---
<div class="spacer">Scroll down, then back up</div><main class="demo"> <section> <p class="label">Any element, CSS only</p> <div class="cards"> <div class="card" data-reveal="fade"> fade </div> <div class="card" data-reveal="fade-up" data-reveal-delay="0.1"> fade-up </div> <div class="card" data-reveal="scale" data-reveal-delay="0.2"> scale </div> <div class="card" data-reveal="blur" data-reveal-delay="0.3"> blur </div> </div> </section>
<section> <p class="label">An image, the same way</p> <Image class="image" src={sample} alt="Stylised mountains under a setting sun" data-reveal="zoom-in" /> </section>
<section> <p class="label">Every direction, on fade</p> <div class="cards"> {DIRECTIONS.map((direction) => ( <div class="card small" data-reveal={`fade-${direction}`}> {direction} </div> ))} </div> </section>
<section> <p class="label">Zoom</p> <div class="cards"> {ZOOMS.map((effect, index) => ( <div class="card" data-reveal={effect} data-reveal-delay={index * 0.1}> {effect} </div> ))} </div> </section>
<section> <p class="label">The same fade-up, four named easings</p> <div class="cards"> {EASES.map((ease) => ( <div class="card small" data-reveal="fade-up" data-reveal-ease={ease} data-reveal-duration="1.2" > {ease} </div> ))} </div> </section>
<section> <p class="label">mask-up, whole element, CSS only</p> <h2 class="headline" data-reveal="mask-up"> Wiped in from the bottom edge, no GSAP </h2> </section>
<section> <p class="label">fade-up, words</p> <p class="lede" data-reveal="fade-up" data-reveal-split="words"> Words rise and fade in one after another, then reset once the paragraph is fully off screen so they play again when you scroll back. </p> </section>
<section> <p class="label">blur, chars</p> <h2 class="headline" data-reveal="blur" data-reveal-split="chars"> Letter by letter </h2> </section>
<section> <p class="label">scatter: letters appear one by one, in a random order</p> <h2 class="headline" data-reveal="scatter"> Twentieth and twenty-first century art </h2> </section>
<section> <p class="label">flicker: letters blink on, in a random order</p> <h2 class="headline" data-reveal="flicker"> Twentieth and twenty-first century art </h2> </section>
<section> <p class="label">fade-up, chars, from the center out</p> <h2 class="headline" data-reveal="fade-up" data-reveal-split="chars" data-reveal-order="center"> From the middle outwards </h2> </section>
<section> <p class="label">mask-up, lines</p> <h2 class="headline" data-reveal="mask-up" data-reveal-split="lines"> Every line slides up from behind its own mask </h2> </section>
<section> <p class="label">mask-down, once, and named in its events</p> <h2 class="headline" data-reveal="mask-down" data-reveal-id="finale" data-reveal-once> This one plays once </h2> <p class="status" id="status" role="status"> Waiting for the headline above </p> </section>
<section> <p class="label"> A custom effect, tilt, defined in this demo's own CSS. Its directions come free </p> <div class="cards"> <div class="card" data-reveal="tilt"> tilt </div> <div class="card" data-reveal="tilt-up" data-reveal-delay="0.1"> tilt-up </div> <div class="card" data-reveal="tilt-down-left" data-reveal-delay="0.2"> tilt-down-left </div> </div> </section>
<section> <p class="label">The same custom effect split into words, from registerRevealBase</p> <p class="lede" data-reveal="tilt" data-reveal-split="words"> Each word tilts into place using start values this page registered for the split path. </p> </section>
<section> <p class="label">Added after load, picked up by the MutationObserver</p> <button type="button" id="add"> Add three cards </button> <div class="cards" id="added"></div> </section></main><div class="spacer"></div>
<script> import { registerRevealBase } from "/src/scripts/scroll-reveal.ts";
// The split half of the custom tilt effect. The CSS half is in the style block below. registerRevealBase("tilt", () => ({ opacity: 0, rotate: -8 }));
// Events: one listener waits for the element named by data-reveal-id. const status = document.getElementById("status"); document.addEventListener("scroll-reveal:in:finale", () => { if (status) status.textContent = "scroll-reveal:in:finale fired"; });
// Nothing here calls into scroll-reveal: the new cards set themselves up. const added = document.getElementById("added"); let count = 0;
document.getElementById("add")?.addEventListener("click", () => { for (let index = 0; index < 3; index += 1) { count += 1; const card = document.createElement("div"); card.className = "card"; card.dataset.reveal = "zoom-in-up"; card.dataset.revealDelay = String(index * 0.1); card.textContent = `card ${count}`; added?.append(card); } });</script>
<style is:global> /* A custom effect, written the way scroll-reveal-custom.css asks: one rule, inside the gate. */ @media (scripting: enabled) and (prefers-reduced-motion: no-preference) { [data-reveal^="tilt"]:not([data-reveal-state="in"], [data-reveal-state="off"]) { rotate: -8deg; } }
.status { margin-block-start: 1rem; color: #666; font-size: 0.875rem; }
#add { justify-self: start; min-block-size: 44px; margin-block-end: 1rem; padding-inline: 1rem; border: 1px solid #999; border-radius: 0.375rem; background: #fff; font: inherit; }</style>---import { ScrollReveal } from "/src/components/ScrollReveal";---
<div class="spacer">Scroll down, then back up</div><main class="demo"> <section> <p class="label">Any element, CSS only</p> <div class="cards"> <ScrollReveal client:load className="card" effect="fade"> fade </ScrollReveal> <ScrollReveal client:load className="card" effect="fade-up" delay={0.1}> fade-up </ScrollReveal> <ScrollReveal client:load className="card" effect="scale" delay={0.2}> scale </ScrollReveal> <ScrollReveal client:load className="card" effect="blur" delay={0.3}> blur </ScrollReveal> </div> </section>
<section> <p class="label">Zoom, with a direction</p> <div class="cards"> <ScrollReveal client:load className="card" effect="zoom-in"> zoom-in </ScrollReveal> <ScrollReveal client:load className="card" effect="zoom-out" delay={0.1}> zoom-out </ScrollReveal> <ScrollReveal client:load className="card" effect="zoom-in-up" delay={0.2}> zoom-in-up </ScrollReveal> <ScrollReveal client:load className="card" effect="blur-left" delay={0.3}> blur-left </ScrollReveal> </div> </section>
<section> <p class="label">Named easings, the same on both paths</p> <div class="cards"> <ScrollReveal client:load className="card small" effect="fade-up" ease="ease-out-back" duration={1.2} > ease-out-back </ScrollReveal> <ScrollReveal client:load className="card small" effect="fade-up" ease="ease-in-out-circ" duration={1.2} > ease-in-out-circ </ScrollReveal> </div> </section>
<section> <p class="label">mask-up, whole element, CSS only</p> <ScrollReveal client:load as="h2" className="headline" effect="mask-up"> Wiped in from the bottom edge, no GSAP </ScrollReveal> </section>
<section> <p class="label">fade-up, words</p> <ScrollReveal client:load as="p" className="lede" split="words"> Words rise and fade in one after another, then reset once the paragraph is fully off screen so they play again when you scroll back. </ScrollReveal> </section>
<section> <p class="label">blur, chars</p> <ScrollReveal client:load as="h2" className="headline" effect="blur" split="chars"> Letter by letter </ScrollReveal> </section>
<section> <p class="label">mask-up, lines</p> <ScrollReveal client:load as="h2" className="headline" effect="mask-up" split="lines"> Every line slides up from behind its own mask </ScrollReveal> </section>
<section> <p class="label">mask-down, once</p> <ScrollReveal client:load as="h2" className="headline" effect="mask-down" once> This one plays once </ScrollReveal> </section></main><div class="spacer"></div>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-revealdescription: 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 newIntersectionObserver, a new `.is-visible` class toggle, or a new CSS transitionfor a scroll entrance.
## Check first
```bashgrep -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` beforeassuming 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 addedlater 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 theelement travels **to**, so `up` starts below and rises. Outside the grammar:`scale`, the `mask-up` and `mask-down` clip wipes, and two letter effects forheadings that split into characters on their own: `scatter` (letters appear oneat a time, in a random order) and `flicker` (each letter blinks on in a randomorder).
```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 thehidden 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 withJavaScript off is stuck in the hidden state.
That gives you `tilt-up`, `tilt-down-left` and the rest as well, because thedirection rules match the end of the name. Opacity 0 is already applied to everyreveal, 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 areanimated by GSAP. To split a custom effect, also register its GSAP values at thetop level of the script that imports `scroll-reveal.ts`:
```tsimport { 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
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.tsFrom the HTML core
/** * 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.tsFrom the HTML core
/** * 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.tsFrom the HTML core
/* * 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.cssFrom the HTML core
/* * 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.cssFrom the HTML core
---name: scroll-revealdescription: 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 newIntersectionObserver, a new `.is-visible` class toggle, or a new CSS transitionfor a scroll entrance.
## Check first
```bashgrep -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` beforeassuming 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 addedlater 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 theelement travels **to**, so `up` starts below and rises. Outside the grammar:`scale`, the `mask-up` and `mask-down` clip wipes, and two letter effects forheadings that split into characters on their own: `scatter` (letters appear oneat a time, in a random order) and `flicker` (each letter blinks on in a randomorder).
```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 thehidden 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 withJavaScript off is stuck in the hidden state.
That gives you `tilt-up`, `tilt-down-left` and the rest as well, because thedirection rules match the end of the name. Opacity 0 is already applied to everyreveal, 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 areanimated by GSAP. To split a custom effect, also register its GSAP values at thetop level of the script that imports `scroll-reveal.ts`:
```tsimport { 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.mdFrom the HTML core
---/** * Astro wrapper for scroll-reveal. Renders one element with the data-reveal * attributes; see src/scripts/scroll-reveal.ts for the full API. You can also * skip the component and put the attributes on any element yourself. * * <ScrollReveal as="h2" effect="mask-up">Lines slide up from a mask</ScrollReveal> * <ScrollReveal as="p" split="words" stagger={0.03}>Words fade up in turn</ScrollReveal> * <ScrollReveal as="h2" effect="flicker">Letters blink on in a random order</ScrollReveal> */import type { HTMLAttributes, HTMLTag } from "astro/types";import type { RevealOrder, RevealSplit } from "../scripts/scroll-reveal-effects";// Imported in frontmatter so Astro bundles it into the head, before first paint.import "../styles/scroll-reveal.css";
// Extends HTMLAttributes: without it, Astro's handling of an `as` prop drops the Props type entirely.interface Props extends HTMLAttributes<"div"> { /** Element to render. Default "div". Headings are never assumed. */ as?: HTMLTag; /** A base (fade, zoom-in, zoom-out, blur) plus an optional direction (up, down, left, right, up-left, up-right, down-left, down-right). Also scale, mask-up, mask-down, and the letter effects scatter and flicker. Default fade-up. */ effect?: string; /** Split into lines, words or chars. Leave unset for a whole element reveal. */ split?: RevealSplit; /** Seconds. */ duration?: number; /** Seconds. */ delay?: number; /** Seconds between split pieces. */ stagger?: number; /** Which piece goes first: start, end, center, edges or random. */ order?: RevealOrder; /** Named easing, for example ease-out-back. Works on both paths. */ ease?: string; /** Percent of the viewport, from the bottom, before it plays. */ start?: number; /** Play once and never reset. */ once?: boolean; /** Names the element in its events: scroll-reveal:in:<revealId>. */ revealId?: string;}
const { as: Tag = "div", effect, split, duration, delay, stagger, order, ease, start, once, revealId, ...rest} = Astro.props;const text = (value: number | undefined) => (value === undefined ? undefined : String(value));---
<Tag data-reveal={effect ?? ""} data-reveal-split={split} data-reveal-duration={text(duration)} data-reveal-delay={text(delay)} data-reveal-stagger={text(stagger)} data-reveal-order={order} data-reveal-ease={ease} data-reveal-start={text(start)} data-reveal-once={once ? "" : undefined} data-reveal-id={revealId} {...rest}> <slot /></Tag>
<script> import "../scripts/scroll-reveal.ts";</script>Goes in src/components/ScrollReveal.astro
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.tsFrom the HTML core
/** * 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.tsFrom the HTML core
/** * 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.tsFrom the HTML core
/* * 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.cssFrom the HTML core
/* * 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.cssFrom the HTML core
---name: scroll-revealdescription: 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 newIntersectionObserver, a new `.is-visible` class toggle, or a new CSS transitionfor a scroll entrance.
## Check first
```bashgrep -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` beforeassuming 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 addedlater 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 theelement travels **to**, so `up` starts below and rises. Outside the grammar:`scale`, the `mask-up` and `mask-down` clip wipes, and two letter effects forheadings that split into characters on their own: `scatter` (letters appear oneat a time, in a random order) and `flicker` (each letter blinks on in a randomorder).
```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 thehidden 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 withJavaScript off is stuck in the hidden state.
That gives you `tilt-up`, `tilt-down-left` and the rest as well, because thedirection rules match the end of the name. Opacity 0 is already applied to everyreveal, 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 areanimated by GSAP. To split a custom effect, also register its GSAP values at thetop level of the script that imports `scroll-reveal.ts`:
```tsimport { 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.mdFrom the HTML core
/** * React wrapper for scroll-reveal. Renders one element with the data-reveal * attributes; see src/scripts/scroll-reveal.ts for the full API. * * <ScrollReveal as="h2" effect="mask-up">Lines slide up from a mask</ScrollReveal> * <ScrollReveal as="p" split="words" stagger={0.03}>Words fade up in turn</ScrollReveal> * <ScrollReveal as="h2" effect="flicker">Letters blink on in a random order</ScrollReveal> */import { useEffect, useRef, type ElementType, type HTMLAttributes } from "react";import type { RevealOrder, RevealSplit } from "../scripts/scroll-reveal-effects";// A CSS import, so the bundler puts it in the head before the content paints.import "../styles/scroll-reveal.css";
interface ScrollRevealProps extends HTMLAttributes<HTMLElement> { /** Element to render. Default "div". Headings are never assumed. */ as?: ElementType; /** A base (fade, zoom-in, zoom-out, blur) plus an optional direction (up, down, left, right, up-left, up-right, down-left, down-right). Also scale, mask-up, mask-down, and the letter effects scatter and flicker. Default fade-up. */ effect?: string; /** Split into lines, words or chars. Leave unset for a whole element reveal. */ split?: RevealSplit; /** Seconds. */ duration?: number; /** Seconds. */ delay?: number; /** Seconds between split pieces. */ stagger?: number; /** Which piece goes first: start, end, center, edges or random. */ order?: RevealOrder; /** Named easing, for example ease-out-back. Works on both paths. */ ease?: string; /** Percent of the viewport, from the bottom, before it plays. */ start?: number; /** Play once and never reset. */ once?: boolean; /** Names the element in its events: scroll-reveal:in:<revealId>. */ revealId?: string;}
export function ScrollReveal({ as: Tag = "div", effect, split, duration, delay, stagger, order, ease, start, once, revealId, children, ...rest}: ScrollRevealProps) { const ref = useRef<HTMLElement>(null);
// Observers and SplitText need the real DOM, which does not exist during // server rendering; effects only run in the browser. The cleanup reverts the // split and disconnects observers on unmount (and between StrictMode's double run). useEffect(() => { const element = ref.current; if (!element) return;
let cancelled = false; let cleanup: (() => void) | undefined;
import("../scripts/scroll-reveal").then(({ initScrollReveal }) => { if (!cancelled) cleanup = initScrollReveal(element); });
return () => { cancelled = true; cleanup?.(); }; // Props only take effect on setup, so a change re-runs it against the new attributes. }, [effect, split, duration, delay, stagger, order, ease, start, once, revealId]);
return ( <Tag ref={ref} data-reveal={effect ?? ""} data-reveal-split={split} data-reveal-duration={duration} data-reveal-delay={delay} data-reveal-stagger={stagger} data-reveal-order={order} data-reveal-ease={ease} data-reveal-start={start} data-reveal-once={once ? "" : undefined} data-reveal-id={revealId} {...rest} > {children} </Tag> );}Goes in src/components/ScrollReveal.tsx
API
Shared by every variant. Wrapper props use the same names in camelCase.
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
data-reveal | effect 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-duration | seconds | 0.8 | How long one piece takes. Masks default to 1. |
data-reveal-delay | seconds | 0 | Wait before the reveal starts. |
data-reveal-stagger | seconds | 0.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-ease | easing 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-start | percent | 15 | How far up the viewport, from the bottom, the element must be before it plays. |
data-reveal-once | boolean attribute | Play once. Without it, a reveal resets when the element is fully off screen and plays again when it returns, from either direction. | |
data-reveal-id | string | Names the element in its events, as scroll-reveal:in:<id> and scroll-reveal:out:<id>. |
Custom properties
| Name | Type | Default | Description |
|---|---|---|---|
--reveal-distance | length | "1.5rem" | How far a directional effect travels. Diagonals use it on both axes. |
--reveal-duration | time | "0.8s" | Beats the data-reveal-duration attribute. |
--reveal-delay | time | "0s" | Beats the data-reveal-delay attribute. |
--reveal-ease | easing function | cubic-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-blur | length | "8px" | Starting blur for the blur effects. |
--reveal-scale | number | 0.92 | Starting scale for the scale effect. |
--reveal-zoom-in | number | 0.6 | Starting scale for the zoom-in effects. Below 1, so the element grows into place. |
--reveal-zoom-out | number | 1.2 | Starting scale for the zoom-out effects. Above 1, so the element shrinks into place. |
Events
| Name | Type | Description |
|---|---|---|
scroll-reveal:in | CustomEvent | The 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:out | CustomEvent | The 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> | CustomEvent | The 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-labelon a paragraph is not announced everywhere.- SEO
All content is server-rendered and visible without JS. Hiding is gated on the
scriptingmedia 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 anaspect-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.