Skip to content

Components / Scroll

Scroll Reveal

Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation. Server HTML stays readable without JavaScript.

Live demo

from="clip"

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

1.3 kB raw

718 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 3.0 kB raw.

CSS 6.7 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser. Elsewhere: scrub uses animation-timeline: view() where it exists and, everywhere else, a scroll listener that measures the same entry range; from="clip" wipes with mask-size (Chrome 120, Firefox 53, Safari 15.4) and simply appears before that.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Reveal from '@moonarc/core/Reveal';
---
<Reveal from="bottom" distance={24}>
  <h2>Appears once, stays.</h2>
</Reveal>

<Reveal as="ul" stagger={60}>
  <li>one</li><li>two</li><li>three</li>
</Reveal>

<Reveal from="clip">
  <img src="/cover.jpg" alt="" />
</Reveal>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/reveal.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts and add the JS gate to your head.

Reveal.astro
---
/**
 * Reveal — scroll-triggered entrance that survives <ClientRouter />.
 *
 * Play once and stay (or replay), per-child stagger, scroll-linked scrub with
 * a real fallback, a reduced-motion branch that keeps the fade and drops the
 * travel, and server HTML that is fully readable when JS never runs.
 *
 * Content is hidden only under html[data-ma-js] (set before paint by the
 * integration), never in the server HTML itself.
 *
 * Curve, duration, travel and stagger default to the active preset
 * (--ma-ease, --ma-duration, --ma-travel-enter, --ma-stagger); a prop sets a
 * component-local property (--ma-r-*) that wins over the preset. The delay
 * and the per-child index are --ma-r-delay and --ma-r-i: as bare --ma-delay
 * and --ma-i they were inherited from any other component that set them.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

type From = 'bottom' | 'top' | 'left' | 'right' | 'clip' | 'none';

interface Props extends HTMLAttributes<'div'> {
  /** Element to render. */
  as?: HTMLTag;
  /** Where the element travels from; `clip` wipes it open from the bottom edge, fully painted; `none` fades only. */
  from?: From;
  /** Travel distance in px. Unset, the preset's --ma-travel-enter, from whichever side. */
  distance?: number;
  /** Scale to grow from, e.g. 0.96. */
  scale?: number;
  /** Blur in px to sharpen from. */
  blur?: number;
  /** Duration in ms. */
  duration?: number;
  /** Delay in ms before the reveal starts. */
  delay?: number;
  /** Any CSS easing. */
  easing?: string;
  /** Interval in ms between direct children; `true` takes the preset's --ma-stagger. When set, the children animate and the wrapper does not. */
  stagger?: number | boolean;
  /** Play once and stay. `false` re-plays every time the element enters. */
  once?: boolean;
  /** Fraction of the element that must be visible to trigger, 0–1; for an element taller than the viewport, that fraction of the viewport's height. */
  threshold?: number;
  /** IntersectionObserver rootMargin. */
  rootMargin?: string;
  /** Scroll-linked: progress follows the element's entry instead of triggering once. Ignores stagger. */
  scrub?: boolean;
}

const {
  as: Tag = 'div',
  from = 'bottom',
  distance,
  scale,
  blur,
  duration,
  delay,
  easing,
  stagger,
  once = true,
  threshold,
  rootMargin,
  scrub = false,
  class: className,
  style,
  ...rest
} = Astro.props;

// Only non-default values become inline custom properties; defaults live in the CSS. An unset distance is the preset's
// travel from any side: it used to follow the preset from the bottom only (the CSS default) and be pinned at 24 px from
// the top, the left and the right.
const vars: string[] = [];
const travel = distance === undefined ? 'var(--ma-travel-enter, 24px)' : `${distance}px`;
if (from === 'left' || from === 'right') vars.push(`--ma-dx:${from === 'left' ? `calc(-1 * ${travel})` : travel}`, '--ma-dy:0px');
else if (from === 'top') vars.push(`--ma-dy:calc(-1 * ${travel})`);
else if (from !== 'bottom') vars.push('--ma-dy:0px');
else if (distance !== undefined) vars.push(`--ma-dy:${travel}`);
if (scale !== undefined && scale !== 1) vars.push(`--ma-scale:${scale}`);
if (blur) vars.push(`--ma-blur:${blur}px`);
if (duration !== undefined) vars.push(`--ma-r-dur:${duration}ms`);
if (delay) vars.push(`--ma-r-delay:${delay}ms`);
if (easing) vars.push(`--ma-r-ease:${easing}`);
// `stagger` alone (true) leaves the interval to the preset's --ma-stagger; it used to print --ma-r-stagger:truems
if (typeof stagger === 'number' && stagger) vars.push(`--ma-r-stagger:${stagger}ms`);
if (typeof style === 'string' && style) vars.push(style);
---

<Tag
  class:list={['ma-reveal', className]}
  data-ma-reveal
  data-once={once ? undefined : 'false'}
  data-stagger={stagger ? '' : undefined}
  data-scrub={scrub ? '' : undefined}
  data-from={from === 'clip' ? 'clip' : undefined}
  data-threshold={threshold}
  data-root-margin={rootMargin}
  style={vars.length ? vars.join(';') : undefined}
  {...rest}
>
  <slot />
</Tag>

<style is:global>
  @layer components {
    /* Hidden state — gated on html[data-ma-js] so server HTML stays readable. */
    :where([data-ma-js] .ma-reveal:not([data-stagger]):not([data-scrub]):not(.ma-in)),
    :where([data-ma-js] .ma-reveal[data-stagger]:not([data-scrub]):not(.ma-in) > *) {
      opacity: 0;
      translate: var(--ma-dx, 0px) var(--ma-dy, var(--ma-travel-enter, 24px));
      scale: var(--ma-scale, 1);
      filter: blur(var(--ma-blur, 0px));
    }

    :where(.ma-reveal:not([data-stagger]):not([data-scrub])),
    :where(.ma-reveal[data-stagger]:not([data-scrub]) > *) {
      transition-property: opacity, translate, scale, filter, mask-size;
      transition-duration: var(--ma-r-dur, var(--ma-duration, 450ms));
      transition-timing-function: var(--ma-r-ease, var(--ma-ease, ease-out));
      transition-delay: var(--ma-r-delay, 0ms);
    }
    :where(.ma-reveal[data-stagger]:not([data-scrub]) > *) {
      transition-delay: calc(var(--ma-r-delay, 0ms) + var(--ma-r-i, 0) * var(--ma-r-stagger, var(--ma-stagger, 60ms)));
    }
    /* from="clip": the element is fully painted and wipes open from the bottom — no fade, no travel. A mask, not
       clip-path, on purpose: Chromium's IntersectionObserver applies the target's own clip-path, so an element
       clipped to nothing never intersects and would never reveal. The mask is not part of the intersection. */
    :where(.ma-reveal[data-from='clip']:not([data-stagger]):not([data-scrub])),
    :where(.ma-reveal[data-from='clip'][data-stagger]:not([data-scrub]) > *) {
      mask-image: linear-gradient(#000 0 0);
      mask-repeat: no-repeat;
      mask-position: 0 100%;
      mask-size: 100% 100%;
    }
    :where([data-ma-js] .ma-reveal[data-from='clip']:not([data-stagger]):not([data-scrub]):not(.ma-in)),
    :where([data-ma-js] .ma-reveal[data-from='clip'][data-stagger]:not([data-scrub]):not(.ma-in) > *) {
      opacity: 1;
      translate: 0 0;
      mask-size: 100% 0%;
    }
    /* Leaving (once=false): out fast, no stagger — symmetry would read as a wait. */
    :where(.ma-reveal[data-leaving]),
    :where(.ma-reveal[data-leaving] > *) {
      transition-duration: var(--ma-duration-fast);
      transition-delay: 0ms;
    }

    /* Scrub: scroll-linked in browsers with view timelines … */
    @supports (animation-timeline: view()) {
      :where([data-ma-js] .ma-reveal[data-scrub]) {
        animation: ma-reveal linear both;
      }
      /* Separate rule with a different (equivalent) selector on purpose:
         lightningcss folds animation-timeline into the `animation` shorthand,
         and browsers reject that declaration because the shorthand cannot set
         a timeline. Keeping the selectors distinct prevents the merge. */
      :where([data-ma-js]) :where(.ma-reveal[data-scrub]) {
        animation-timeline: view();
        animation-range: var(--ma-range, entry 0% entry 100%);
      }
    }
    /* … and driven by a scroll listener writing --ma-p everywhere else: a raw ratio, clamped here to 0–1. */
    :where([data-ma-js] .ma-reveal[data-scrub][data-ma-fallback]) {
      opacity: clamp(0, var(--ma-p, 1), 1);
      translate: calc(var(--ma-dx, 0px) * (1 - clamp(0, var(--ma-p, 1), 1))) calc(var(--ma-dy, var(--ma-travel-enter, 24px)) * (1 - clamp(0, var(--ma-p, 1), 1)));
      transition: opacity 120ms linear, translate 120ms linear;
    }

    /* Reduced motion: keep the fade, drop the travel. Declared, not overridden
       through the custom properties, because inline style would win. */
    @media (prefers-reduced-motion: reduce) {
      :where([data-ma-js] .ma-reveal:not(.ma-in)),
      :where([data-ma-js] .ma-reveal[data-stagger]:not(.ma-in) > *) {
        translate: 0 0;
        scale: 1;
        filter: none;
      }
      /* clip becomes the fade everything else gets */
      :where([data-ma-js] .ma-reveal[data-from='clip']:not(.ma-in)),
      :where([data-ma-js] .ma-reveal[data-from='clip'][data-stagger]:not(.ma-in) > *) {
        mask-size: 100% 100%;
        opacity: 0;
      }
      :where(.ma-reveal),
      :where(.ma-reveal > *) {
        transition-duration: var(--ma-duration-fast);
        transition-delay: 0ms;
      }
      :where([data-ma-js] .ma-reveal[data-scrub]) {
        animation: none;
        opacity: 1;
        translate: 0 0;
      }
    }

    /* Print: the end state, with no transition into it. Switching to print media is a style change like any other, so
       a transition ran from the hidden state and the page printed at its first frame (opacity 0). */
    @media print {
      :where(.ma-reveal),
      :where(.ma-reveal > *) {
        opacity: 1;
        translate: none;
        scale: none;
        filter: none;
        mask-image: none;
        animation: none;
        transition: none;
      }
    }
  }

  @keyframes ma-reveal {
    from {
      opacity: 0;
      translate: var(--ma-dx, 0px) var(--ma-dy, var(--ma-travel-enter, 24px));
      scale: var(--ma-scale, 1);
    }
    to {
      opacity: 1;
      translate: 0 0;
      scale: 1;
    }
  }
</style>

<script>
  import { onMount } from '../lib/runtime';

  const NATIVE_SCRUB = typeof CSS !== 'undefined' && CSS.supports('animation-timeline: view()');

  onMount<HTMLElement>('[data-ma-reveal]', (el, { signal }) => {
    const targets = el.hasAttribute('data-stagger') ? (Array.from(el.children) as HTMLElement[]) : [el];
    if (targets.length > 1) targets.forEach((t, i) => t.style.setProperty('--ma-r-i', String(i)));

    if (el.hasAttribute('data-scrub')) {
      if (NATIVE_SCRUB) return;
      el.dataset.maFallback = '';
      // The entry range view() measures: 0 while the top is below the viewport, 1 once the element has come in by its
      // own height or the viewport's, whichever is less, and 1 from then on (the CSS clamps the ratio to that range).
      // The intersection ratio this used to read never reached 1 for an element taller than the viewport, and fell
      // again as the element left at the top. Capture hears a scroller inside the page as well.
      const set = () => {
        const r = el.getBoundingClientRect();
        el.style.setProperty('--ma-p', `${(innerHeight - r.top) / Math.min(r.height, innerHeight)}`);
      };
      set();
      for (const type of ['scroll', 'resize']) addEventListener(type, set, { signal, capture: true });
      return;
    }

    const once = el.dataset.once !== 'false';
    const done = () => once && el.classList.contains('ma-in');
    // will-change is a loan: taken when the reveal starts, repaid when the last target settles. Any property settles
    // it, since from="clip" moves mask-size and never opacity; an empty staggered group has no last target to wait for.
    const will = (v: string) => targets.forEach((t) => (t.style.willChange = v));
    // Revealed already and played once: an element persisted across a swap keeps its state, and repays a loan the swap
    // cut off before its transition ended. Observed again, it was hidden as soon as it was off screen and replayed on
    // the way back.
    if (done()) return will('');
    targets.at(-1)?.addEventListener('transitionend', () => will(''), { signal });

    // `threshold` is a share of the element, and a group taller than the screen may never show that share: eight cards
    // stacked on a phone are 3 000 px, and 20 % of them is more than a 568 px screen holds, so they never appeared.
    // Every observer's first entry says how tall the element is against the viewport; past it, the share becomes that
    // fraction of the viewport's own height and the observer is set again. A resize starts over.
    const share = Number(el.dataset.threshold ?? 0.2);
    let io: IntersectionObserver;
    const watch = (threshold = share) => {
      io?.disconnect();
      io = new IntersectionObserver(
        ([e]) => {
          const fit = Math.min(share, (share * innerHeight) / e!.boundingClientRect.height);
          if (fit < threshold) return watch(fit);
          if (e!.isIntersecting) {
            will('translate, opacity');
            delete el.dataset.leaving;
            el.classList.add('ma-in');
            if (once) io.disconnect();
          } else if (el.classList.contains('ma-in')) {
            el.dataset.leaving = '';
            el.classList.remove('ma-in');
          }
        },
        { threshold, rootMargin: el.dataset.rootMargin ?? '0px 0px -10% 0px' },
      );
      io.observe(el);
    };
    watch();
    addEventListener('resize', () => done() || watch(), { signal });
    signal.addEventListener('abort', () => io.disconnect());
  });
</script>

Props

PropTypeDefaultDescription
asHTMLTag'div'Element to render.
from'bottom' | 'top' | 'left' | 'right' | 'clip' | 'none''bottom'Where the element travels from. clip wipes the element open from its bottom edge (fully painted, no fade, no travel); none fades only.
distancenumberpreset (ui: 24)Travel distance in px. Unset, it follows the preset's --ma-travel-enter from whichever side it travels; set, it pins this instance.
scalenumbernoneScale to grow from, e.g. 0.96. Never below 0.9.
blurnumbernoneBlur in px to sharpen from. Costly on large elements; keep under 8.
durationnumberpreset (ui: 450)Duration in ms. Unset, it follows the active preset (data-ma-preset).
delaynumber0Delay in ms.
easingstringpreset curveAny CSS easing. Unset, it is the preset's sampled spring.
staggernumber | truenoneInterval in ms between direct children; the preset's --ma-stagger when set without a value. When set, the children animate instead of the wrapper. 30–80 ms reads well.
oncebooleantruePlay once and stay. false re-plays on every entry, leaving quickly and without stagger.
thresholdnumber0.2Fraction of the element that must be visible to trigger; for an element taller than the viewport, that fraction of the viewport's height.
rootMarginstring'0px 0px -10% 0px'IntersectionObserver rootMargin; the default waits until the element is 10% into the viewport.
scrubbooleanfalseScroll-linked: progress follows entry into the viewport. Uses animation-timeline: view() where supported; elsewhere a scroll listener measures the same entry range, so a tall element still reaches full and stays full as it leaves.

Reduced motion

Keeps a fast opacity fade, drops translate, scale, blur and stagger. Scrub becomes static.

With ClientRouter

Binds through the shared runtime: every instance on the new page is observed after each navigation, observers are disconnected before the swap, and the html[data-ma-js] gate is restored so nothing flashes. A once-reveal persisted across the swap (transition:persist) keeps its revealed state instead of being observed again.

Why it is built this way

Replaces: AOS · AnimatedContent (React Bits) · FadeContent (React Bits) · ScrollReveal (React Bits) · BlurText (React Bits) · motion.div whileInView. See the migration table.

Marquee

Ticker

Infinite scrolling strip for logos, quotes or tags in any direction.

every browseralwayshover

Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.

every browserscroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown