Skip to content

Components / Page transitions

Curtain

A full-viewport wipe between pages: the next page opens through an iris from where you clicked, or through blinds, a shutter, doors, pixels, staggered columns, a diagonal, or a title panel that carries the next page's name. It animates the browser's own root view-transition snapshots with clip-path and mask, on Astro's router; back plays the shape in reverse. The only script is the click point.

Live demo

page one

Iris.

page two

From the click.

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

479 B raw

266 B gzip.

CSS 10.0 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 120 · Firefox 144 · Safari 18; needs view-transitions, has, clip-path, mask-image, mask-composite, property. 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 Curtain from '@moonarc/core/Curtain';
// in the layout, once, on every page
---
<Curtain mode="iris" />

<Curtain mode="title" title={Astro.props.title} />
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/curtain.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.

Curtain.astro
---
/**
 * Curtain — a full-viewport wipe between two pages. Where PageTransition
 * animates a region, Curtain owns the root: it animates the browser's own
 * ::view-transition-old(root) / -new(root) with a clip-path (iris, doors,
 * shutter, diagonal) or a mask (blinds, stagger, pixels), so the new page
 * comes in through a shape while the old one stays put beneath it. Back
 * navigation plays the shape in reverse on the leaving page. `title` is a
 * real panel with a view-transition-name that carries the next page's title
 * across the swap, and only across a swap: a same-document transition the
 * library runs (html[data-ma-vt]) leaves it out. One element in the layout;
 * the mode is a prop and lives in the DOM, so :has() on the root selects the
 * rules — nothing global.
 * The only script is the iris origin: the last pointer-down, written on
 * <html> and rewritten after every swap.
 */
import type { HTMLAttributes } from 'astro/types';

type Mode = 'iris' | 'blinds' | 'shutter' | 'doors' | 'pixels' | 'stagger' | 'diagonal' | 'title';

interface Props extends HTMLAttributes<'div'> {
  /** The shape. */
  mode?: Mode;
  /** The page's title, for `title` mode: shown on the panel while it covers the swap. */
  title?: string;
  /** Duration in ms. Unset: the preset's --ma-dur-gentle (title: --ma-dur-ambient). */
  duration?: number;
  /** Panel colours for `title` mode. */
  color?: string;
  textColor?: string;
}

const { mode = 'iris', title, duration, color, textColor, class: className, style, ...rest } = Astro.props;
const vars = [color && `--ma-curtain-color:${color}`, textColor && `--ma-curtain-text:${textColor}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-curtain', className]} data-mode={mode} data-duration={duration} aria-hidden="true" style={vars || undefined} {...rest}>
  {title && <div class="ma-curtain__panel">{title}</div>}
</div>

<style is:global>
  @property --ma-curtain-f {
    syntax: '<percentage>';
    inherits: false;
    initial-value: 0%;
  }
  @property --ma-curtain-px {
    syntax: '<length>';
    inherits: false;
    initial-value: 0px;
  }

  @layer components {
    :where(.ma-curtain) {
      display: none;
    }
    /* title: the panel lives on the page, fully clipped, so its snapshot exists for the transition */
    :where(.ma-curtain[data-mode='title']) {
      display: block;
      position: fixed;
      inset: 0;
      z-index: 2147483000;
      pointer-events: none;
      clip-path: inset(0 0 100% 0);
    }
    /* named for page swaps only: inline, the name also put the full-screen panel into every same-document transition the
       library runs (a morphing dialog, FilterGrid), and the title swept across the page */
    :where(:root:not([data-ma-vt]) .ma-curtain__panel) {
      view-transition-name: ma-curtain-title;
    }
    :where(.ma-curtain__panel) {
      position: absolute;
      inset: 0;
      display: grid;
      place-items: center;
      padding: 6vmin;
      /* the panel is the page's own surface, laid over Canvas so a translucent token never lets the old page through */
      background: linear-gradient(var(--ma-curtain-color, var(--ma-panel)), var(--ma-curtain-color, var(--ma-panel))), Canvas;
      color: var(--ma-curtain-text, var(--ma-ink));
      font-size: clamp(2rem, 6vw, 5rem);
      font-weight: 700;
      letter-spacing: -0.03em;
      line-height: 1.05;
      text-align: center;
      text-wrap: balance;
    }

    /* The shape layer: the new page on the way forward, the old page on the way back. The other one holds still. */
    :where(:root:not([data-astro-transition='back']):has(.ma-curtain:not([data-mode='title'])))::view-transition-old(root),
    :where(:root[data-astro-transition='back']:has(.ma-curtain:not([data-mode='title'])))::view-transition-new(root) {
      animation: none;
      mix-blend-mode: normal;
    }
    :where(:root:not([data-astro-transition='back']):has(.ma-curtain:not([data-mode='title'])))::view-transition-new(root),
    :where(:root[data-astro-transition='back']:has(.ma-curtain:not([data-mode='title'])))::view-transition-old(root) {
      animation-duration: var(--ma-curtain-dur, var(--ma-dur-gentle));
      animation-timing-function: var(--ma-ease-out);
      animation-fill-mode: both;
      mix-blend-mode: normal;
    }
    :where(:root[data-astro-transition='back']:has(.ma-curtain:not([data-mode='title'])))::view-transition-old(root) {
      z-index: 1;
      animation-direction: reverse;
      animation-timing-function: var(--ma-ease-in);
    }
    :where(:root:not([data-astro-transition='back']):has(.ma-curtain[data-mode='iris']))::view-transition-new(root),
    :where(:root[data-astro-transition='back']:has(.ma-curtain[data-mode='iris']))::view-transition-old(root) {
      animation-name: ma-curtain-iris;
    }
    :where(:root:not([data-astro-transition='back']):has(.ma-curtain[data-mode='doors']))::view-transition-new(root),
    :where(:root[data-astro-transition='back']:has(.ma-curtain[data-mode='doors']))::view-transition-old(root) {
      animation-name: ma-curtain-doors;
    }
    :where(:root:not([data-astro-transition='back']):has(.ma-curtain[data-mode='shutter']))::view-transition-new(root),
    :where(:root[data-astro-transition='back']:has(.ma-curtain[data-mode='shutter']))::view-transition-old(root) {
      animation-name: ma-curtain-shutter;
    }
    :where(:root:not([data-astro-transition='back']):has(.ma-curtain[data-mode='diagonal']))::view-transition-new(root),
    :where(:root[data-astro-transition='back']:has(.ma-curtain[data-mode='diagonal']))::view-transition-old(root) {
      animation-name: ma-curtain-diagonal;
    }
    /* blinds: eight slats whose filled part grows; the gradient reads the animated registered property */
    :where(:root:not([data-astro-transition='back']):has(.ma-curtain[data-mode='blinds']))::view-transition-new(root),
    :where(:root[data-astro-transition='back']:has(.ma-curtain[data-mode='blinds']))::view-transition-old(root) {
      animation-name: ma-curtain-blinds;
      mask-image: repeating-linear-gradient(#000 0 var(--ma-curtain-f), transparent 0 12.5%);
    }
    /* stagger: eight columns, one after another */
    :where(:root:not([data-astro-transition='back']):has(.ma-curtain[data-mode='stagger']))::view-transition-new(root),
    :where(:root[data-astro-transition='back']:has(.ma-curtain[data-mode='stagger']))::view-transition-old(root) {
      animation-name: ma-curtain-stagger;
      animation-timing-function: steps(8, end);
      mask-image: linear-gradient(#000 0 0);
      mask-repeat: no-repeat;
    }
    /* pixels: a grid of squares growing in six steps — two gradients intersected, one per axis */
    :where(:root:not([data-astro-transition='back']):has(.ma-curtain[data-mode='pixels']))::view-transition-new(root),
    :where(:root[data-astro-transition='back']:has(.ma-curtain[data-mode='pixels']))::view-transition-old(root) {
      animation-name: ma-curtain-pixels;
      animation-timing-function: steps(6, end);
      mask-image:
        linear-gradient(90deg, transparent calc(50% - var(--ma-curtain-px) / 2), #000 0 calc(50% + var(--ma-curtain-px) / 2), transparent 0),
        linear-gradient(transparent calc(50% - var(--ma-curtain-px) / 2), #000 0 calc(50% + var(--ma-curtain-px) / 2), transparent 0);
      mask-size: 6vmin 6vmin;
      mask-composite: intersect;
    }

    /* title: the old page holds, the panel rises with the next title, the new page is there when it leaves */
    :where(:root:has(.ma-curtain[data-mode='title']))::view-transition-old(root) {
      animation: ma-curtain-cut-old var(--ma-curtain-dur, var(--ma-dur-ambient)) linear both;
      mix-blend-mode: normal;
    }
    :where(:root:has(.ma-curtain[data-mode='title']))::view-transition-new(root) {
      animation: ma-curtain-cut-new var(--ma-curtain-dur, var(--ma-dur-ambient)) linear both;
      mix-blend-mode: normal;
    }
    :where(:root:has(.ma-curtain[data-mode='title']))::view-transition-group(ma-curtain-title) {
      z-index: 2;
      animation: ma-curtain-title var(--ma-curtain-dur, var(--ma-dur-ambient)) var(--ma-ease-in-out) both;
    }
    :where(:root:has(.ma-curtain[data-mode='title']))::view-transition-old(ma-curtain-title) {
      display: none;
    }
    :where(:root:has(.ma-curtain[data-mode='title']))::view-transition-new(ma-curtain-title) {
      animation: none;
      mix-blend-mode: normal;
    }

    @media (prefers-reduced-motion: reduce) {
      :where(:root:has(.ma-curtain))::view-transition-old(root),
      :where(:root:has(.ma-curtain))::view-transition-new(root),
      :where(:root:has(.ma-curtain))::view-transition-group(ma-curtain-title),
      :where(:root:has(.ma-curtain))::view-transition-new(ma-curtain-title) {
        animation: none;
        mask-image: none;
        clip-path: none;
      }
      :where(:root:has(.ma-curtain))::view-transition-old(root),
      :where(:root:has(.ma-curtain))::view-transition-group(ma-curtain-title) {
        display: none;
      }
    }
  }

  @keyframes ma-curtain-iris {
    from {
      clip-path: circle(0 at var(--ma-curtain-x, 50%) var(--ma-curtain-y, 50%));
    }
    to {
      clip-path: circle(150% at var(--ma-curtain-x, 50%) var(--ma-curtain-y, 50%));
    }
  }
  @keyframes ma-curtain-doors {
    from {
      clip-path: inset(0 50%);
    }
    to {
      clip-path: inset(0 0);
    }
  }
  @keyframes ma-curtain-shutter {
    from {
      clip-path: inset(0 0 100% 0);
    }
    to {
      clip-path: inset(0 0 0 0);
    }
  }
  @keyframes ma-curtain-diagonal {
    from {
      clip-path: polygon(0 0, 0 0, 0 0);
    }
    to {
      clip-path: polygon(0 0, 200% 0, 0 200%);
    }
  }
  @keyframes ma-curtain-blinds {
    from {
      --ma-curtain-f: 0%;
    }
    to {
      --ma-curtain-f: 12.5%;
    }
  }
  @keyframes ma-curtain-stagger {
    from {
      mask-size: 0% 100%;
    }
    to {
      mask-size: 100% 100%;
    }
  }
  @keyframes ma-curtain-pixels {
    from {
      --ma-curtain-px: 0px;
    }
    to {
      --ma-curtain-px: 6vmin;
    }
  }
  @keyframes ma-curtain-cut-old {
    0%,
    49.9% {
      opacity: 1;
    }
    50%,
    100% {
      opacity: 0;
    }
  }
  @keyframes ma-curtain-cut-new {
    0%,
    49.9% {
      opacity: 0;
    }
    50%,
    100% {
      opacity: 1;
    }
  }
  @keyframes ma-curtain-title {
    from {
      transform: translateY(100%);
    }
    38%,
    62% {
      transform: translateY(0);
    }
    to {
      transform: translateY(-100%);
    }
  }
</style>

<script>
  // The iris opens from the last pointer-down. The point is written on <html>, because the
  // view-transition pseudo-elements inherit from it, and written again after every swap,
  // because the router replaces <html>'s attributes. Back/forward opens from the centre.
  // The curtain's duration prop rides along the same way.
  let x = '50%';
  let y = '50%';
  const write = () => {
    const s = document.documentElement.style;
    s.setProperty('--ma-curtain-x', x);
    s.setProperty('--ma-curtain-y', y);
    const d = document.querySelector<HTMLElement>('.ma-curtain')?.dataset.duration;
    if (d) s.setProperty('--ma-curtain-dur', `${d}ms`);
    else s.removeProperty('--ma-curtain-dur');
  };
  document.addEventListener(
    'pointerdown',
    (e) => {
      x = `${e.clientX}px`;
      y = `${e.clientY}px`;
      write();
    },
    { passive: true },
  );
  addEventListener('popstate', () => {
    x = y = '50%';
    write();
  });
  document.addEventListener('astro:after-swap', write);
  write();
</script>

Props

PropTypeDefaultDescription
mode'iris' | 'blinds' | 'shutter' | 'doors' | 'pixels' | 'stagger' | 'diagonal' | 'title''iris'The shape. The destination page's mode plays, because the new document's CSS styles the transition.
titlestringnoneFor title mode: the page's title, shown on the panel while it covers the swap. Give every page its own.
durationnumberpreset (760; title 1390)Duration in ms. Unset, the preset's --ma-dur-gentle; title mode uses --ma-dur-ambient for its three beats.
colorstringvar(--ma-panel)Panel colour in title mode: the page surface by default, over an opaque Canvas layer.
textColorstringvar(--ma-ink)Panel text colour in title mode.

Reduced motion

Astro's router skips view transitions under the OS setting; under the site's switch the snapshots are cut without a shape.

With ClientRouter

This is the ClientRouter: Curtain has no effect without it. The click-point script listens once on the document and rewrites the origin after every swap.

Why it is built this way

Replaces: page-transition examples (motion.dev) · curtain / pixel transitions (Codrops) · Barba.js and Swup transitions. See the migration table.

page one

Hero.

page two

Hero.

Try the real swap →

Page Transition

Page transitions

Animates a named region between pages: rise, zoom, wipe, scope, fade, slide, or the browser's own shared-element morph.

newly · Chrome 111 · Firefox 144 · Safari 18click
Ambient loopthe same element rides into the next page

Press play, then navigate: position, volume and playback carry over.

Persist Player

Page transitions

An audio player that keeps playing while you navigate: the same <audio> element is carried into the next page by Astro's router, so position, volume and playback never restart.

every browserclick

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

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