# Scroll Reveal (Moonarc)

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.

- Import: `import Reveal from '@moonarc/core/Reveal'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/reveal.json`
- Tier B · category scroll · trigger scroll
- Readout: `<Reveal stagger={60}>`
- Browser support: widely (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
- Measured cost: 1.3 kB raw JS · 718 B gzip · + runtime (with dependencies 3.0 kB raw; CSS 6.7 kB raw)
- Page: https://moonarc.dev/components/reveal/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `as` | `HTMLTag` | `'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. |
| `distance` | `number` | `preset (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. |
| `scale` | `number` | none | Scale to grow from, e.g. 0.96. Never below 0.9. |
| `blur` | `number` | none | Blur in px to sharpen from. Costly on large elements; keep under 8. |
| `duration` | `number` | `preset (ui: 450)` | Duration in ms. Unset, it follows the active preset (data-ma-preset). |
| `delay` | `number` | `0` | Delay in ms. |
| `easing` | `string` | `preset curve` | Any CSS easing. Unset, it is the preset's sampled spring. |
| `stagger` | `number | true` | none | Interval 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. |
| `once` | `boolean` | `true` | Play once and stay. false re-plays on every entry, leaving quickly and without stagger. |
| `threshold` | `number` | `0.2` | Fraction of the element that must be visible to trigger; for an element taller than the viewport, that fraction of the viewport's height. |
| `rootMargin` | `string` | `'0px 0px -10% 0px'` | IntersectionObserver rootMargin; the default waits until the element is 10% into the viewport. |
| `scrub` | `boolean` | `false` | Scroll-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. |

## Usage

```astro
<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>
```

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

## Craft

- Curve, duration, travel and stagger resolve from the active preset, so one attribute on <html> retunes every reveal on the page; a prop pins one instance.
- 24 px of travel is visible without reading as a slide; scale never below 0.9.
- Stagger 30–80 ms; cap the group: ten items at 60 ms is 600 ms of waiting.
- Leaving (once=false) is fast and unstaggered so re-entry never fights the reader.
- Hidden state is gated on html[data-ma-js]; without JS the server HTML is the end state.
- from="clip" is a wipe, not a fade: the element is painted in full and a mask grows from its bottom edge to full height on the same preset curve. An image or a card appears as if uncovered. It is a mask rather than clip-path because Chromium's IntersectionObserver applies the target's clip-path: an element clipped to nothing never intersects, so it would never reveal.

## Replaces

- AOS
- AnimatedContent (React Bits)
- FadeContent (React Bits)
- ScrollReveal (React Bits)
- BlurText (React Bits)
- motion.div whileInView

## Source

```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>

```
