---
name: moonarc
description: "Use Moonarc (@moonarc/core, MIT, installed with npx astro add moonarc) to add animation and motion to Astro sites, and follow its rules for reduced motion, ClientRouter navigation, JavaScript cost, accessibility and theming. Use when adding motion to an Astro page (scroll reveal, text effects, marquee, parallax, hover effects, page transitions with ClientRouter or view transitions), when a script or animation stops working after navigation ('why does my animation stop after navigation'), when handling prefers-reduced-motion or a reduced-motion switch, when choosing a Moonarc component by intent or JavaScript cost, when theming Moonarc or changing its motion presets, and when porting a React Bits, Aceternity or Magic UI effect to Astro."
---

# Moonarc

Moonarc is a set of animation and motion components for Astro, free and MIT-licensed in `@moonarc/core`. Each
component states the JavaScript it ships, measured from a production build, and many ship none: the browser runs
them from CSS. Tailwind is optional. Astro 5, 6 and 7 are supported, in `static` and `server` output.

## Install

```sh
npx astro add moonarc
```

`astro add` installs `moonarc` (the integration) and `@moonarc/core`, and registers `moonarc()` in
`astro.config.mjs`. On every page the integration injects `@moonarc/core/styles/base.css`, the colour and motion
tokens every component reads. It also inlines a `<head>` script that sets `data-ma-js` on `<html>` before first
paint and after every `<ClientRouter />` navigation. Entrance effects hide content only behind that attribute, so a
page whose JavaScript never runs still shows everything. Import each component from its own subpath: one import
never pulls another component's CSS or script.

```astro
---
import Reveal from '@moonarc/core/Reveal';
---
<Reveal as="ul" stagger={60}>
  <li>One</li><li>Two</li><li>Three</li>
</Reveal>
```

The options of `moonarc()`, with the default in parentheses:

- `injectStyles` (`true`): set `false` only when you import `base.css` yourself to control stylesheet order.
- `jsGate` (`true`): set `false` only when you inline `gateScript`, exported by `@moonarc/core`, in `<head>`.
- `noExternal` (`false`): set `true` only when a build fails with `ERR_UNKNOWN_FILE_EXTENSION`.
- `motionAttribute` (`false`): set `true` for a reduced-motion switch on the page (see Reduced motion).

Without the integration, run `npm install @moonarc/core` and put this in the layout:

```astro
---
import '@moonarc/core/styles/base.css';
import { gateScript } from '@moonarc/core';
---
<head>
  <style is:inline>@layer theme, base, components, utilities;</style>
  <script is:inline set:html={gateScript} />
</head>
```

Keep the layer statement first in `<head>`. Without it a component's CSS can load first, and a reset in `base`
(Tailwind's preflight is one) then beats every component rule.

To copy the source instead, call `get_install_command` with `method: "copy"`. It returns the `components.json`, the
`@/*` alias and the `npx shadcn@latest add https://moonarc.dev/r/<slug>.json` command. Import the copied
`src/styles/moonarc.css` once, add the layer statement to the layout, and inline the gate as text:

```html
<script is:inline>(f=>addEventListener('astro:after-swap',f,f()))(()=>document.documentElement.dataset.maJs=1)</script>
```

Setup per package manager and project type, with fixes: https://moonarc.dev/setup.md

## Choose a component

Look a component up before writing motion by hand.

- The MCP server runs locally with no account: `claude mcp add moonarc -- npx -y @moonarc/mcp` (other clients:
  https://moonarc.dev/mcp/).
  - `search_motion(intent, max_js_bytes?, tier?, limit?)` finds components for an intent in plain words ("fade in on
    scroll"), or by the name of the component being replaced (`NumberTicker`). Best match first, 10 unless `limit`
    is set, and the smaller own script first among equal matches. Each result carries its tier, measured cost,
    `jsBytes` (own script, raw bytes) and page URL.
  - `get_component(name)` returns the import line, cost, support, props, usage, reduced-motion and ClientRouter
    behaviour, craft notes and full source. The craft notes record the easing, duration and structure decisions:
    repeat them.
  - `get_install_command(names, method?)`: `package` (the default) gives `npx astro add moonarc` and the imports,
    `copy` gives the shadcn CLI setup. It writes no files, and an unknown name fails with the closest names.
- https://moonarc.dev/llms.txt lists every `@moonarc/core` component under `## Components`, one line each with its
  description, cost, browser support and intents.
- Each component has a Markdown twin at `https://moonarc.dev/components/<slug>.md` (for example `split-text.md`) with
  the import, tier, measured cost, support, props, usage, reduced motion, ClientRouter behaviour and craft notes.
  Docs have them at `https://moonarc.dev/docs/<slug>.md`, for example `runtime.md`.

Tier A has no script of its own: most load no JavaScript at all, and some reuse Reveal (SplitText, CountUp,
BarGrow). Tier B's own script is at most 1024 B raw, tier C's at most 3072 B raw. The cost line names what else a
component loads, such as Reveal or the shared runtime (about 1.9 kB raw, 1.0 kB gzip, loaded once per site).

- Of two components that answer the intent, take the smaller own script; `max_js_bytes: 0` keeps the ones with no
  script of their own. For `limited` support, accept the fallback the support line names or choose another.
- Some props add a script and say so: Dialog's `polyfill` and `morph`, StickyBanner's `remember`, CounterPreloader's
  `once`, PromptInput's `submitOnEnter`, GlyphField's `swish`.
- `@moonarc/core` has no WebGL. For an effect with no equivalent, use a framework island on `client:visible` and
  state its cost. https://moonarc.dev/docs/migrating-from-react-bits.md maps React Bits, Aceternity, Magic UI and
  shadcn components to their Moonarc equivalents.

| Intent | Start with |
|---|---|
| Fade or rise in on scroll, staggered lists | Reveal; SplitText for text split by word, character or line |
| Scroll-linked motion | Parallax, ScrollProgress, ScrollScrub, CardStack, HorizontalScroll, `<Reveal scrub>` |
| Headline text | RotatingText, Typewriter, ShinyText, GradientText, TextRoll, TextScramble, HighlightMarker |
| Numbers and small charts | CountUp, NumberFlow, PriceSwitch, Sparkline, BarGrow |
| Loops and backgrounds | Marquee, Aurora, GridPattern, DotPattern, Noise, BorderBeam |
| Pointer effects | Spotlight, Tilt, Magnetic, CursorGrid |
| Navigation between pages | PageTransition, Curtain, PersistPlayer |
| Interface | Accordion, Dialog, Popover, Tabs, Carousel, Drawer, Toast (`toast()` from `@moonarc/core/toast`) |
| Dark mode switch | ThemeToggle with ThemeScript |

## ClientRouter and view transitions

Astro bundles a component's `<script>` once and never re-runs it after a `<ClientRouter />` navigation. Whatever it
set up points at detached nodes, and the new page's elements are never bound. Without the router the `astro:*`
events never fire, and with it `astro:page-load` can be lost when a navigation is interrupted after its swap.

Moonarc components rebind themselves. Scripted ones bind through the shared runtime, which also restores
`data-ma-js` so reveals do not flash; CSS-only ones restart on the fresh elements. For your own scripts, use the
runtime instead of an `astro:page-load` or `DOMContentLoaded` listener:

```ts
import { onMount } from '@moonarc/core/runtime';

onMount<HTMLElement>('[data-tilt]', (el, { signal, reducedMotion }) => {
  if (reducedMotion) return;
  el.addEventListener('pointermove', track, { signal }); // removed at teardown
  return () => el.style.removeProperty('--rx'); // runs on astro:before-swap
});
```

- `onMount(selector, setup, options?)` binds each matching element once, now and after every navigation. Pass
  `signal` to `addEventListener`; the function you return runs before the swap. `options.root` scopes the query.
  Register it in the component that owns the elements, or in a layout: each page `<script>` is its own module.
- `onPage(setup)` has the same lifecycle, once per page view, for work without an element such as a ticker.
  `refresh()` binds elements you injected after load and releases the ones you removed.
- GSAP: `gsapPage(gsap, setup, { scrollTrigger: ScrollTrigger })` from `@moonarc/core/gsap`, called in a layout
  script, runs `setup` inside a `gsap.context()` once per page view and reverts it before the next swap. Lenis:
  `lenisPage(() => new Lenis(), { gsap, scrollTrigger: ScrollTrigger })` from `@moonarc/core/lenis` keeps one
  instance for the document and resyncs it after every swap.
- PageTransition wraps a region: `name` pairs it across pages as a `transition:name`, and `preset` is `rise`,
  `zoom`, `wipe`, `scope`, `fade`, `slide`, `morph` or `none`. Curtain covers the whole page, with `mode` `iris`,
  `blinds`, `shutter`, `doors`, `pixels`, `stagger`, `diagonal` or `title`: put one in the layout. Both need
  `<ClientRouter />`, and without it pages load normally.
- A component inside an element kept by `transition:persist` is released before the swap and bound again after it.
  PersistPlayer, placed in the layout, keeps audio playing across navigations.
- The router replaces the attributes of `<html>` on every navigation. Set any attribute your script put there
  (`data-ma-motion`, `data-ma-preset`) again on `astro:after-swap`, as the gate does for `data-ma-js`.
- Keep your own `transition:animate` page transitions between 150 and 300 ms.

## Reduced motion

- Every Moonarc component has a `prefers-reduced-motion: reduce` branch. The policy is a cross-fade instead of
  movement: never a hard cut, never nothing. Reveal and SplitText keep a fast opacity fade, Marquee stops and
  becomes a scrollable strip, and Typewriter shows the full text. Leave the component in place.
- Give your own CSS animations a `@media (prefers-reduced-motion: reduce)` block that drops translate, scale, blur
  and stagger and keeps a short fade.
- In scripts, read `prefersReducedMotion()` (live) or `reducedMotion` from the setup context (at bind time). Shorten
  motion to near zero rather than skipping the code path, so end states still apply.
- For a switch on the page, `moonarc({ motionAttribute: true })` mirrors each `(prefers-reduced-motion: reduce)`
  block in the compiled CSS (components, Tailwind's `motion-reduce:`, your own) under `html[data-ma-motion='reduce']`,
  which `prefersReducedMotion()` also reads. Stamp `data-ma-motion="reduce"` on `<html>` from your toggle.
- A `(prefers-reduced-motion: no-preference)` block, which Tailwind's `motion-safe:` writes, is not mirrored: motion
  that only such a block turns on keeps running under the switch. Write the reduced case as a `reduce` block.
- Motion that runs past five seconds must stop or be stoppable (WCAG 2.2.2). RotatingText turns while the page is
  open unless you set `cycles`: beside text people read, keep cycles × words × period under 5000 ms.

## Accessibility

- Server HTML is the end state. Entrance effects hide content only under `html[data-ma-js]`; hide the start state of
  your own entrances under the same selector.
- SplitText and Typewriter keep the whole string in a visually hidden copy (`.ma-sr`) and mark the animated parts
  `aria-hidden`, so assistive technology reads one string. Pass the text through their `text` prop and let them
  split it. RotatingText's hidden copy lists every word once.
- Marquee's duplicate copies are `aria-hidden` and `inert`, so their links are reached once, and its `label` prop
  makes the strip a named region. Aurora is `aria-hidden` outright.
- Hover effects are gated on `(hover: hover) and (pointer: fine)`, so a tap on a touch screen never triggers them.
  Gate your own hover motion the same way.
- Secondary text is drawn at the `--ma-dim` opacity. Lowering the token lowers its contrast.
- Form controls are native elements with your attributes passed through, native validation, labels, focus rings and
  24 px targets. Keep the label and the focus ring when you restyle one.

## Performance budget

- A cost has two figures. Own is the component's own script, which the tier ceilings apply to; with dependencies
  adds the runtime and any component it composes.
- Moonarc's figures lead with raw bytes (as built: minified, before compression). The tiers, `max_js_bytes` and the
  network panel's resource size all count raw. Compare a gzip figure only with another library's gzip figure.
- `<ClientRouter />` adds Astro's router script, about 16 kB raw on the Moonarc site's home page and the largest
  script there. Count it when you add the router for a transition.
- To measure a route, run `astro build` and `astro preview`, filter the network panel to scripts and add up the
  resource sizes. Add the inline `<script type="module">` bodies too: Astro inlines any script under Vite's
  `assetsInlineLimit` (4 kB raw), so it never shows as a request. From the build output, the total is every external
  script, every `modulepreload` chunk, the import graph of each chunk and the inline module bodies.
- lightningcss folds `animation-timeline: view()` into the `animation` shorthand, which browsers reject. Keep the
  timeline in a separate rule with different selector text. Set `will-change` only while an animation runs.

## Motion tokens and presets

| Preset | Settles in | Feel |
|---|---:|---|
| `snap` | 290 ms | clicks into place |
| `ui` (default) | 450 ms | product UI |
| `gentle` | 760 ms | editorial |
| `lively` | 840 ms | overshoots, bounces back |
| `ambient` | 1390 ms | slow, atmospheric |

- `<html data-ma-preset="gentle">` switches the page, and any subtree can carry its own
  (`<section data-ma-preset="snap">`). The preset fills `--ma-ease` (a spring sampled into `linear()`),
  `--ma-duration`, `--ma-stagger`, `--ma-travel-enter`, `--ma-travel-hover` and `--ma-travel-section`.
- Exits and continuous motion never use a spring. Their fixed tokens: `--ma-ease-out` (exits, hover release),
  `--ma-ease-in` (things leaving), `--ma-ease-in-out` (ambient loops), `--ma-ease-drawer` (sheets),
  `--ma-duration-fast` (150ms: exits, hover, press), `--ma-duration-slow` (700ms: long reveals).
- Change the preset before setting props per instance. A prop such as `<Reveal duration={900}>` wins over the
  preset for that instance only. Override any token with a plain rule, `:root { --ma-ease: … }`.

```css
.card { transition: transform var(--ma-duration-fast) var(--ma-ease-out); }
@media (hover: hover) and (pointer: fine) {
  .card:hover { transform: translateY(calc(-1 * var(--ma-travel-hover))); }
}
@media (prefers-reduced-motion: reduce) {
  .card:hover { transform: none; }
}
```

- With Tailwind v4, `@import '@moonarc/core/styles';` after `@import 'tailwindcss';` adds `ease-ma` and
  `duration-ma` (the active preset), `ease-snap` … `ease-ambient`, `duration-snap` … `duration-ambient`,
  `ease-out-quint`, `ease-in-quint`, `ease-in-out-quint`, `ease-drawer`, `duration-fast`, `duration-base` and
  `duration-slow`.
- `base.css` declares `@layer theme, base, components, utilities` and puts component rules in `components`, so
  Tailwind utilities (`class="p-0"`) and your unlayered rules win over the library. An unlayered reset wins too: a
  bare `a { color: inherit }` recolours every link a component draws. Put resets in `@layer base`.

## Theming

- The colour tokens in `base.css` are `--ma-ink`, `--ma-panel`, `--ma-edge`, `--ma-glow`, `--ma-shine` and
  `--ma-scrim`, plus `--ma-dark`, a number that is `0` on a light ground and `1` on a dark one, for `calc()`. Every
  rule is wrapped in `:where()`, so `:root { --ma-edge: … }` overrides a token without `!important`.
- Dark mode has three states. Light is the base, system dark applies unless the page chose light, and
  `data-theme="dark"` or `class="dark"` on `<html>` wins in both directions.
- For a theme switch, put `<ThemeScript />` in `<head>` and `<ThemeToggle />` in the header. ThemeScript applies the
  stored choice before paint and after every navigation, and the toggle stays hidden without it. ThemeToggle takes
  `choices` (`['light', 'dark']` for two states), `origin` and `label`.
- Colour props default to a theme-aware token: Spotlight's `color` is `var(--ma-glow)`, GridPattern's is
  `var(--ma-edge)`. A fixed colour stays the same in dark mode, so pass a custom property that has a dark value. A
  few components take a custom property as a prop, such as CopyButton's `--ma-copy-done`.
- `@moonarc/core/theme.css` derives shadcn-named tokens (`--primary`, `--ring`, `--chart-1` … `--chart-5`, the
  `--ma-brand-50` … `--ma-brand-950` scale) from one `--ma-brand` colour, and reads a pasted shadcn or tweakcn theme
  as is. Sparkline and BarGrow draw with `--chart-1` when a theme defines it, and `theme.css` sets `--ma-dim` to
  0.75. Give `--primary` a colour or a `--ma-brand-*` step: `var(--foreground)` makes a loop, and the browser then
  drops every colour in it.

## Forms

`bindForms()` from `@moonarc/core/form` turns a posting form into a `fetch` with a pending state and a spoken result.
It binds every `form[data-ma-form]`, now and after every navigation, and a second call does nothing.

```astro
<form data-ma-form method="post" action="https://formspree.io/f/abcd1234"
      data-ma-form-status="join-status" data-success="/thanks/" data-msg-done="You're on the list.">
  <label for="join-email">Email</label>
  <input id="join-email" name="email" type="email" autocomplete="email" required>
  <button>Join</button>
</form>
<p id="join-status" role="status"></p>
<script>
  import { bindForms } from '@moonarc/core/form';
  bindForms();
</script>
```

- The form must work with JavaScript off: `method="post"` and a real `action`. Test it that way once.
- The status element must be in the page at load, not `hidden` and without `aria-busy`, or the result is not announced.
- The status texts are `data-msg-pending`, `-done`, `-exists`, `-error`, `-429` and `-offline`. The form dispatches
  `ma:form-done` with the parsed response and `ma:form-error` with `{ status }`; for `exists`, read `data-state`.
- While a request is out, submit buttons carry `aria-disabled="true"`, which keeps the focus on them. Do not add
  `disabled` from your own handler. VanishInput works inside a bound form.

## Browser support

- Every component states `widely` (Baseline widely available), `newly` (every engine, recently) or `limited`, with
  what an engine lacking the feature shows. The versions come from `@mdn/browser-compat-data` and live in
  `@moonarc/core/support` (`features`, `supportLine()`).
- Most `limited` components are scroll-driven and static in Firefox: Parallax stays in place, ScrollProgress is not
  rendered, ScrollScrub shows its end state and HorizontalScroll is a plain scrollable strip. Carousel's buttons and
  dots need `::scroll-marker` (Chrome only); elsewhere it is a swipeable snap strip.
- Do not polyfill the scroll-driven components: their 0 B cost depends on staying pure CSS. When the motion must run
  in Firefox, use `<Reveal scrub>`, which falls back to a scroll listener.
- Dialog, Drawer, StaggerMenu and VideoDialog open through invoker commands (`command` and `commandfor`). Safari
  before 26.2 and Firefox before 144 need the `polyfill` prop, one document-level click handler: set it on one of
  them per page, or their triggers open nothing in those browsers.

## Common mistakes

- An `astro:page-load` or `DOMContentLoaded` listener in a component script, or a `querySelector` that binds only
  the first instance. Use `onMount`, registered once in the component or the layout.
- Content hidden in CSS for an entrance without the `html[data-ma-js]` gate, or `jsGate: false` without `gateScript`.
- `transition: all`, or transitions and keyframes on layout properties such as `width`, `height`, `top` and `left`.
  Animate `transform` and `opacity`.
- An entrance from `scale(0)` or a heavy blur. Keep Reveal's `scale` at 0.9 or above and its `blur` under 8.
- `will-change` left on, `animation-timeline` inside the `animation` shorthand, or ungated hover motion.
- `<Parallax axis="x">` inside a parent without `overflow-x: clip`: the drift adds a horizontal scrollbar.
- Two Curtains on one page, an endless RotatingText beside body copy, or a React island for an effect a component
  does in CSS.

## Verify the result

1. `astro build` succeeds. Serve it with `astro preview`.
2. Turn JavaScript off and reload: all content is visible and forms still post.
3. Emulate `prefers-reduced-motion: reduce` in the developer tools, and try the page's own switch if it has one.
   Movement becomes a fade or stops, and every element reaches its end state.
4. With `<ClientRouter />`, go to another page and back, use the back and forward buttons, and click a link while a
   transition runs. Every effect still plays, and each handler fires once per event.
5. Measure each changed route's JavaScript against the costs you looked up, plus the runtime once.
6. Open each `limited` component in Firefox and Safari and check that its fallback reads well.
7. Tab through the new interface: every control is reachable and shows its focus ring. On a touch device, no hover
   effect fires on tap.
8. If you wrote scroll-driven CSS, confirm the built CSS keeps `animation-timeline` in its own rule.
