# Animation libraries for Astro, measured

> The same scroll reveal built with 6 animation libraries in 9 setups on Astro 7.3.2, measured on 2026-09-28 in Chromium, Firefox and WebKit. Without `ClientRouter`, 8 of the 9 setups animate; astro-reveal (default mode) does not, for a reason its card gives. With `ClientRouter`, 2 setups animate on every page: Moonarc (1.2 kB gzip of JavaScript) and Motion for React in a React island (108.2 kB gzip of JavaScript). The other 7 setups lose the reveal on one page or more; each card gives the cause, and the fix where one was measured.

Measured on 2026-09-28. Page: https://moonarc.dev/compare/

The test effect is a scroll reveal: a heading and a paragraph that fade in and rise as they come into view. We built it with 6 libraries in 9 setups, each in its own Astro 7.3.2 project and set up the way the library's README or documentation shows. We measured the JavaScript each page loads and watched each reveal in Chromium, Firefox and WebKit.

Without `ClientRouter`, 8 of the 9 setups animate; astro-reveal (default mode) does not, for a reason its card gives. With `ClientRouter`, 2 setups animate on every page: Moonarc (1.2 kB gzip of JavaScript) and Motion for React in a React island (108.2 kB gzip of JavaScript). The other 7 setups lose the reveal on one page or more; each card gives the cause, and the fix where one was measured.

## The same scroll reveal, 9 setups

Sizes are gzip, for every script and stylesheet the built page loads, inline ones included. "Animates" means the heading was seen moving as it came into view. "Visible, no reveal" means it ended visible, and no fade or movement was seen while it came into view. "Stays hidden" means its opacity ended below 0.5. A check marks the result you want, a dash one with a caveat its card explains, and a cross content left hidden.

| Library | JS, one reveal | JS, ten reveals | CSS, one reveal | Without ClientRouter | With ClientRouter | Reduced motion | JavaScript off |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Moonarc 0.1.0 | 1.2 kB gzip | 1.2 kB gzip | 1.7 kB gzip | animates | animates on every page | fades, no travel | content visible |
| AstroAnimate 0.1.2 | 1.5 kB gzip | 1.9 kB gzip | 343 B gzip | animates | animates on the first page only; later pages: visible, no reveal | visible, no animation | content visible |
| astro-reveal (default mode) 0.3.1 | 0 B | 0 B | 766 B gzip | visible, no reveal | every page: visible, no reveal | visible, no animation | content visible |
| astro-reveal (observer mode) 0.3.1 | 880 B gzip | 880 B gzip | 766 B gzip | animates | first page: stays hidden; later pages: visible, no reveal | visible, no animation | content visible |
| AOS 2.3.4 | 4.8 kB gzip | 4.8 kB gzip | 2.2 kB gzip | animates | animates on the first page only; later pages: visible, no reveal | full reveal, with travel | content hidden |
| GSAP with ScrollTrigger 3.15.0 | 43.0 kB gzip | 43.0 kB gzip | 0 B | animates | animates on the first page only; later pages: visible, no reveal | full reveal, with travel | content visible |
| Motion 13.4.4 | 18.7 kB gzip | 18.7 kB gzip | 62 B gzip | animates | animates on the first page only; later pages: stays hidden | full reveal, with travel | content hidden |
| Motion (mini animate) 13.4.4 | 3.8 kB gzip | 3.8 kB gzip | 62 B gzip | animates | animates on the first page only; later pages: stays hidden | full reveal, with travel | content hidden |
| Motion for React in a React island 13.4.4 | 108.2 kB gzip | 108.2 kB gzip | 66 B gzip | animates | animates on every page | full reveal, with travel | content hidden |

Measured in Chromium 153.0.8010.12, Firefox 155.0 and WebKit 26.6. A result that differs between engines names them.

## Each result, its cause, and when to pick the library

`ClientRouter` is the router behind Astro's view transitions: with it, every page a visitor reaches by a link after the first is a navigation. After a navigation, Astro does not run a bundled script again, and skips an inline script whose text already ran ([Astro's docs](https://docs.astro.build/en/guides/view-transitions/#script-re-execution); `transitions/swap-functions.js` in Astro 7.3.2). A reveal set up once, when its script first runs, therefore binds the first page only, unless something sets it up again on every page.

Each card traces its results to the library's code, to Astro's or Vite's, or to a measurement, gives the measured fix where there is one, and says when to pick that library.

### Moonarc

`@moonarc/core` 0.1.0; packed from its repository; not on npm yet. One reveal: 1.2 kB gzip of JavaScript, 1.7 kB gzip of CSS. Ten: 1.2 kB gzip. On a page with one component, `Reveal` and the runtime ship as one inline script. With more components the runtime is a file of its own, and `Reveal` with it measures 1.5 kB gzip (3.0 kB raw, the figure on its component page). The CSS is one stylesheet: the integration's tokens and `Reveal`'s rules.

- Without ClientRouter: animates.
- With ClientRouter: animates on every page. `Reveal` binds through the shared runtime, which attaches again after every swap and cleans up before the next one.
- Reduced motion: fades, no travel. The reduced-motion branch keeps a fast opacity fade and drops the travel.
- JavaScript off: content visible. The integration hides entrance effects only behind `data-ma-js`, an attribute its inline script sets before paint, so a page whose JavaScript never runs shows all its content.
- Pick Moonarc when you want reveals that keep working across `ClientRouter` navigations with no lifecycle code of your own, and more effects at a stated cost each: 102 primitives, 67 of them load no JavaScript. For timelines and scroll scenes, pick GSAP; for springs and gestures, pick Motion.
- Sources: [Reveal](https://moonarc.dev/components/reveal/), [How the runtime rebinds](https://moonarc.dev/docs/view-transitions/)

### AstroAnimate

`@astroanimate/core` 0.1.2, released 2026-05-23. One reveal: 1.5 kB gzip of JavaScript, 343 B gzip of CSS. Ten: 1.9 kB gzip. `FadeInText` with `enhance` writes its script inline with every instance: ten reveals put 39.0 kB raw of script in the page, which the browser parses and runs ten times. Gzipped together, as they travel in the page, the nine extra copies add 350 B gzip.

- Install on Astro 7: AstroAnimate 0.1.2 declares `astro ^4.0.0 || ^5.0.0 || ^6.0.0`, so `npm install astro@7.3.2 @astroanimate/core@0.1.2` stops with ERESOLVE. With `--legacy-peer-deps` it installs and builds, and every result here comes from that install.
- Without ClientRouter: animates.
- With ClientRouter: animates on the first page only; later pages: visible, no reveal. The script is the same text on every page, and `ClientRouter` does not run an inline script again once one with the same text has run. On the next page the content fades in through the component's 2 s CSS fallback, in view or not. The script does not carry `data-astro-rerun`, the attribute Astro's docs give for running an inline script after every navigation, and a page cannot add it.
- Reduced motion: visible, no animation. `FadeInText`'s own CSS shows every state at its end under `prefers-reduced-motion: reduce`.
- JavaScript off: content visible. The content fades in through the 2 s CSS fallback, about 2.6 s after the page loads.
- Pick AstroAnimate when you want its ready-made components (GlassCard, ExpandableCard, Dock, AnimatedTabs and others in 0.1.2) on Astro 4, 5 or 6, which its peer range covers.
- Sources: [@astroanimate/core on npm](https://www.npmjs.com/package/@astroanimate/core), [astroanimate/astroanimate](https://github.com/astroanimate/astroanimate)

### astro-reveal (default mode)

`astro-reveal` 0.3.1, released 2026-07-01. One reveal: 0 B of JavaScript, 766 B gzip of CSS. Ten: 0 B.

- Without ClientRouter: visible, no reveal. The default mode is CSS scroll-driven animation. In an Astro 7.3.2 production build, Vite 8.3.1 minifies the CSS with lightningcss 1.33.0, and the built rule reads `:where(html:not(.reveal-js)) [data-reveal]{animation:none;animation-range:entry calc(15% + var(--reveal-index) * 4%) cover calc(50% + var(--reveal-index) * 4%)}`: the timeline is gone, so the reveal does not run. This is a lightningcss bug ([parcel-bundler/lightningcss#1342](https://github.com/parcel-bundler/lightningcss/issues/1342), open) that Astro closed as upstream ([withastro/astro#17940](https://github.com/withastro/astro/issues/17940)). With `vite: { build: { cssMinify: 'esbuild' } }` in the Astro config the rule keeps `animation-timeline: view()`, and the reveal animates in Chromium and WebKit and shows the content with no reveal in Firefox (measured). Firefox 155.0 does not support `animation-timeline: view()`, and there the mode shows the content without animation by design.
- With ClientRouter: every page: visible, no reveal. The same stylesheet serves every page, so every page gives the same result.
- Reduced motion: visible, no animation. The animation sits inside `prefers-reduced-motion: no-preference`.
- JavaScript off: content visible. The mode needs no script.
- Pick astro-reveal's default mode when you want a scroll-linked reveal with no JavaScript that plays in reverse on the way up, in a build that keeps its timeline: on Astro 7, with `cssMinify: 'esbuild'`.
- Sources: [astro-reveal on npm](https://www.npmjs.com/package/astro-reveal), [lightningcss#1342](https://github.com/parcel-bundler/lightningcss/issues/1342), [The same pitfall in our docs](https://moonarc.dev/docs/performance/)

### astro-reveal (observer mode)

`astro-reveal` 0.3.1, released 2026-07-01. One reveal: 880 B gzip of JavaScript, 766 B gzip of CSS. Ten: 880 B gzip.

- Without ClientRouter: animates. The observer reveals the content once.
- With ClientRouter: first page: stays hidden; later pages: visible, no reveal. Astro fires `astro:page-load` on the first load too. The runtime sets itself up when its module loads and again on that event, and the second setup disconnects the first IntersectionObserver, makes a new one and skips every element the first one tracked (astro-reveal 0.3.1, `dist/runtime/observer.js`). Content not already revealed stays at `opacity: 0`. After a navigation the swap replaces the attributes of `<html>` and drops the `reveal-js` class the head script set, so the runtime stands down and the default mode's CSS applies, with the result in the row above.
- Reduced motion: visible, no animation. The runtime stands down, and the hidden state sits inside `prefers-reduced-motion: no-preference`.
- JavaScript off: content visible. The class that hides the content comes from a script, so without one nothing is hidden.
- Pick astro-reveal's observer mode when your pages do not use `ClientRouter` and you want a reveal that plays once, for 880 B gzip of JavaScript.
- Sources: [astro-reveal on npm](https://www.npmjs.com/package/astro-reveal)

### AOS

`aos` 2.3.4, released 2018-10-03; its last beta, 3.0.0-beta.6, came out the same day. One reveal: 4.8 kB gzip of JavaScript, 2.2 kB gzip of CSS. Ten: 4.8 kB gzip.

- Without ClientRouter: animates.
- With ClientRouter: animates on the first page only; later pages: visible, no reveal. `AOS.init()` runs once, in the layout's script. Its MutationObserver finds the next page's elements, but the new `<body>` lacks the `data-aos-*` attributes `init()` wrote on the first one, and AOS's CSS reads its durations from them, so the elements show with a 0 s transition. With those attributes written on `<body>` in the layout (`data-aos-easing="ease" data-aos-duration="400" data-aos-delay="0"`, AOS's defaults), one `init()` animates on every page (measured). Calling `init()` on every `astro:page-load` instead also animates on every page, and each call adds scroll, resize and orientationchange listeners and a MutationObserver.
- Reduced motion: full reveal, with travel. AOS leaves reduced motion to its `disable` option; [michalsnik/aos#611](https://github.com/michalsnik/aos/issues/611), open since 2020-08-24, asks for that to be documented.
- JavaScript off: content hidden. AOS 2.3.4's stylesheet starts `fade` and `zoom` elements at `opacity: 0` until its script runs. [michalsnik/aos#81](https://github.com/michalsnik/aos/issues/81) reported it in 2016; the maintainer closed it in 2018 after moving the styles under `html:not(.no-js)` for 3.0, which has had no stable release.
- Pick AOS when you want one attribute per element and a list of named effects. With `ClientRouter`, write its attributes on `<body>` in the layout, as measured above.
- Sources: [AOS in Astro, the guide](https://moonarc.dev/resources/guides/aos-alternative-for-astro/), [michalsnik/aos#81](https://github.com/michalsnik/aos/issues/81), [michalsnik/aos#611](https://github.com/michalsnik/aos/issues/611)

### GSAP with ScrollTrigger

`gsap` 3.15.0, released 2026-04-13. One reveal: 43.0 kB gzip of JavaScript, 0 B of CSS. Ten: 43.0 kB gzip.

- Without ClientRouter: animates.
- With ClientRouter: animates on the first page only; later pages: visible, no reveal. `gsap.from()` runs once, in the layout's script, so the next page's elements get no tween and show at once. Created in a `gsap.context()` on every `astro:page-load` and reverted on `astro:before-swap`, as GSAP's Rodrigo advises Astro users ([GSAP forum, 2024-07-06](https://gsap.com/community/forums/topic/41197-astro-viewtransitions-breaks-scrolltrigger-the-second-time-i-enter-a-page/)), the reveal animates on every page (measured). Moonarc's `gsapPage` adapter does the same.
- Reduced motion: full reveal, with travel. GSAP leaves reduced motion to the developer, through `gsap.matchMedia()`.
- JavaScript off: content visible. `gsap.from()` never ran, so the content stays as the HTML has it.
- Pick GSAP when you need timelines, pinned or scrubbed scroll scenes, or its plugins. With `ClientRouter`, set it up per page as measured above, or wrap the setup in `gsapPage` from `@moonarc/core/gsap`.
- Sources: [GSAP forum answer](https://gsap.com/community/forums/topic/41197-astro-viewtransitions-breaks-scrolltrigger-the-second-time-i-enter-a-page/), [gsap.matchMedia()](https://gsap.com/docs/v3/GSAP/gsap.matchMedia()), [GSAP with ClientRouter, the guide](https://moonarc.dev/resources/guides/gsap-scrolltrigger-astro-view-transitions/)

### Motion

`motion` 13.4.4, released 2026-09-25. One reveal: 18.7 kB gzip of JavaScript, 62 B gzip of CSS. Ten: 18.7 kB gzip. This setup imports the hybrid `animate()` from `motion`, 18 kB gzip in Motion's docs. The next row imports the mini `animate()` from `motion/mini`, 2.3 kB gzip in its docs, which animates `opacity` and `transform` too.

- Without ClientRouter: animates.
- With ClientRouter: animates on the first page only; later pages: stays hidden. `inView()` runs once, in the layout's script, and watches the elements that exist then. The next page's elements keep the `opacity: 0` this setup's CSS starts them at: Motion's `inView` example animates to `opacity: 1` and leaves the starting style to the page. With `inView()` called on every `astro:page-load` and its stop function on `astro:before-swap`, the reveal animates on every page (measured).
- Reduced motion: full reveal, with travel.
- JavaScript off: content hidden. The CSS that starts the elements hidden has no script to undo it.
- Pick Motion when you need springs, gestures or scroll-linked animation in plain JavaScript, or layout animation in React.
- Sources: [animate()](https://motion.dev/docs/animate), [inView()](https://motion.dev/docs/inview)

### Motion (mini animate)

`motion` 13.4.4, released 2026-09-25. One reveal: 3.8 kB gzip of JavaScript, 62 B gzip of CSS. Ten: 3.8 kB gzip. `animate()` from `motion/mini` runs on the browser's Web Animations API; `inView()` still comes from `motion`.

- Without ClientRouter: animates.
- With ClientRouter: animates on the first page only; later pages: stays hidden. Set up the same way as the row above, with the same result.
- Reduced motion: full reveal, with travel.
- JavaScript off: content hidden. The same CSS start as the row above.
- Pick Motion's mini `animate()` when `opacity` and `transform` are all you animate: it is the smallest way to use Motion measured here.
- Sources: [animate(), mini](https://motion.dev/docs/animate)

### Motion for React in a React island

`motion` 13.4.4, released 2026-09-25. One reveal: 108.2 kB gzip of JavaScript, 66 B gzip of CSS. Ten: 108.2 kB gzip.

- Without ClientRouter: animates.
- With ClientRouter: animates on every page. Each page mounts its own islands, so the reveal runs after every navigation.
- Reduced motion: full reveal, with travel. Motion for React follows the reduced-motion setting inside `<MotionConfig reducedMotion="user">`; its default is `"never"`.
- JavaScript off: content hidden. The server renders the island at its `initial` style, `opacity: 0`, and only hydration shows it.
- Pick Motion for React in a React island when your pages already hydrate React, which leaves Motion for React and the island to load, or when you want a component from a React library such as React Bits, Magic UI or Aceternity as it is.
- Sources: [MotionConfig](https://motion.dev/docs/react-motion-config), [The React Bits migration table](https://moonarc.dev/docs/migrating-from-react-bits/)

## The same hero, with React

We built one hero twice on this site: with Moonarc components, and as a React island with Motion for React 13.4.4, the version the table measured. The budget harness that gates every release measures both pages. Both also load the site's own scripts (the router, search, the theme and motion toggles, the byte counter), so the figures below count what each hero adds.

- Moonarc: 1.5 kB gzip of hero JavaScript: `Reveal`, the runtime and the integration's inline script, as the catalogue measures them. SplitText, RotatingText, Marquee and Aurora are CSS. The whole page loads 9.7 kB gzip of JavaScript. https://moonarc.dev/compare/moonarc/
- React + Motion for React: 110.5 kB gzip of hero JavaScript: React and ReactDOM, 67.4 kB gzip; Motion for React with the hero component, 41.2 kB gzip; Astro's island scripts, 1.9 kB gzip. The whole page loads 119.4 kB gzip of JavaScript. https://moonarc.dev/compare/react/

The React page renders the headline at `opacity: 0` and shows it once React, ReactDOM and Motion for React load and the island hydrates. The Moonarc page animates the headline, the rotating word and the marquee in CSS, and runs one IntersectionObserver for the reveal. If your pages already load React, the island adds Motion for React, Astro's island scripts and your component; if they do not, it adds React and ReactDOM as well. [The React Bits migration table](https://moonarc.dev/docs/migrating-from-react-bits/) maps each React Bits, Aceternity and Magic UI component to one here, with the byte difference.

## Questions, answered from the measurements

### Which animation library for Astro loads the least JavaScript?

For one scroll reveal on Astro 7.3.2, gzip: astro-reveal (default mode) 0 B (its reveal did not run), astro-reveal (observer mode) 880 B gzip, Moonarc 1.2 kB gzip, AstroAnimate 1.5 kB gzip, Motion (mini animate) 3.8 kB gzip, AOS 4.8 kB gzip, Motion 18.7 kB gzip, GSAP with ScrollTrigger 43.0 kB gzip, and Motion for React in a React island 108.2 kB gzip. With ten reveals, AstroAnimate grows to 1.9 kB gzip; the others stay the same. The smallest that animates on every page with `ClientRouter` is Moonarc, at 1.2 kB gzip.

### Does AOS work with Astro view transitions?

AOS 2.3.4 animates on a page without `ClientRouter`, the router behind Astro's view transitions. With it, AOS animates on the first page, and on later pages shows the content with no reveal, because the new `<body>` lacks the attributes `AOS.init()` wrote on the first one. With those attributes written on `<body>` in the layout, it animates on every page. Measured on 2026-09-28 in Chromium, Firefox and WebKit.

### Does GSAP ScrollTrigger work with Astro's ClientRouter?

Yes, when the tweens are made again for every page. A `gsap.from()` set up once in a layout's script animates on the first page, and on later pages shows the content with no reveal, because Astro does not run that script again after a navigation. Made in a `gsap.context()` on every `astro:page-load` and reverted on `astro:before-swap`, the reveal animates on every page. Moonarc's `gsapPage` adapter does that for you.

### Can I use Framer Motion in Astro without React?

Framer Motion is now Motion for React, imported from `motion/react`. It needs React: in Astro it runs in a React island, and a page with one reveal loaded 108.2 kB gzip of JavaScript. Motion's JavaScript API, in the same `motion` package, runs without React: the same reveal loaded 18.7 kB gzip with the hybrid `animate()`, and 3.8 kB gzip with `animate()` from `motion/mini`.

### Which of these libraries respect prefers-reduced-motion?

With `prefers-reduced-motion: reduce`, Moonarc fades without travel, AstroAnimate shows the content with no animation, astro-reveal (default mode) shows the content with no animation, and astro-reveal (observer mode) shows the content with no animation. AOS, GSAP with ScrollTrigger, Motion, Motion (mini animate), and Motion for React in a React island play the full reveal by default: GSAP leaves it to `gsap.matchMedia()`, Motion for React to `<MotionConfig reducedMotion="user">`, and AOS to its `disable` option.

### What do visitors see with JavaScript turned off?

AOS, Motion, Motion (mini animate), and Motion for React in a React island leave the content hidden: AOS's stylesheet starts it at `opacity: 0`, the Motion setups start it hidden in their CSS, and the React island renders its `initial` style. The other setups show the content.

## How it was measured

We built one Astro 7.3.2 project per setup, with static output, and installed each with npm 11.19.0 and `--ignore-scripts --legacy-peer-deps`. Moonarc 0.1.0 was packed from its repository, because it is not on npm yet. A library that needs a script gets it in a component every page includes, the way a layout holds a site-wide script. Bytes count every script and stylesheet the built page loads. Each file is gzipped on its own; the inline scripts are gzipped together, in page order, as they travel inside the HTML. 1 kB gzip is 1024 B gzip. A browser at 1280 × 800 waits 3 s after the page loads or swaps, longer than any fallback timer here. It then wheels down in 120 px steps until the heading is 40 % down the viewport, and reads it for 2 s. Each read takes the heading's effective opacity and its vertical offset. We read each setup on a page without ClientRouter, on a first page with it and after two navigations, under reduced motion, and with JavaScript off.

Astro 7.3.2, Vite 8.3.1, lightningcss 1.33.0, Node v24.20.0, npm 11.19.0. Browsers: Chromium 153.0.8010.12, Firefox 155.0, WebKit 26.6. The raw results, per engine: https://moonarc.dev/compare.json

The scope is one effect, a scroll reveal, on Astro 7.3.2. Maintain one of these libraries and see a setup or a result that is wrong? Write to hello@moonarc.dev and we run the harness again.

### Moonarc

`astro.config.mjs`:

```js
import moonarc from 'moonarc';
export default { integrations: [moonarc()] };
```

`src/pages/index.astro`:

```astro
---
import Reveal from '@moonarc/core/Reveal';
---
<Reveal><h2>Heading</h2><p>…</p></Reveal>
```

### AstroAnimate

`src/pages/index.astro`:

```astro
---
import FadeInText from '@astroanimate/core/FadeInText';
---
<FadeInText enhance><h2>Heading</h2><p>…</p></FadeInText>
```

### astro-reveal (default mode)

`astro.config.mjs`:

```js
import reveal from 'astro-reveal';
export default { integrations: [reveal()] };
```

`src/pages/index.astro`:

```astro
---
import Reveal from 'astro-reveal/Reveal.astro';
---
<Reveal animation="up"><h2>Heading</h2><p>…</p></Reveal>
```

### astro-reveal (observer mode)

`astro.config.mjs`:

```js
import reveal from 'astro-reveal';
export default { integrations: [reveal({ mode: 'observer' })] };
```

`src/pages/index.astro`:

```astro
---
import Reveal from 'astro-reveal/Reveal.astro';
---
<Reveal animation="up"><h2>Heading</h2><p>…</p></Reveal>
```

### AOS

`src/components/Setup.astro`:

```astro
<script>
  import AOS from 'aos';
  import 'aos/dist/aos.css';
  AOS.init();
</script>
```

`src/pages/index.astro`:

```astro
---
import Setup from '../components/Setup.astro';
---
<div data-aos="fade-up"><h2>Heading</h2><p>…</p></div>
<Setup />
```

### GSAP with ScrollTrigger

`src/components/Setup.astro`:

```astro
<script>
  import { gsap } from 'gsap';
  import { ScrollTrigger } from 'gsap/ScrollTrigger';
  gsap.registerPlugin(ScrollTrigger);
  gsap.utils.toArray('.reveal').forEach((el) => {
    gsap.from(el, { opacity: 0, y: 24, duration: 0.6, scrollTrigger: { trigger: el, start: 'top 85%' } });
  });
</script>
```

`src/pages/index.astro`:

```astro
---
import Setup from '../components/Setup.astro';
---
<div class="reveal"><h2>Heading</h2><p>…</p></div>
<Setup />
```

### Motion

`src/components/Setup.astro`:

```astro
<style is:global>
  .reveal { opacity: 0; transform: translateY(24px); }
</style>
<script>
  import { animate, inView } from 'motion';
  inView('.reveal', (el) => {
    animate(el, { opacity: 1, transform: 'none' }, { duration: 0.6 });
  });
</script>
```

`src/pages/index.astro`:

```astro
---
import Setup from '../components/Setup.astro';
---
<div class="reveal"><h2>Heading</h2><p>…</p></div>
<Setup />
```

### Motion (mini animate)

`src/components/Setup.astro`:

```astro
<style is:global>
  .reveal { opacity: 0; transform: translateY(24px); }
</style>
<script>
  import { inView } from 'motion';
  import { animate } from 'motion/mini';
  inView('.reveal', (el) => {
    animate(el, { opacity: 1, transform: 'none' }, { duration: 0.6 });
  });
</script>
```

`src/pages/index.astro`:

```astro
---
import Setup from '../components/Setup.astro';
---
<div class="reveal"><h2>Heading</h2><p>…</p></div>
<Setup />
```

### Motion for React in a React island

`astro.config.mjs`:

```js
import react from '@astrojs/react';
export default { integrations: [react()] };
```

`src/components/RevealIsland.jsx`:

```jsx
import { motion } from 'motion/react';

export default function RevealIsland({ children }) {
  return (
    <motion.div initial={{ opacity: 0, y: 24 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.6 }}>
      {children}
    </motion.div>
  );
}
```

`src/pages/index.astro`:

```astro
---
import RevealIsland from '../components/RevealIsland.jsx';
---
<RevealIsland client:load><h2>Heading</h2><p>…</p></RevealIsland>
```
