# Page Transition (Moonarc)

Animates a named region between pages: rise, zoom, wipe, scope, fade, slide, or the browser's own shared-element morph. Zero JavaScript of its own: Astro's <ClientRouter /> drives view transitions, and the presets are keyframes on the active motion preset's curve.

- Import: `import PageTransition from '@moonarc/core/PageTransition'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/page-transition.json`
- Tier A · category transition · trigger click
- Readout: `<PageTransition name="hero" preset="rise">`
- Browser support: newly (Chrome 111 · Firefox 144 · Safari 18); elsewhere: an instant swap between pages
- Measured cost: 0 B JS (CSS 7.9 kB raw)
- Page: https://moonarc.dev/components/page-transition/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `name` | `string` | none | transition:name. The same name on two pages pairs the regions; omit it to animate a region on its own. |
| `preset` | `'rise' | 'zoom' | 'wipe' | 'scope' | 'fade' | 'slide' | 'morph' | 'none'` | `'rise'` | Entrance and exit. scope sinks and dims the old page behind while the new one rises in front, like a sheet; morph lets the browser interpolate size and position between the two pages' elements. |
| `as` | `HTMLTag` | `'div'` | Element to render. |

## Usage

```astro
---
// both pages, same name
---
<PageTransition name="hero" preset="rise">
  <h1>Page one</h1>
</PageTransition>

<PageTransition name="cover" preset="morph">
  <img src="/cover.jpg" alt="" />
</PageTransition>

<PageTransition name="main" preset="scope">
  <main>…</main>
</PageTransition>
```

## Reduced motion

Astro's router skips view-transition animations; the swap is instant.

## With ClientRouter

This is the ClientRouter. Without it the wrapper is inert and pages load normally.

## Craft

- Exit at --ma-duration-fast on ease-out, entrance at --ma-duration on the preset spring: the old page gets out of the way before the new one arrives.
- Backwards navigation plays the same keyframes reversed, so back feels like undo.
- rise travels --ma-travel-section (48 px): page-level motion needs more distance than component-level motion to register.
- morph is a shared-element transition: the browser interpolates the paired region's size and position between the two pages, so no script measures layout.
- scope keeps the old page in view for the whole entrance (it sinks to 92% and dims to a third behind the arriving sheet), so the exit runs on the entrance duration instead of the fast one; that is the depth cue. Backwards, the sheet drops and the page behind comes back up.

## Replaces

- framer-motion AnimatePresence page transitions
- Barba.js
- Swup

## Source

```astro
---
/**
 * PageTransition — the page-level motion Astro's <ClientRouter /> makes
 * possible and nothing else on the web can do this cheaply: wrap a region,
 * give it a name, and it animates between pages. Zero JS of its own; the
 * router is Astro's. Presets are keyframes in this file, resolved through
 * the active preset's curve and duration, so page motion matches the rest.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';
import type { TransitionAnimationValue, TransitionDirectionalAnimations } from 'astro';
import { fade, slide } from 'astro:transitions';

type Preset = 'fade' | 'slide' | 'rise' | 'zoom' | 'wipe' | 'scope' | 'morph' | 'none';

interface Props extends HTMLAttributes<'div'> {
  /** transition:name — the same name on both pages pairs the elements. */
  name?: string;
  /** How the region enters and leaves. `morph` is the shared-element morph the browser draws itself. */
  preset?: Preset;
  /** Element to render. */
  as?: HTMLTag;
}

const { name, preset = 'rise', as: Tag = 'div', class: className, ...rest } = Astro.props;

const own = (kind: 'rise' | 'zoom' | 'wipe' | 'scope'): TransitionDirectionalAnimations => {
  // scope: the old page stays in view, sinking and dimming for the whole entrance, so its exit runs on the entrance duration
  const out = { name: `ma-vt-${kind}-out`, duration: kind === 'scope' ? 'var(--ma-duration)' : 'var(--ma-duration-fast)', easing: 'var(--ma-ease-out)', fillMode: 'both' as const };
  const inn = { name: `ma-vt-${kind}-in`, duration: 'var(--ma-duration)', easing: 'var(--ma-ease)', fillMode: 'both' as const };
  return { forwards: { old: out, new: inn }, backwards: { old: { ...out, name: `ma-vt-${kind}-in`, direction: 'reverse' as const }, new: { ...inn, name: `ma-vt-${kind}-out`, direction: 'reverse' as const } } };
};

const animate: TransitionAnimationValue =
  preset === 'fade' ? fade({ duration: 'var(--ma-duration)' }) : preset === 'slide' ? slide({ duration: 'var(--ma-duration)' }) : preset === 'morph' ? 'initial' : preset === 'none' ? 'none' : own(preset);
---

{
  name ? (
    <Tag class:list={['ma-page', className]} transition:name={name} transition:animate={animate} {...rest}>
      <slot />
    </Tag>
  ) : (
    <Tag class:list={['ma-page', className]} transition:animate={animate} {...rest}>
      <slot />
    </Tag>
  )
}

<style is:global>
  @layer components {
    :where(.ma-page) {
      display: block;
    }
    @media (prefers-reduced-motion: reduce) {
      /* Astro's router already drops view-transition animations under reduced motion. */
      :where(.ma-page) {
        animation: none;
      }
    }
  }

  @keyframes ma-vt-rise-in {
    from {
      opacity: 0;
      translate: 0 var(--ma-travel-section, 48px);
    }
  }
  @keyframes ma-vt-rise-out {
    to {
      opacity: 0;
      translate: 0 calc(var(--ma-travel-section, 48px) * -0.5);
    }
  }
  @keyframes ma-vt-zoom-in {
    from {
      opacity: 0;
      scale: 0.96;
    }
  }
  @keyframes ma-vt-zoom-out {
    to {
      opacity: 0;
      scale: 1.03;
    }
  }
  @keyframes ma-vt-wipe-in {
    from {
      clip-path: inset(0 100% 0 0);
    }
    to {
      clip-path: inset(0 0 0 0);
    }
  }
  @keyframes ma-vt-wipe-out {
    from {
      clip-path: inset(0 0 0 0);
    }
    to {
      clip-path: inset(0 0 0 100%);
    }
  }
  /* scope: the old page sinks and dims behind, the new one rises as a sheet in front */
  @keyframes ma-vt-scope-in {
    from {
      translate: 0 100%;
    }
    to {
      translate: 0 0;
    }
  }
  @keyframes ma-vt-scope-out {
    from {
      scale: 1;
      opacity: 1;
    }
    to {
      scale: 0.92;
      opacity: 0.35;
    }
  }
</style>

```
