Documentation
Ghost Reveal docs
Ghost Reveal makes elements fade into view as you scroll, using HTML attributes and one small script.
- About 2 KB, zero dependencies, no CSS file to add
- HTML attributes such as
data-reveal. Existing AOS markup works too - Accessible: it respects reduced motion, and content stays visible without JavaScript
On this page
Quick start in 3 steps #
No build tools needed. This works in any HTML page, WordPress theme or site builder that lets you add a script.
-
Add the script before
</head>or before</body>:<script src="https://unpkg.com/ghost-reveal@0.2/dist/ghost-reveal.umd.min.js" data-auto defer></script> -
Mark the elements that should reveal with
data-reveal:<h2 data-reveal>Hello</h2> <img data-reveal="zoom-in" src="photo.jpg" alt="…"> Done. Scroll your page. Elements fade up with a soft blur as they enter the screen.
Live example
These cards use data-reveal-once="false", so they replay each time you scroll them out of view and back in.
Installation #
From a CDN (no build step) #
The quickest way. The data-auto attribute starts it for you:
<script src="https://unpkg.com/ghost-reveal@0.2/dist/ghost-reveal.umd.min.js" data-auto defer></script>To pass options, leave out data-auto and call GhostReveal.init() at the end of <body>:
<script src="https://unpkg.com/ghost-reveal@0.2/dist/ghost-reveal.umd.min.js"></script>
<script>GhostReveal.init({ duration: 900, blur: 8 });</script>Prefer ES modules? Import it straight from the CDN:
<script type="module">
import { init } from 'https://unpkg.com/ghost-reveal@0.2/dist/ghost-reveal.esm.min.js';
init();
</script>From npm #
npm i ghost-revealimport { init } from 'ghost-reveal';
init();
// or start it with zero config:
import 'ghost-reveal/auto';Which file should I use? #
File in dist/ | Use it for | min+gzip |
|---|---|---|
ghost-reveal.umd.min.js | A <script> tag (CDN, WordPress, no build). Adds window.GhostReveal, the AOS alias and AOS easing names. Add data-auto to start it automatically. | 2,718 B |
ghost-reveal.esm.min.js | <script type="module"> from a CDN, when you want to call init() yourself. | 1,977 B |
ghost-reveal.auto.esm.min.js | The same as a module, starting automatically. This is what import 'ghost-reveal/auto' loads. | 1,963 B |
ghost-reveal.esm.js | What bundlers (Vite, Next.js, …) get from import { init } from 'ghost-reveal'. Readable source; your bundler minifies it. | 2,571 B |
aos-easings.esm.js | Optional add-on with the AOS easing names, for ESM users (ghost-reveal/aos-easings). | 312 B |
Tip: pin the version in CDN URLs (@0.2) so an update never changes your site unexpectedly.
Basic usage #
Everything is configured on the element itself. data-reveal on its own uses the default animation. Give it a name to pick another, and add data-reveal-* attributes to fine-tune that element:
<h2 data-reveal>Uses the default (fade-up)</h2>
<p data-reveal="fade-left" data-reveal-delay="150">Slides in from the right</p>
<img data-reveal="zoom-in" data-reveal-duration="1200" data-reveal-blur="10" src="hero.jpg" alt="…">Call init() once, after the page's HTML exists. Options passed here become the defaults for every element:
import { init } from 'ghost-reveal';
const reveal = init({ duration: 900, offset: 100 });Content that appears later, such as route changes, lazy-loaded lists or CMS widgets, is picked up automatically. You don't need to call anything again.
Animations #
There are 23 named animations in 4 families. Each tile below plays its own animation as it scrolls into view. Select one to open it in the Playground.
Fade 5 #
Opacity and blur, plus a short move of distance px in the named direction.
Zoom 10 #
Scales from 0.6 (in) or 1.2 (out) with opacity and blur, optionally moving too.
zoom-inzoom-outzoom-in-upzoom-in-downzoom-in-leftzoom-in-rightzoom-out-upzoom-out-downzoom-out-leftzoom-out-right
Slide 4 #
Moves 100% of its own size into place, with a fade.
Flip 4 #
A 3D rotate of 100° with perspective(2500px), opacity and blur.
Try every animation in the Playground
Direction names follow AOS: fade-up moves up into place (it starts lower). Every family also fades and blurs, and the blur can be set to 0.
Attributes reference #
Put these on any element you reveal. Each one also works with the data-aos- prefix, except data-reveal-stagger. If both forms are set, data-reveal-* wins.
| Attribute | Values | Default | Example value |
|---|---|---|---|
data-revealor data-aos | An animation name. Empty (or "true" from JSX) means the default animation | fade-up | "zoom-in" |
data-reveal-durationor data-aos-duration | Milliseconds | 700 | "900" |
data-reveal-delayor data-aos-delay | Milliseconds | 0 | "150" |
data-reveal-easingor data-aos-easing | Any CSS easing, or a registered easing name | cubic-bezier(.16,1,.3,1) | "ease-out" |
data-reveal-distanceor data-aos-distance | Pixels of travel (fade and zoom directions) | 16 | "40" |
data-reveal-bluror data-aos-blur | Pixels of starting blur, 0 for none | 4 | "10" |
data-reveal-offsetor data-aos-offset | Pixels above the bottom of the viewport where the reveal starts | 60 | "120" |
data-reveal-onceor data-aos-once | false replays it every time it scrolls into view | true | "false" |
data-reveal-anchor-placementor data-aos-anchor-placement | top-bottom, center-bottom, bottom-bottom, top-center, center-center, bottom-center | top-bottom | "center-bottom" |
data-reveal-scrubor data-aos-scrub | No value (experimental, see Scrub) | off | (no value) |
data-reveal-stagger | On a parent: milliseconds between siblings revealed together. Empty means 80 | none | "100" |
Options reference #
Pass these to init(). Attributes on an element override the matching option for that element.
init({ duration: 900, once: false, disable: 'mobile' });| Option | Type | Default | Description |
|---|---|---|---|
selector | string | '[data-reveal],[data-aos]' | CSS selector of elements to reveal. |
animation | string (animation name) | 'fade-up' | Animation used when the attribute is empty (data-reveal). |
duration | number | 700 | Animation duration in ms. |
delay | number | 0 | Delay in ms. |
easing | string | 'cubic-bezier(.16,1,.3,1)' | Any CSS easing string, or a key registered in easings. Expo-out. |
distance | number | 16 | Travel in px for fade-* and zoom-*-<dir> animations. |
blur | number | 4 | Start blur in px (blur-to-sharp). 0 disables. |
offset | number | 60 | Trigger line in px above the bottom of the viewport. AOS: 120. |
rootMargin | string | none | Raw IntersectionObserver rootMargin; overrides offset (but not per-element data-*-offset). Derived from offset. |
anchorPlacement | string (anchor placement) | 'top-bottom' | Default anchor placement. |
once | boolean | true | Reveal only once. AOS default is false. |
initial | boolean | false | Also animate elements that are already on screen at init. Otherwise they are simply shown, without animation. |
observe | boolean | true | Watch the DOM (MutationObserver, added subtrees only) for added/removed elements. |
scrub | boolean | false | Experimental: tie progress to scroll position with a native ViewTimeline (reversible, no JS per frame). Falls back to the normal reveal where ViewTimeline is unsupported. Usually set per element: data-reveal-scrub. |
disable | boolean | 'mobile' | (() => boolean) | false | Disable entirely (nothing is hidden). 'mobile' = viewport narrower than 768px. Re-checked on refresh(). |
Timing, stagger and effects #
Stagger #
Put data-reveal-stagger on a parent. Its children that reveal together start one after another. Leave it empty for 80 ms steps, or give it a number:
<ul data-reveal-stagger="100">
<li data-reveal>One</li>
<li data-reveal>Two</li>
<li data-reveal>Three</li>
</ul>Only siblings revealed together count, so in a long grid each new row starts right away instead of waiting for every card above it.
Blur #
The default reveal goes from a 4px blur to sharp. Use more blur for big headings, or 0 for a crisp fade:
<h1 data-reveal data-reveal-blur="10" data-reveal-distance="28" data-reveal-duration="1200">Big hero heading</h1>
<img data-reveal data-reveal-blur="0" src="large.jpg" alt="…">Tip: blur is the most expensive of the three animated properties. On very large images for low-end phones, data-reveal-blur="0" keeps things smooth.
Duration, delay and easing #
data-reveal-duration and data-reveal-delay take milliseconds. data-reveal-easing takes any CSS easing such as ease-out or cubic-bezier(.2,.8,.2,1). The default is cubic-bezier(.16,1,.3,1), an expo-out curve that starts fast and lands softly. An invalid easing falls back to linear.
AOS easing names (ease-out-back, ease-in-out-cubic, …) work in the script-tag build. With npm, register them first:
import { init, easings } from 'ghost-reveal';
import aosEasings from 'ghost-reveal/aos-easings';
Object.assign(easings, aosEasings);
init();When it triggers: offset and anchor placement #
By default an element reveals when its top edge is 60px above the bottom of the screen. Change that with data-reveal-offset or the offset option.
data-reveal-anchor-placement picks which part of the element must reach which line: <element edge>-<screen line>. For example, center-bottom waits until the element's middle reaches the offset line, and top-center waits until its top reaches the middle of the screen.
<section data-reveal data-reveal-anchor-placement="center-bottom">…</section>Once or every time #
Elements reveal once by default. To replay each time an element comes back into view, use data-reveal-once="false", or init({ once: false }) for the whole page. The element resets while it is fully off screen, so you never see it snap back.
Disable #
Turn it off entirely (nothing is hidden) with disable: true, on narrow screens with disable: 'mobile' (under 768px), or with your own test:
init({ disable: () => document.body.classList.contains('no-motion') });JavaScript API #
init(), refresh() and destroy() #
import { init } from 'ghost-reveal';
const reveal = init(); // start; returns an instance
reveal.refresh(); // re-scan: new elements, removed ones, changed attributes
reveal.destroy(); // stop everything and show all content againYou rarely need refresh(): new elements are found automatically. Call it after you add data-reveal to an element that is already on the page, or if you turned off observe.
On the server (no document), init() does nothing and returns a harmless instance, so it is safe in SSR code.
Events #
reveal:in fires on an element when it reveals, including elements already on screen at start. reveal:out fires when an element with once: false resets. Both bubble, so one listener on document catches them all:
document.addEventListener('reveal:in', (event) => {
console.log('revealed', event.target);
});Events are dispatched a moment after init() returns, so a listener added right after init() still hears the first ones.
keyframes() and defaults #
keyframes(name, distance?, blur?) returns an animation's starting keyframe as plain data. Use it with element.animate(), or to build CSS. defaults holds the global defaults, and you can change them. This output comes straight from the library:
import { keyframes, defaults } from 'ghost-reveal';
keyframes('fade-up');
// → [{ opacity: 0, filter: 'blur(4px)', transform: 'translate(0px,16px)' }, {}]
keyframes('zoom-in');
// → [{ opacity: 0, filter: 'blur(4px)', transform: 'translate(0px,0px) scale(0.6)' }, {}]
keyframes('slide-left');
// → [{ opacity: 0, filter: 'blur(4px)', transform: 'translate(100%,0%)' }, {}]
keyframes('fade-up', 40, 0); // custom distance, no blur
// → [{ opacity: 0, transform: 'translate(0px,40px)' }, {}]
defaults;
// → { animation: 'fade-up',
// duration: 700,
// delay: 0,
// easing: 'cubic-bezier(.16,1,.3,1)',
// distance: 16,
// blur: 4,
// offset: 60,
// once: true,
// observe: true }The script-tag global #
The script-tag build exposes window.GhostReveal with init, refresh, refreshHard, destroy, create, defaults, easings and keyframes. It is also available as window.AOS if nothing else uses that name. Calling GhostReveal.init() again replaces the previous instance. create() makes an independent one.
Framework guides #
Every recipe is "call init() once on the client". Because observe: true is the default, content rendered later (route changes, infinite lists, CMS widgets) is picked up automatically. You don't need a refresh() per route.
Verified: the React (19, StrictMode, with a simulated route change), Vue 3.5 and Svelte 5 recipes ran in headless Chromium, and React renderToString ran in Node, during v0.2 development (those throwaway test apps aren't part of this repo). The Next.js, Nuxt, SvelteKit, Astro, Angular and WordPress recipes follow those frameworks' documented APIs but haven't been run.
Plain HTML / CDN (no build) #
<script src="https://unpkg.com/ghost-reveal@0.2/dist/ghost-reveal.umd.min.js" data-auto defer></script>
<!-- or, with options: -->
<script src="https://unpkg.com/ghost-reveal@0.2/dist/ghost-reveal.umd.min.js"></script>
<script>GhostReveal.init({ duration: 900, blur: 8 });</script> <!-- at the end of <body> -->
<!-- or ESM: -->
<script type="module">
import { init } from 'https://unpkg.com/ghost-reveal@0.2/dist/ghost-reveal.esm.min.js';
init();
</script>WordPress / no build step #
// functions.php (theme or a small plugin)
add_action('wp_enqueue_scripts', function () {
wp_enqueue_script('ghost-reveal', 'https://unpkg.com/ghost-reveal@0.2/dist/ghost-reveal.umd.min.js', [], '0.2.0', true);
// Also reveal blocks that have the class "reveal" (Block → Advanced → Additional CSS class)
wp_add_inline_script('ghost-reveal', "GhostReveal.init({ selector: '[data-reveal],[data-aos],.reveal' });");
});Elements matched only by class use the default animation. Use a Custom HTML block for full data-reveal-* control.
Vite / any ESM bundler #
import 'ghost-reveal/auto'; // zero config, or:
import { init } from 'ghost-reveal';
init({ blur: 6 });React #
Call it once near the root.
import { useEffect } from 'react';
import { init } from 'ghost-reveal';
export function GhostReveal(props) {
useEffect(() => {
const reveal = init(props);
return () => reveal.destroy();
}, []);
return null;
}
// <GhostReveal /> once in your root component. Then <h2 data-reveal>…</h2> anywhere.In JSX a bare data-reveal renders as data-reveal="true". Ghost Reveal treats that as the default animation.
Next.js (App Router) #
// app/ghost-reveal.tsx
'use client';
import { useEffect } from 'react';
import { init, type GhostRevealOptions } from 'ghost-reveal';
export default function GhostReveal(props: GhostRevealOptions) {
useEffect(() => {
const reveal = init(props);
return () => reveal.destroy();
}, []);
return null;
}
// app/layout.tsx (a Server Component, unchanged otherwise)
import GhostReveal from './ghost-reveal';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<GhostReveal />
</body>
</html>
);
}Server Components can render data-reveal attributes freely. Only the tiny client component above runs JS. Client-side navigations are picked up by the DOM observer.
Vue 3 (Vite) #
// main.js
import { createApp } from 'vue';
import { init } from 'ghost-reveal';
import App from './App.vue';
createApp(App).mount('#app');
init(); // vue-router navigations are picked up automaticallyNuxt 3/4 #
// plugins/ghost-reveal.client.ts
import { init } from 'ghost-reveal';
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('app:mounted', () => init());
});Svelte 5 / SvelteKit #
<!-- src/routes/+layout.svelte (SvelteKit) or App.svelte -->
<script>
import { onMount } from 'svelte';
import { init } from 'ghost-reveal';
let { children } = $props();
onMount(() => init().destroy); // the returned function runs on unmount
</script>
{@render children()}Astro #
Works with <ClientRouter /> view transitions too.
---
// src/layouts/Layout.astro
---
<html lang="en">
<body>
<slot />
<script>
import 'ghost-reveal/auto';
</script>
</body>
</html>Angular (17+) #
import { Component, DestroyRef, afterNextRender, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { init } from 'ghost-reveal';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<h1 data-reveal>Hello</h1><router-outlet />`,
})
export class AppComponent {
constructor() {
const destroyRef = inject(DestroyRef);
afterNextRender(() => {
const reveal = init(); // browser only; with SSR, afterNextRender never runs on the server
destroyRef.onDestroy(() => reveal.destroy());
});
}
}For bound values, use attribute bindings: [attr.data-reveal-delay]="i * 80".
Migrating from AOS #
Most AOS sites switch by swapping two files. Your data-aos markup and AOS.init() call keep working.
Before (AOS)
<link rel="stylesheet" href="https://unpkg.com/aos@2.3.4/dist/aos.css">
<script src="https://unpkg.com/aos@2.3.4/dist/aos.js"></script>
<script>AOS.init();</script>
<div data-aos="fade-up" data-aos-duration="800">…</div>After (Ghost Reveal)
<!-- no CSS file -->
<script src="https://unpkg.com/ghost-reveal@0.2/dist/ghost-reveal.umd.min.js"></script>
<script>AOS.init();</script>
<div data-aos="fade-up" data-aos-duration="800">…</div>Keep the script at the end of <body> as you had with AOS, or use data-auto defer instead of the inline call.
What carries over #
data-aosanimation names anddata-aos-duration,-delay,-easing,-offset,-onceand-anchor-placementAOS.init(),AOS.refresh(),AOS.refreshHard()and the AOS easing names
What's different #
- The look, on purpose: a blur-fade with an expo-out curve, 700ms and 16px of travel. Slides fade too.
oncedefaults totrue(AOS:false) andoffsetto60(AOS:120).- Elements already on screen at load are shown, not animated. Use
initial: trueto animate them. - There are no
aos-init/aos-animateclasses. Listen for thereveal:inandreveal:outevents instead. - With
once: false, elements reset instantly when fully off screen. There is no reverse animation.
Not supported #
mirror, startEvent, throttleDelay, debounceDelay, useClassNames, animatedClassName, data-aos-anchor, data-aos-id, disable: 'phone' | 'tablet' (use 'mobile' or a function), and *-top anchor placements (they behave like *-bottom).
Match the AOS look exactly #
AOS.init({ blur: 0, duration: 400, easing: 'ease', distance: 100, offset: 120, once: false });Accessibility and reduced motion #
- Reduced motion: when someone has reduce motion turned on in their system settings, nothing is hidden or animated, and content simply appears. If they change the setting while the page is open, Ghost Reveal follows it right away.
- Content is never locked away: without JavaScript, or if the script fails to load, everything is visible. Ghost Reveal hides elements only after it has started, and it uses no CSS classes or inline styles.
- Nothing jumps on load: elements already on screen when the page loads are shown immediately, with no flash or layout shift. Only
opacity,transformandfilterare animated. - No sideways scrollbar: elements that slide in from the side never widen the page, including on phones.
Don't add opacity: 0 in your CSS (a common AOS habit). Ghost Reveal doesn't need it, and it would hide content for visitors without JavaScript.
Experimental: native scroll-linked scrub #
Add data-reveal-scrub and the animation follows the scroll position in both directions, driven by the browser's own scroll timeline with no JavaScript per frame:
<div data-reveal="fade-up" data-reveal-scrub>…</div>It uses ViewTimeline where the browser has it (Chrome/Edge 115+ and Safari 26+, per MDN data checked on 2026-10-21). Elsewhere, including current stable Firefox, it falls back to the normal reveal. This mode is experimental and may change.
Animations are plain keyframe data (keyframes()), so the same definitions can move to native CSS scroll-driven animations as browser support grows. Read the roadmap for the plan.
Browser support #
Ghost Reveal needs IntersectionObserver and the Web Animations API, among a few other modern features. Going by MDN compatibility data, that means roughly Chrome/Edge 84+, Firefox 75+ and Safari 14+. Those older versions haven't been tested.
The test suite passes in current Chromium, Firefox and WebKit engines. It hasn't been run on real Safari or iOS devices yet. In a browser that's too old, Ghost Reveal stops before hiding anything, so visitors still see all the content.
Troubleshooting and FAQ #
Elements show up but don't animate #
- Elements on screen when the page loads are shown without animation, by design. Scroll down to see reveals, or use
init({ initial: true }). - Your device may have reduce motion turned on. In that case nothing animates, also by design.
- Check that the script loads (the browser console shows errors), and that
data-autois on the script-tag build. With modules, useimport 'ghost-reveal/auto'or callinit(). disableor a customselectormay be excluding the element.
Elements stay invisible #
Look for your own CSS that sets opacity: 0 or visibility: hidden (left over from AOS) and remove it. Ghost Reveal never needs CSS.
Content added later doesn't reveal #
New elements are found automatically. If you added data-reveal to an element that was already on the page, or you set observe: false, call refresh() afterwards.
"document is not defined" with SSR #
Importing Ghost Reveal is safe on the server, and init() on the server does nothing. If you see this error, something is running browser code during server rendering. Call init() inside useEffect, onMount, afterNextRender or a client-only plugin, as in the framework guides.
Elements at the top animate or flash on load #
Start Ghost Reveal after the HTML is parsed: use defer or data-auto defer, put the script at the end of <body>, or use a module script. If init() runs in <head> before the body exists, elements look as if they were added later, so even those on screen animate in.
A horizontal scrollbar appears #
Ghost Reveal stops its own slides from widening the page. If you still see one, something else is wider than the screen: often a fixed-width element or a negative margin. Pages that already set overflow-x: hidden (or clip) on html or body keep the full slide-in from off screen. To get that, clip horizontal overflow yourself:
html, body { overflow-x: clip; }Does it work with a strict Content Security Policy? #
Yes. It sets no inline styles. Animations run through the Web Animations API, which style-src doesn't block. This site runs it under script-src 'self'; style-src 'self' without violations. Allow the script's origin in script-src (or host the file yourself). An inline GhostReveal.init() needs a nonce or hash, or use data-auto instead.
Is it fast? #
It uses IntersectionObserver instead of scroll listeners, shares observers between elements, and animates only opacity, transform and filter, which browsers can run off the main thread. It also watches the page for new content efficiently, scanning only the elements that were added.
For AI agents #
A compact, always up-to-date reference for AI tools and coding agents lives at /llms.txt. Point your assistant at it before it writes Ghost Reveal markup.