Compare · measured 2026-09-28
Animation libraries for Astro, measured
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 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.
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
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.
Why ClientRouter changes the result
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; 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.
Moonarc
@moonarc/core0.1.0; packed from its repository; not on npm yet1.2 kB gzip of JavaScript for one reveal · 1.2 kB gzip for ten · 1.7 kB gzip of CSS
On a page with one component,
Revealand the runtime ship as one inline script. With more components the runtime is a file of its own, andRevealwith 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 andReveal's rules.- Without ClientRouter
- animates.
- With ClientRouter
- animates on every page.
Revealbinds 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
ClientRouternavigations 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.AstroAnimate
@astroanimate/core0.1.2, released 2026-05-231.5 kB gzip of JavaScript for one reveal · 1.9 kB gzip for ten · 343 B gzip of CSS
FadeInTextwithenhancewrites 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, sonpm install astro@7.3.2 @astroanimate/core@0.1.2stops with ERESOLVE. With--legacy-peer-depsit 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
ClientRouterdoes 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 carrydata-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 underprefers-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.
astro-reveal (default mode)
astro-reveal0.3.1, released 2026-07-010 B of JavaScript for one reveal · 0 B for ten · 766 B gzip of CSS
- 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, open) that Astro closed as upstream (withastro/astro#17940). Withvite: { build: { cssMinify: 'esbuild' } }in the Astro config the rule keepsanimation-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 supportanimation-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'.astro-reveal on npm · lightningcss#1342 · The same pitfall in our docs
astro-reveal (observer mode)
astro-reveal0.3.1, released 2026-07-01880 B gzip of JavaScript for one reveal · 880 B gzip for ten · 766 B gzip of CSS
- Without ClientRouter
- animates. The observer reveals the content once.
- With ClientRouter
- first page: stays hidden; later pages: visible, no reveal. Astro fires
astro:page-loadon 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 atopacity: 0. After a navigation the swap replaces the attributes of<html>and drops thereveal-jsclass 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
ClientRouterand you want a reveal that plays once, for 880 B gzip of JavaScript.AOS
aos2.3.4, released 2018-10-03; its last beta, 3.0.0-beta.6, came out the same day4.8 kB gzip of JavaScript for one reveal · 4.8 kB gzip for ten · 2.2 kB gzip of CSS
- 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 thedata-aos-*attributesinit()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), oneinit()animates on every page (measured). Callinginit()on everyastro:page-loadinstead 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
disableoption; michalsnik/aos#611, open since 2020-08-24, asks for that to be documented. - JavaScript off
- content hidden. AOS 2.3.4's stylesheet starts
fadeandzoomelements atopacity: 0until its script runs. michalsnik/aos#81 reported it in 2016; the maintainer closed it in 2018 after moving the styles underhtml: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.AOS in Astro, the guide · michalsnik/aos#81 · michalsnik/aos#611
GSAP with ScrollTrigger
gsap3.15.0, released 2026-04-1343.0 kB gzip of JavaScript for one reveal · 43.0 kB gzip for ten · 0 B of CSS
- 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 agsap.context()on everyastro:page-loadand reverted onastro:before-swap, as GSAP's Rodrigo advises Astro users (GSAP forum, 2024-07-06), the reveal animates on every page (measured). Moonarc'sgsapPageadapter 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 ingsapPagefrom@moonarc/core/gsap.GSAP forum answer · gsap.matchMedia() · GSAP with ClientRouter, the guide
Motion
motion13.4.4, released 2026-09-2518.7 kB gzip of JavaScript for one reveal · 18.7 kB gzip for ten · 62 B gzip of CSS
This setup imports the hybrid
animate()frommotion, 18 kB gzip in Motion's docs. The next row imports the minianimate()frommotion/mini, 2.3 kB gzip in its docs, which animatesopacityandtransformtoo.- 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 theopacity: 0this setup's CSS starts them at: Motion'sinViewexample animates toopacity: 1and leaves the starting style to the page. WithinView()called on everyastro:page-loadand its stop function onastro: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.
Motion (mini animate)
motion13.4.4, released 2026-09-253.8 kB gzip of JavaScript for one reveal · 3.8 kB gzip for ten · 62 B gzip of CSS
animate()frommotion/miniruns on the browser's Web Animations API;inView()still comes frommotion.- 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()whenopacityandtransformare all you animate: it is the smallest way to use Motion measured here.Motion for React in a React island
motion13.4.4, released 2026-09-25108.2 kB gzip of JavaScript for one reveal · 108.2 kB gzip for ten · 66 B gzip of CSS
- 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
initialstyle,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.
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
hero JavaScript, gzip: 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.
Open the page; its footer counts the JavaScript as it loads →React + Motion for React
110.5 kB gzip
hero JavaScript, gzip: 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.
Open the page; its footer counts the JavaScript as it loads →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 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.
- Measured
- 2026-09-28
- Astro and Vite
- Astro 7.3.2, Vite 8.3.1, lightningcss 1.33.0
- Node and npm
- v24.20.0, npm 11.19.0
- Browsers
- Chromium 153.0.8010.12, Firefox 155.0, WebKit 26.6
The raw results, per engine and per setup, with the fixes: /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 setup
import moonarc from 'moonarc';
export default { integrations: [moonarc()] };---
import Reveal from '@moonarc/core/Reveal';
---
<Reveal><h2>Heading</h2><p>…</p></Reveal>AstroAnimate setup
---
import FadeInText from '@astroanimate/core/FadeInText';
---
<FadeInText enhance><h2>Heading</h2><p>…</p></FadeInText>astro-reveal (default mode) setup
import reveal from 'astro-reveal';
export default { integrations: [reveal()] };---
import Reveal from 'astro-reveal/Reveal.astro';
---
<Reveal animation="up"><h2>Heading</h2><p>…</p></Reveal>astro-reveal (observer mode) setup
import reveal from 'astro-reveal';
export default { integrations: [reveal({ mode: 'observer' })] };---
import Reveal from 'astro-reveal/Reveal.astro';
---
<Reveal animation="up"><h2>Heading</h2><p>…</p></Reveal>AOS setup
<script>
import AOS from 'aos';
import 'aos/dist/aos.css';
AOS.init();
</script>---
import Setup from '../components/Setup.astro';
---
<div data-aos="fade-up"><h2>Heading</h2><p>…</p></div>
<Setup />GSAP with ScrollTrigger setup
<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>---
import Setup from '../components/Setup.astro';
---
<div class="reveal"><h2>Heading</h2><p>…</p></div>
<Setup />Motion setup
<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>---
import Setup from '../components/Setup.astro';
---
<div class="reveal"><h2>Heading</h2><p>…</p></div>
<Setup />Motion (mini animate) setup
<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>---
import Setup from '../components/Setup.astro';
---
<div class="reveal"><h2>Heading</h2><p>…</p></div>
<Setup />Motion for React in a React island setup
import react from '@astrojs/react';
export default { integrations: [react()] };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>
);
}---
import RevealIsland from '../components/RevealIsland.jsx';
---
<RevealIsland client:load><h2>Heading</h2><p>…</p></RevealIsland>