Skip to content

Components / Page transitions

Page Transition

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.

Live demo

page one

Hero.

page two

Hero.

Try the real swap →

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 7.9 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 111 · Firefox 144 · Safari 18; needs view-transitions. Elsewhere: an instant swap between pages.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import PageTransition from '@moonarc/core/PageTransition';
// 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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/page-transition.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

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

Props

PropTypeDefaultDescription
namestringnonetransition: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.
asHTMLTag'div'Element to render.

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.

Why it is built this way

Replaces: framer-motion AnimatePresence page transitions · Barba.js · Swup. See the migration table.

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.

every browserscroll

Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.

every browserscroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown