# Curtain (Moonarc)

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.

- Import: `import Curtain from '@moonarc/core/Curtain'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/curtain.json`
- Tier B · category transition · trigger click
- Readout: `<Curtain mode="iris">`
- Browser support: newly (Chrome 120 · Firefox 144 · Safari 18); elsewhere: an instant swap between pages
- Measured cost: 479 B raw JS · 266 B gzip (CSS 10.0 kB raw)
- Page: https://moonarc.dev/components/curtain/

## Props

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

## Usage

```astro
---
// in the layout, once, on every page
---
<Curtain mode="iris" />

<Curtain mode="title" title={Astro.props.title} />
```

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

## Craft

- Curtain owns the root and PageTransition owns regions: a named region on the same page is captured separately and keeps its own preset, and the curtain wipes everything else. Use both; never two curtains.
- The old page holds still and the new one comes in through the shape. The default cross-fade is replaced (animation: none on the old snapshot, mix-blend-mode: normal on both), because a fade under a wipe looks muddy.
- The mode is read with :root:has(.ma-curtain[data-mode]) on the destination page, so a page decides how it is entered. An editorial page can open with a title while the shop uses an iris.
- The iris starts at the last pointer-down: one document listener, two custom properties on <html>, rewritten after the swap because the router replaces the root's attributes. Back and forward open from the centre.
- Blinds and pixels animate a registered custom property that a mask gradient reads, so the mask is re-rasterised on each frame. Eight stripes or a grid of squares are cheap to rasterise, and pixels use steps(6) because pixels do not tween.
- Back plays the same keyframes reversed on the leaving page, above the arriving one, so going back looks like the forward move undone.
- The title panel is a real element on every page, clipped away in normal life; its snapshot is what the browser slides across, so the title is the next page's own, not a copy the script knows about.

## Replaces

- page-transition examples (motion.dev)
- curtain / pixel transitions (Codrops)
- Barba.js and Swup transitions

## Source

```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>

```
