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.

  1. Add the script before </head> or before </body>:

    HTML
    <script src="https://unpkg.com/ghost-reveal@0.2/dist/ghost-reveal.umd.min.js" data-auto defer></script>
  2. Mark the elements that should reveal with data-reveal:

    HTML
    <h2 data-reveal>Hello</h2>
    <img data-reveal="zoom-in" src="photo.jpg" alt="…">
  3. Done. Scroll your page. Elements fade up with a soft blur as they enter the screen.

Live example

data-revealthe default fade-up
zoom-inscales into place
flip-left3D flip

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:

HTML
<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>:

HTML
<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:

HTML
<script type="module">
  import { init } from 'https://unpkg.com/ghost-reveal@0.2/dist/ghost-reveal.esm.min.js';
  init();
</script>

From npm #

Terminal
npm i ghost-reveal
JavaScript
import { 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 formin+gzip
ghost-reveal.umd.min.jsA <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.jsThe same as a module, starting automatically. This is what import 'ghost-reveal/auto' loads.1,963 B
ghost-reveal.esm.jsWhat bundlers (Vite, Next.js, …) get from import { init } from 'ghost-reveal'. Readable source; your bundler minifies it.2,571 B
aos-easings.esm.jsOptional 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:

HTML
<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:

JavaScript
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.

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.

AttributeValuesDefaultExample value
data-reveal
or data-aos
An animation name. Empty (or "true" from JSX) means the default animationfade-up"zoom-in"
data-reveal-duration
or data-aos-duration
Milliseconds700"900"
data-reveal-delay
or data-aos-delay
Milliseconds0"150"
data-reveal-easing
or data-aos-easing
Any CSS easing, or a registered easing namecubic-bezier(.16,1,.3,1)"ease-out"
data-reveal-distance
or data-aos-distance
Pixels of travel (fade and zoom directions)16"40"
data-reveal-blur
or data-aos-blur
Pixels of starting blur, 0 for none4"10"
data-reveal-offset
or data-aos-offset
Pixels above the bottom of the viewport where the reveal starts60"120"
data-reveal-once
or data-aos-once
false replays it every time it scrolls into viewtrue"false"
data-reveal-anchor-placement
or data-aos-anchor-placement
top-bottom, center-bottom, bottom-bottom, top-center, center-center, bottom-centertop-bottom"center-bottom"
data-reveal-scrub
or data-aos-scrub
No value (experimental, see Scrub)off(no value)
data-reveal-staggerOn a parent: milliseconds between siblings revealed together. Empty means 80none"100"

Options reference #

Pass these to init(). Attributes on an element override the matching option for that element.

JavaScript
init({ duration: 900, once: false, disable: 'mobile' });
OptionTypeDefaultDescription
selectorstring'[data-reveal],[data-aos]'CSS selector of elements to reveal.
animationstring (animation name)'fade-up'Animation used when the attribute is empty (data-reveal).
durationnumber700Animation duration in ms.
delaynumber0Delay in ms.
easingstring'cubic-bezier(.16,1,.3,1)'Any CSS easing string, or a key registered in easings. Expo-out.
distancenumber16Travel in px for fade-* and zoom-*-<dir> animations.
blurnumber4Start blur in px (blur-to-sharp). 0 disables.
offsetnumber60Trigger line in px above the bottom of the viewport. AOS: 120.
rootMarginstringnoneRaw IntersectionObserver rootMargin; overrides offset (but not per-element data-*-offset). Derived from offset.
anchorPlacementstring (anchor placement)'top-bottom'Default anchor placement.
oncebooleantrueReveal only once. AOS default is false.
initialbooleanfalseAlso animate elements that are already on screen at init. Otherwise they are simply shown, without animation.
observebooleantrueWatch the DOM (MutationObserver, added subtrees only) for added/removed elements.
scrubbooleanfalseExperimental: 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.
disableboolean | 'mobile' | (() => boolean)falseDisable 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:

HTML
<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:

HTML
<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:

JavaScript
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.

HTML
<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:

JavaScript
init({ disable: () => document.body.classList.contains('no-motion') });

JavaScript API #

init(), refresh() and destroy() #

JavaScript
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 again

You 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:

JavaScript
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:

JavaScript
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) #

HTML
<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 #

PHP
// 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 #

JavaScript
import 'ghost-reveal/auto';          // zero config, or:
import { init } from 'ghost-reveal';
init({ blur: 6 });

React #

Call it once near the root.

JSX
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) #

TSX
// 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) #

JavaScript
// 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 automatically

Nuxt 3/4 #

TypeScript
// plugins/ghost-reveal.client.ts
import { init } from 'ghost-reveal';
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hook('app:mounted', () => init());
});

Svelte 5 / SvelteKit #

Svelte
<!-- 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.

Astro
---
// src/layouts/Layout.astro
---
<html lang="en">
  <body>
    <slot />
    <script>
      import 'ghost-reveal/auto';
    </script>
  </body>
</html>

Angular (17+) #

TypeScript
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)

HTML
<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)

HTML
<!-- 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-aos animation names and data-aos-duration, -delay, -easing, -offset, -once and -anchor-placement
  • AOS.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.
  • once defaults to true (AOS: false) and offset to 60 (AOS: 120).
  • Elements already on screen at load are shown, not animated. Use initial: true to animate them.
  • There are no aos-init/aos-animate classes. Listen for the reveal:in and reveal:out events 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 #

JavaScript
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, transform and filter are 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:

HTML
<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-auto is on the script-tag build. With modules, use import 'ghost-reveal/auto' or call init().
  • disable or a custom selector may 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:

CSS
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.

Back to top