Page Transition
Page transitionsAnimates a named region between pages: rise, zoom, wipe, scope, fade, slide, or the browser's own shared-element morph.
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, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
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
Chrome 120 · Firefox 144 · Safari 18; needs view-transitions, has, clip-path, mask-image, mask-composite, property. Elsewhere: an instant swap between pages.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import Curtain from '@moonarc/core/Curtain';
// in the layout, once, on every page
---
<Curtain mode="iris" />
<Curtain mode="title" title={Astro.props.title} />Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/curtain.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* 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>| Prop | Type | Default | Description |
|---|---|---|---|
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. |
title | string | none | For title mode: the page's title, shown on the panel while it covers the swap. Give every page its own. |
duration | number | preset (760; title 1390) | Duration in ms. Unset, the preset's --ma-dur-gentle; title mode uses --ma-dur-ambient for its three beats. |
color | string | var(--ma-panel) | Panel colour in title mode: the page surface by default, over an opaque Canvas layer. |
textColor | string | var(--ma-ink) | Panel text colour in title mode. |
Astro's router skips view transitions under the OS setting; under the site's switch the snapshots are cut without a shape.
ClientRouterThis 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.
Replaces: page-transition examples (motion.dev) · curtain / pixel transitions (Codrops) · Barba.js and Swup transitions. See the migration table.
Animates a named region between pages: rise, zoom, wipe, scope, fade, slide, or the browser's own shared-element morph.
Press play, then navigate: position, volume and playback carry over.
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.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown