# Ghost pass

> An invite page: a glossy pastel ticket with a glowing ghost flies in, rises, tilts to the pointer and spins, above a code to copy.

The page an invite code lands on. A bent, glossy ticket with a glowing ghost flies up into the middle of the stage as rays open behind it, then rises to its place while the headline comes in word by word, then the code and the two ways in. The ticket floats, leans towards the pointer with its shine sliding the other way, and spins round to show its back when dragged. Copy code copies the code and says so.

A React component by Bruno Delorence: https://brunodelorence.com/components/ghost-pass. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Media.

## Anatomy

1. **Pass**: A button holding a bent, glossy SVG ticket. It floats, leans towards the pointer, and a drag, tap or arrow key spins it round to its back.
2. **Shadow**: A soft radial ellipse under the pass that narrows and fades as the pass floats up.
3. **Headline**: A real h2; each word comes in on its own after the entrance, and the pass’s title is in the accent.
4. **Lede**: One line naming the issuer and what the code does.
5. **Code pill**: The code as plain text, selected whole by one click, with the copy button at its right end.
6. **Copy button**: Copies the code and turns to Copied for 1.8 s; both labels share one cell, so its width never changes.
7. **Ways in**: Download the app (filled) and Join the web waitlist (soft): links with their hrefs, buttons without.
8. **Note**: A small line for readers on a computer: copy the code, then open the app on the phone.

## When to use

- The page an invite or referral link lands on: one code to redeem, and one or two ways in.
- A launch or waitlist moment that should feel like receiving something: a ticket to hold, turn over and keep.
- A code that is typed on another device: the pill keeps it large and selectable, and Copy code says when it is copied.
- A playful brand whose mascot can live on the pass, drawn in SVG with no image files to load.

## When not to use

- Codes people type under pressure, like one-time passwords or 2FA. Use a plain code input with paste support.
- A list of several invites or tickets. Use a table or cards, each with its own copy button.
- A real boarding pass or event ticket that must scan. Use a wallet pass with a barcode.
- A sober product (banking, health, admin). The flying, floating, spinning pass reads as a game; use a simple card.
- Inside a dialog or a narrow column. It is a whole page section, scaled from 880 px down to about 580 px.

## Installation

Install it with the shadcn CLI, into components/ghost-pass of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/ghost-pass.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { GhostPass } from './ghost-pass';

export function InvitePage({ code }: { code: string }) {
  return (
    <GhostPass
      code={code}
      appName="Veil"
      downloadHref="/download"
      waitlistHref="/waitlist"
      autoplay={false}
    />
  );
}
```

## States

- **Settled**: After the entrance: the pass floating face front, the code ready to copy.
- **Leaning**: The pointer up and to the right: the pass tips towards it and its shine slides the other way.
- **Back**: Turned round by a drag or a key: the mirrored back with the issuer’s name. It springs to the front after a flick.
- **Copied**: A check and Copied in the same width as Copy code; a polite live region says Code copied.
- **Another issuer**: `appName`, `title` and `code` change the stub, the headline’s accent and the copy.
- **Light**: A lavender wash fading to white down the stage, whatever the page’s scheme.
- **Dark**: A deep violet wash over near-black; the pass keeps its pastel colours.

## How it works

- The ticket is one SVG drawn from a few numbers: its outlines are computed once, the top and bottom edges bowing upwards as one quadratic curve each (exact for a parabola), so the card looks bent round a vertical axis. A darker copy of each outline, a few units lower, is its thickness; the stub is a separate outline whose left edge is a row of small elliptical scallops.
- The art is layered gradients clipped to the body: a pink-to-periwinkle sky, slow rays, two coins with a rim, and the ghost, a dome clipped over a radial gradient with a blurred white copy behind it for the glow and a lavender fade at its foot.
- The entrance is CSS animations keyed by data-phase: the pass flies in from below, small and steeply turned, to the middle of the stage (an overshooting curve), rays open and turn behind it, then after 1.25 s it rises to its place in 0.42 s while the wash grows down the stage. The headline words then fade, unblur and lift 50 ms apart, and the lines below follow 110 ms apart.
- The float, the rays, the bobbing coins and the blink are endless CSS animations, paused offscreen or in a hidden tab, and off under reduced motion.
- The lean and the spin are springs run frame by frame only while something moves, written as one rotateX/rotateY transform on the card and two custom properties that slide the shine. A drag spins it; on release the throw picks the whole turn to land on, and a softer spring brings it there with a small bounce. The back is the other face of the same 3D box, mirrored so the stub is on the correct side.
- Copy code writes to the clipboard, and both labels share one grid cell so the button never changes width as Copy code turns into Copied.
- While on screen and untouched, a demo pointer sweeps over the pass (which leans after it, read from the pointer’s own animation each frame), flicks it round and presses Copy code without touching the clipboard. Any real input hands control to the reader.
- Every size is a multiple of one unit that follows the stage’s width (a container query), so the page scales as a whole down to about 580 px and below that the headline wraps instead.

## API reference

- **code** (`string`, default `'K4WX2PQN'`): The invite code shown in the pill, and copied.
- **appName** (`string`, default `'Veil'`): The issuer’s name: on the stub and the back of the pass, and in the copy.
- **title** (`string`, default `'Ghost Pass'`): The name of the pass, highlighted at the end of the headline.
- **downloadHref** (`string`): Where Download the app leads; without it, a plain button.
- **waitlistHref** (`string`): Where Join the web waitlist leads; without it, a plain button.
- **onCopy** (`(code: string) => void`): Called when the reader copies the code (also when the clipboard refuses).
- **intro** (`boolean`, default `true`): Plays the entrance the first time it comes on screen; false shows it settled at once.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer tilts and spins the pass and presses Copy code.
- **defaultCopied** (`boolean`, default `false`): Opens with the button saying Copied, until it is pressed. Turns the demo off.
- **defaultFace** (`'front' | 'back'`, default `'front'`): Opens turned to its back. A drag, a tap or a key turns it to face front. Turns the demo off.
- **defaultLean** (`{ x: number; y: number }`): Opens leaning as if the pointer were at x, y (each -1 to 1 from the pass’s centre), its shine slid the other way. Turns the demo off.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Added to the root.

## Accessibility

- A section labelled with the pass’s name, with a real h2 headline, a paragraph, and buttons or links for every action.
- The pass is a button: arrow keys, Enter or Space spin it, and its label says so; the drawing inside is aria-hidden.
- The code is plain, selectable text (one click selects all of it), and Copy code announces Code copied through a polite live region.
- Every control shows a focus ring on keyboard focus only.
- Under reduced motion the entrance is skipped, nothing floats, turns or blinks, and the pass neither leans nor spins.
- The demo pointer is decorative (aria-hidden) and stops at the first real input.

## Source

### ghost-pass/ghost-pass.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { PassBack, PassFront } from './pass-art';
import { useAutoplay } from './use-autoplay';
import styles from './ghost-pass.module.css';

/*
 * Ghost pass: the page an invite code lands on. A glossy pastel ticket with a glowing ghost floats
 * above the words "You've been sent a Ghost Pass", the code in a pill with a Copy button, and the
 * two ways in.
 *
 * - The entrance (once, when it first comes on screen): the pass flies up from below, small and
 *   steeply turned, and lands big in the middle of the stage with a little overshoot, while rays
 *   open out behind it and turn. After a beat it rises to its place and shrinks, the lavender wash
 *   grows down the stage, and the headline comes in word by word (each one fades, unblurs and
 *   lifts), then the rest, line by line. All of it is CSS animations keyed by data-phase.
 * - The pass floats (a slow bob and sway, its shadow breathing under it), the rays on its art turn,
 *   the coins bob, and the ghost blinks now and then: CSS animations, paused offscreen.
 * - It leans towards the pointer and its shine slides the other way; drag it sideways (or press
 *   Enter or Space on it) and it spins round, showing its back, then springs back to face front.
 *   The lean and the spin are springs run frame by frame only while they move, written as one
 *   transform and two custom properties.
 * - Copy code writes the code to the clipboard; the button says Copied for a moment, and a live
 *   region says so.
 * - While on screen and untouched, a demo pointer hovers the pass, flicks it round and presses
 *   Copy code. Any real input hands control to the reader.
 */

/** How far the pass leans towards the pointer, in degrees, and over what distance in px. */
const LEAN_X = 12;
const LEAN_Y = 16;
const REACH_X = 320;
const REACH_Y = 240;
/** Degrees of spin per px dragged. */
const DRAG = 0.55;
/** The springs: lean (snappy), and the spin settling back to face front (a little bounce). */
const LEAN = { k: 170, c: 20 };
const SETTLE = { k: 70, c: 11 };
/** The entrance, in ms: when the pass rises to its place. The text follows in the CSS. */
const RISE_AT = 1250;
/** How long Copied shows. */
const COPIED_FOR = 1800;

type Phase = 'wait' | 'intro' | 'ready';

const clamp = (value: number, min = -1, max = 1) => Math.min(max, Math.max(min, value));

export function GhostPass({
  code = 'K4WX2PQN',
  appName = 'Veil',
  title = 'Ghost Pass',
  downloadHref,
  waitlistHref,
  onCopy,
  intro = true,
  autoplay = true,
  defaultCopied = false,
  defaultFace = 'front',
  defaultLean,
  colorScheme,
  className = '',
}: {
  /** The invite code shown in the pill, and copied. */
  code?: string;
  /** The issuer's name: on the stub, and in the copy. */
  appName?: string;
  /** The name of the pass, highlighted at the end of the headline. */
  title?: string;
  /** Where Download the app leads; without it, a button that does nothing. */
  downloadHref?: string;
  /** Where Join the web waitlist leads; without it, a button that does nothing. */
  waitlistHref?: string;
  /** Called after the code is copied (or when copying is refused), with the code. */
  onCopy?: (code: string) => void;
  /** Plays the entrance the first time it comes on screen; false shows it settled at once. */
  intro?: boolean;
  /** While on screen and untouched, a demo pointer tilts and spins the pass and copies the code. */
  autoplay?: boolean;
  /** Opens with the button saying Copied (until it is pressed); turns the demo off. */
  defaultCopied?: boolean;
  /** Opens turned to its back; turns the demo off. A drag, a tap or a key turns it to face front. */
  defaultFace?: 'front' | 'back';
  /** Opens leaning as if the pointer were at x, y (each -1 to 1, from the pass's centre), the
   * shine slid the other way; turns the demo off. It settles when the pointer leaves. */
  defaultLean?: { x: number; y: number };
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, take, playing, visible, reduced } = useAutoplay<HTMLElement>(5000);
  const [phase, setPhase] = useState<Phase>(intro ? 'wait' : 'ready');
  const [copied, setCopied] = useState(defaultCopied);
  /** Opening in a given state holds it still: no demo. */
  const held = defaultCopied || defaultFace === 'back' || defaultLean !== undefined;
  const [message, setMessage] = useState('');
  const tilt = useRef<HTMLSpanElement>(null);
  const slot = useRef<HTMLDivElement>(null);
  const copyButton = useRef<HTMLButtonElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const copyTimer = useRef(0);

  /** The motion: lean and spin with their speeds, and what drives them. */
  const lean0 = {
    x: defaultLean ? -clamp(defaultLean.y) * LEAN_X : 0,
    y: defaultLean ? clamp(defaultLean.x) * LEAN_Y : 0,
  };
  const face0 = defaultFace === 'back' ? 180 : 0;
  const motion = useRef({
    rx: lean0.x,
    ry: lean0.y,
    vx: 0,
    vy: 0,
    spin: face0,
    spinV: 0,
    /** The turn the spin settles on (face front, or the back it opened on). */
    home: face0,
    /** Where the lean is heading. */
    tx: lean0.x,
    ty: lean0.y,
    /** A drag in progress: where it started, the spin then, and the last moves for the flick. */
    drag: null as null | { x: number; spin: number; last: number; at: number; v: number },
    /** The demo's pointer is pressing the pass. */
    demoDown: false,
    frame: 0,
    then: 0,
  });

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  // The entrance plays when the stage first comes on screen; under reduced motion it is skipped.
  useEffect(() => {
    if (phase !== 'wait') return;
    if (reduced) return setPhase('ready');
    if (!visible) return;
    const element = root.current;
    const place = slot.current;
    if (element && place) {
      // The pass starts in the middle of the stage: how far below its place that is.
      const middle = element.clientHeight / 2;
      const own = place.offsetTop + place.offsetHeight * 0.42;
      element.style.setProperty('--drop', `${Math.max(0, middle - own).toFixed(1)}px`);
    }
    setPhase('intro');
  }, [phase, visible, reduced, root]);
  useEffect(() => {
    if (phase !== 'intro') return;
    const timer = window.setTimeout(() => setPhase('ready'), RISE_AT);
    return () => window.clearTimeout(timer);
  }, [phase]);

  /** One frame of the springs; keeps going while anything moves. */
  const step = useEffectEvent((now: number) => {
    const m = motion.current;
    const dt = Math.min(0.032, (now - (m.then || now)) / 1000) || 1 / 60;
    m.then = now;
    // The demo's pointer drives the lean like a real one, from where its flight is right now.
    if (playing && pointer.current && pointer.current.style.opacity === '1') {
      const box = new DOMMatrixReadOnly(getComputedStyle(pointer.current).transform);
      const size = pointer.current.offsetWidth;
      aim(box.m41 + size * 0.15, box.m42 + size * 0.1, true);
    }
    const lean = (value: number, speed: number, target: number) => {
      const accel = LEAN.k * (target - value) - LEAN.c * speed;
      const v = speed + accel * dt;
      return [value + v * dt, v];
    };
    [m.rx, m.vx] = lean(m.rx, m.vx, m.tx);
    [m.ry, m.vy] = lean(m.ry, m.vy, m.ty);
    if (!m.drag) {
      const accel = SETTLE.k * (m.home - m.spin) - SETTLE.c * m.spinV;
      m.spinV += accel * dt;
      m.spin += m.spinV * dt;
    }
    write();
    const still =
      Math.abs(m.rx - m.tx) < 0.02 &&
      Math.abs(m.ry - m.ty) < 0.02 &&
      Math.abs(m.vx) + Math.abs(m.vy) < 0.05 &&
      !m.drag &&
      Math.abs(m.spin - m.home) < 0.05 &&
      Math.abs(m.spinV) < 0.05 &&
      !(playing && pointer.current?.style.opacity === '1');
    if (still) {
      // Whole turns are dropped (a back it opened on stays).
      m.spin = ((m.home % 360) + 360) % 360;
      m.home = m.spin;
      m.spinV = 0;
      m.frame = 0;
      m.then = 0;
      write();
      return;
    }
    m.frame = requestAnimationFrame(step);
  });

  /** Writes the lean and the spin: one transform, and where the shine sits. */
  const write = () => {
    const m = motion.current;
    const element = tilt.current;
    if (!element) return;
    element.style.transform = `rotateX(${m.rx.toFixed(2)}deg) rotateY(${(m.ry + m.spin).toFixed(2)}deg)`;
    element.style.setProperty('--shine-x', (-m.ry * 3.2).toFixed(1));
    element.style.setProperty('--shine-y', (m.rx * 3.2).toFixed(1));
  };

  const wake = () => {
    const m = motion.current;
    if (!m.frame) m.frame = requestAnimationFrame(step);
  };
  useEffect(() => () => cancelAnimationFrame(motion.current.frame), []);
  // A pass opened leaning or turned is drawn so from the first paint.
  useLayoutEffect(() => {
    if (held) write();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  /** Leans the pass towards a point in the stage's px (the reader's pointer or the demo's). */
  const aim = (x: number, y: number, fromDemo = false) => {
    const m = motion.current;
    const element = root.current;
    const card = tilt.current;
    if (!element || !card || reduced) return;
    const stage = element.getBoundingClientRect();
    const box = card.getBoundingClientRect();
    const cx = box.left - stage.left + box.width / 2;
    const cy = box.top - stage.top + box.height / 2;
    m.tx = -clamp((y - cy) / REACH_Y) * LEAN_X;
    m.ty = clamp((x - cx) / REACH_X) * LEAN_Y;
    if (fromDemo) {
      if (m.demoDown && m.drag) dragTo(x);
      else if (m.demoDown) m.drag = { x, spin: m.spin, last: x, at: performance.now(), v: 0 };
      else if (m.drag) release();
    } else wake();
  };

  const dragTo = (x: number) => {
    const m = motion.current;
    if (!m.drag) return;
    const now = performance.now();
    const elapsed = Math.max(1, now - m.drag.at);
    const v = ((x - m.drag.last) * DRAG * 1000) / elapsed;
    m.drag.v = m.drag.v * 0.4 + v * 0.6;
    m.drag.last = x;
    m.drag.at = now;
    m.spin = m.drag.spin + (x - m.drag.x) * DRAG;
  };

  const release = () => {
    const m = motion.current;
    if (!m.drag) return;
    // A flick carries on: aim for the turn the throw would reach, and let the spring land it.
    m.spinV = clamp(m.drag.v, -1400, 1400);
    m.home = Math.round((m.spin + m.spinV * 0.35) / 360) * 360;
    m.drag = null;
    wake();
  };

  const local = (event: { clientX: number; clientY: number }) => {
    const stage = root.current!.getBoundingClientRect();
    return { x: event.clientX - stage.left, y: event.clientY - stage.top };
  };

  const onPointerMove = (event: ReactPointerEvent) => {
    if (event.pointerType !== 'mouse' && !motion.current.drag) return;
    take();
    const at = local(event);
    aim(at.x, at.y);
    if (motion.current.drag) dragTo(at.x);
  };
  const onPointerLeave = () => {
    const m = motion.current;
    if (m.drag) return;
    m.tx = 0;
    m.ty = 0;
    wake();
  };

  const onCardDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
    take();
    if (reduced || event.button !== 0) return;
    event.currentTarget.setPointerCapture(event.pointerId);
    const at = local(event);
    const m = motion.current;
    m.drag = { x: at.x, spin: m.spin, last: at.x, at: performance.now(), v: 0 };
    wake();
  };
  const onCardUp = (event: ReactPointerEvent<HTMLButtonElement>) => {
    const m = motion.current;
    const moved = m.drag ? Math.abs(m.spin - m.drag.spin) : 0;
    release();
    if (event.pointerType !== 'mouse') {
      m.tx = 0;
      m.ty = 0;
    }
    // A tap without a drag spins it once round.
    if (moved < 4 && event.type === 'pointerup') spinOnce(m.ry >= 0 ? 1 : -1);
  };

  /** A full turn, as a keyboard press or a tap gives it. */
  const spinOnce = (direction = 1) => {
    if (reduced) return;
    const m = motion.current;
    m.home = Math.round(m.spin / 360) * 360 + 360 * direction;
    m.spinV += 420 * direction;
    wake();
  };
  const onCardKey = (event: ReactKeyboardEvent) => {
    take();
    if (event.key === 'ArrowLeft') spinOnce(-1);
    if (event.key === 'ArrowRight') spinOnce(1);
  };

  const copy = async (fromDemo = false) => {
    if (!fromDemo) {
      take();
      try {
        await navigator.clipboard.writeText(code);
      } catch {
        // Refused (no permission, or not a secure page): the code is still on screen to select.
      }
      onCopy?.(code);
    }
    setCopied(true);
    announce(fromDemo ? '' : 'Code copied');
    window.clearTimeout(copyTimer.current);
    copyTimer.current = window.setTimeout(() => setCopied(false), COPIED_FOR);
  };
  useEffect(() => () => window.clearTimeout(copyTimer.current), []);

  // The demo: hover the pass from one side to the other, flick it round, then copy the code.
  const demo = autoplay && !held && playing && phase === 'ready';
  useEffect(() => {
    if (!demo) return;
    const stage = root.current;
    const glyph = pointer.current;
    const card = tilt.current;
    const button = copyButton.current;
    if (!stage || !glyph || !card || !button) return;
    const m = motion.current;
    const centre = (element: Element, dx = 0, dy = 0): Point => {
      const s = stage.getBoundingClientRect();
      const b = element.getBoundingClientRect();
      return {
        x: b.left - s.left + b.width * (0.5 + dx),
        y: b.top - s.top + b.height * (0.5 + dy),
      };
    };
    let stop = () => {};
    // The pointer shows up once the words have settled.
    const start = window.setTimeout(() => {
      stop = runDemo(stage, glyph, async ({ wait, glide, glyph: show }) => {
        wake();
        for (;;) {
          show('arrow');
          await glide(centre(card, -0.32, -0.22), 900);
          wake();
          await wait(500);
          await glide(centre(card, 0.34, 0.18), 1100);
          await wait(400);
          await glide(centre(card, 0.1, 0), 600);
          show('hand');
          await wait(200);
          m.demoDown = true;
          await glide(centre(card, -0.95, 0.06), 220);
          m.demoDown = false;
          await wait(1500);
          show('arrow');
          await glide(centre(button), 1000);
          show('hand');
          await wait(350);
          button.dataset.pressed = '';
          await wait(140);
          delete button.dataset.pressed;
          void copy(true);
          await wait(2000);
          show('arrow');
          await glide(centre(stage, 0.36, 0.1), 1100);
          await wait(2400);
        }
      });
    }, 2400);
    return () => {
      window.clearTimeout(start);
      stop();
      m.demoDown = false;
      if (m.drag) release();
      m.tx = 0;
      m.ty = 0;
      wake();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo]);

  const words = "You've been sent a".split(' ');
  const titleWords = title.split(' ');

  return (
    <section
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-phase={phase}
      data-paused={visible ? undefined : ''}
      data-reduced={reduced ? '' : undefined}
      data-instant={intro ? undefined : ''}
      aria-label={`${title} invite`}
      onPointerMove={onPointerMove}
      onPointerLeave={onPointerLeave}
      onPointerDown={take}
      onKeyDown={take}
    >
      <div className={styles.backdrop} aria-hidden="true">
        <div className={styles.wash} />
        <div className={styles.burst} />
      </div>

      <div ref={slot} className={styles.slot} data-part="pass">
        <div className={styles.flight}>
          <div className={styles.entry}>
            <div className={styles.float}>
              <button
                type="button"
                className={styles.card}
                aria-label={`${title} from ${appName}. Drag or use the arrow keys to spin it`}
                onPointerDown={onCardDown}
                onPointerUp={onCardUp}
                onPointerCancel={release}
                onKeyDown={onCardKey}
                onClick={(event) => {
                  // Enter and Space: a keyboard press spins it (a pointer is handled above).
                  if (event.detail === 0) spinOnce(1);
                }}
              >
                <span ref={tilt} className={styles.tilt}>
                  <span className={styles.pose}>
                    <span className={styles.face}>
                      <PassFront name={appName.toLowerCase()} />
                    </span>
                    <span className={`${styles.face} ${styles.back}`}>
                      <PassBack name={appName.toLowerCase()} />
                    </span>
                  </span>
                </span>
              </button>
            </div>
          </div>
        </div>
        <div className={styles.shadow} aria-hidden="true" data-part="shadow" />
      </div>

      <div className={styles.copy}>
        <h2 className={styles.headline} data-part="headline">
          {[...words, ...titleWords]
            .map((word, i) => (
              <span
                key={i}
                className={styles.word}
                data-accent={i >= words.length ? '' : undefined}
                style={{ '--i': i } as CSSProperties}
              >
                {word}
              </span>
            ))
            .flatMap((word, i) => (i ? [' ', word] : [word]))}
        </h2>
        <p className={styles.lede} data-part="lede" style={{ '--i': 0 } as CSSProperties}>
          Skip the {appName} waitlist by downloading the app and redeeming your code.
        </p>

        <div className={styles.code} data-part="code" style={{ '--i': 1 } as CSSProperties}>
          <span className={styles.value}>{code}</span>
          <button
            ref={copyButton}
            type="button"
            className={styles.copyButton}
            data-part="copy"
            data-copied={copied ? '' : undefined}
            onClick={() => void copy()}
          >
            <span className={styles.copyLabel} aria-hidden={copied}>
              Copy code
            </span>
            <span className={styles.copiedLabel} aria-hidden={!copied}>
              <svg viewBox="0 0 16 16" className={styles.check} aria-hidden="true">
                <path d="M3.5 8.5l3 3 6-7" />
              </svg>
              Copied
            </span>
          </button>
        </div>

        <div className={styles.actions} data-part="actions" style={{ '--i': 2 } as CSSProperties}>
          <Action href={downloadHref} part="download" primary>
            <svg viewBox="0 0 20 20" className={styles.icon} aria-hidden="true">
              <rect x="5" y="1.75" width="10" height="16.5" rx="2.6" />
              <path d="M10 6.5v6m-2.6-2.6L10 12.5l2.6-2.6" />
            </svg>
            Download the app
          </Action>
          <Action href={waitlistHref} part="waitlist">
            Join the web waitlist
          </Action>
        </div>

        <p className={styles.note} data-part="note" style={{ '--i': 3 } as CSSProperties}>
          On a computer? Copy your code, then open {appName} on your phone.
        </p>
      </div>

      <span className={styles.status} role="status" aria-live="polite">
        {message}
      </span>
      <DemoPointer
        ref={pointer}
        className={styles.pointer}
        arrowClass={styles.arrow}
        handClass={styles.hand}
      />
    </section>
  );
}

function Action({
  href,
  part,
  primary,
  children,
}: {
  href?: string;
  part?: string;
  primary?: boolean;
  children: ReactNode;
}) {
  const className = `${styles.action} ${primary ? styles.primary : ''}`;
  return href ? (
    <a className={className} href={href} data-part={part}>
      {children}
    </a>
  ) : (
    <button type="button" className={className} data-part={part}>
      {children}
    </button>
  );
}
```

### ghost-pass/pass-art.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import { useId } from 'react';
import styles from './ghost-pass.module.css';

/*
 * The pass, drawn: a ticket bent a little around a vertical axis, so its top and bottom edges bow
 * upwards. The body holds the art (a pastel sky with rays, a glowing ghost, two coins); past a
 * thin gap, the stub carries the issuer's mark, its left edge scalloped where it would tear off.
 *
 * Every outline is computed once here from a few numbers, in the drawing's own units (290 wide).
 * A darker copy of each outline, a few units lower, is the card's thickness. The rays, the coins,
 * the ghost's blink and the shine are groups the stylesheet moves by transforms.
 */

/** The drawing's width; the body ends at BODY, the stub starts at STUB. */
const W = 290;
const BODY = 216;
const STUB = 219;
/** The top and bottom edges at the two ends, and how much the middle bows up. */
const TOP = 13;
const BOTTOM = 211;
const BOW = 12;
/** Outer and inner corner radii. */
const OUTER = 20;
const INNER = 10;
/** The scallops on the stub's torn edge: how far each bulges, and roughly how tall each is. */
const TOOTH = 3.4;
const TOOTH_STEP = 10;
/** The thickness: how far the darker copy sits below and to the right. */
export const DEPTH = { x: 2.5, y: 8 };
export const VIEW = `0 0 ${W + 4} ${BOTTOM + 8}`;

const bow = (x: number) => BOW * (1 - ((x - W / 2) / (W / 2)) ** 2);
const top = (x: number) => TOP - bow(x);
const bottom = (x: number) => BOTTOM - bow(x);
const n = (value: number) => Number(value.toFixed(2));

/** A stretch of a bowed edge, from x0 to x1: one quadratic curve fits the parabola exactly. */
function edge(f: (x: number) => number, x0: number, x1: number) {
  const m = (x0 + x1) / 2;
  const control = 2 * f(m) - (f(x0) + f(x1)) / 2;
  return `Q${n(m)} ${n(control)} ${n(x1)} ${n(f(x1))}`;
}

/** A bowed rounded shape from x `a` to `b`; a scalloped left edge when `torn`. */
function outline(a: number, b: number, left: number, right: number, torn = false) {
  const parts = [
    `M${a} ${n(top(a) + left)}`,
    `Q${a} ${n(top(a))} ${n(a + left)} ${n(top(a + left))}`,
    edge(top, a + left, b - right),
    `Q${b} ${n(top(b))} ${b} ${n(top(b) + right)}`,
    `L${b} ${n(bottom(b) - right)}`,
    `Q${b} ${n(bottom(b))} ${n(b - right)} ${n(bottom(b - right))}`,
    edge(bottom, b - right, a + left),
    `Q${a} ${n(bottom(a))} ${a} ${n(bottom(a) - left)}`,
  ];
  if (torn) {
    const length = bottom(a) - left - (top(a) + left);
    const count = Math.round(length / TOOTH_STEP);
    const step = n(length / count);
    for (let i = 0; i < count; i += 1) parts.push(`a${TOOTH} ${step / 2} 0 0 1 0 -${step}`);
  }
  parts.push('Z');
  return parts.join('');
}

/** The middle of the body, seen from behind. */
const BACK_MIDDLE = W + DEPTH.x - BODY / 2;

const BODY_PATH = outline(0, BODY, OUTER, INNER);
const STUB_PATH = outline(STUB, W, INNER, OUTER, true);

/** Rays from behind the ghost's head: thin wedges, every 22.5°. */
const RAYS = Array.from({ length: 16 }, (_, i) => {
  const a = ((i * 22.5 - 90) * Math.PI) / 180;
  const half = (3.2 * Math.PI) / 180;
  const r = 260;
  const p = (angle: number) => `${n(150 + Math.cos(angle) * r)} ${n(78 + Math.sin(angle) * r)}`;
  return `M150 78L${p(a - half)}L${p(a + half)}Z`;
}).join('');

/** The ghost: a dome rising from below the card, and a cloudy arm at its lower left. */
const GHOST = 'M82 250V134c0-44 32-74 72-74s72 30 72 74v116z';
const ARM = 'M40 250c-9-34 5-70 33-74 11-16 32-19 44-6 9 10 10 26 1 38v42z';

/** The issuer's mark: a small ghost with a wavy hem, its eyes cut out. */
function Mark({ x, y, size, cut }: { x: number; y: number; size: number; cut: string }) {
  const s = size / 24;
  return (
    <g transform={`translate(${x} ${y}) scale(${s})`}>
      <path
        d="M2 22V11C2 5.5 6.5 1 12 1s10 4.5 10 10v11l-3.3-2.4L15.3 22 12 19.6 8.7 22l-3.4-2.4z"
        fill="#fff"
      />
      <ellipse cx="8.6" cy="11" rx="1.8" ry="2.3" fill={cut} />
      <ellipse cx="15.4" cy="11" rx="1.8" ry="2.3" fill={cut} />
    </g>
  );
}

/** The front of the pass. `name` is the issuer on the stub. */
export function PassFront({ name }: { name: string }) {
  const id = useId().replace(/:/g, '');
  const u = (key: string) => `url(#${id}${key})`;
  return (
    <svg className={styles.art} viewBox={VIEW} aria-hidden="true" data-part="front">
      <defs>
        <clipPath id={`${id}body`}>
          <path d={BODY_PATH} />
        </clipPath>
        <clipPath id={`${id}dome`}>
          <path d={GHOST} />
        </clipPath>
        <linearGradient
          id={`${id}arm`}
          gradientUnits="userSpaceOnUse"
          x1="0"
          y1="172"
          x2="0"
          y2="225"
        >
          <stop offset="0" stopColor="#fbf9ff" />
          <stop offset="0.5" stopColor="#e3ddfd" />
          <stop offset="1" stopColor="#bdb6f7" />
        </linearGradient>
        <clipPath id={`${id}stub`}>
          <path d={STUB_PATH} />
        </clipPath>
        <linearGradient id={`${id}sky`} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#f19ee6" />
          <stop offset="0.32" stopColor="#c29ef4" />
          <stop offset="0.62" stopColor="#a9a6f7" />
          <stop offset="1" stopColor="#c9d8fd" />
        </linearGradient>
        <radialGradient id={`${id}pink`} cx="0" cy="0.35" r="0.55">
          <stop offset="0" stopColor="#f6a6e4" stopOpacity="0.95" />
          <stop offset="1" stopColor="#f6a6e4" stopOpacity="0" />
        </radialGradient>
        <radialGradient id={`${id}ice`} cx="0.02" cy="1" r="0.5">
          <stop offset="0" stopColor="#c6ecfb" />
          <stop offset="1" stopColor="#c6ecfb" stopOpacity="0" />
        </radialGradient>
        <radialGradient id={`${id}halo`} cx="0.72" cy="0.38" r="0.55">
          <stop offset="0" stopColor="#fff" stopOpacity="0.25" />
          <stop offset="0.45" stopColor="#f3eaff" stopOpacity="0.08" />
          <stop offset="1" stopColor="#fff" stopOpacity="0" />
        </radialGradient>
        <radialGradient id={`${id}ghost`} gradientUnits="userSpaceOnUse" cx="150" cy="98" r="120">
          <stop offset="0" stopColor="#ffffff" />
          <stop offset="0.3" stopColor="#fffdf8" />
          <stop offset="0.55" stopColor="#fbf1fb" />
          <stop offset="0.8" stopColor="#f3efff" />
          <stop offset="1" stopColor="#e8e4ff" />
        </radialGradient>
        <linearGradient
          id={`${id}sheen`}
          gradientUnits="userSpaceOnUse"
          x1="90"
          y1="60"
          x2="230"
          y2="150"
        >
          <stop offset="0" stopColor="#ffd7f1" stopOpacity="0.4" />
          <stop offset="0.5" stopColor="#fff6d2" stopOpacity="0.18" />
          <stop offset="1" stopColor="#c9e3ff" stopOpacity="0.5" />
        </linearGradient>
        <linearGradient
          id={`${id}fade`}
          gradientUnits="userSpaceOnUse"
          x1="0"
          y1="148"
          x2="0"
          y2="225"
        >
          <stop offset="0" stopColor="#b9b2f7" stopOpacity="0" />
          <stop offset="1" stopColor="#b3aef7" stopOpacity="0.8" />
        </linearGradient>
        <linearGradient id={`${id}coin`} x1="0" y1="0" x2="0.4" y2="1">
          <stop offset="0" stopColor="#d6ffc0" />
          <stop offset="0.5" stopColor="#8ae86a" />
          <stop offset="1" stopColor="#56c944" />
        </linearGradient>
        <linearGradient id={`${id}tab`} x1="0" y1="0" x2="0.25" y2="1">
          <stop offset="0" stopColor="#8f92fc" />
          <stop offset="0.45" stopColor="#9a85fb" />
          <stop offset="1" stopColor="#b26bf9" />
        </linearGradient>
        <linearGradient id={`${id}gloss`} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#fff" stopOpacity="0.35" />
          <stop offset="0.35" stopColor="#fff" stopOpacity="0" />
          <stop offset="0.7" stopColor="#fff" stopOpacity="0" />
          <stop offset="1" stopColor="#fff" stopOpacity="0.18" />
        </linearGradient>
        <radialGradient id={`${id}shine`}>
          <stop offset="0" stopColor="#fff" stopOpacity="0.6" />
          <stop offset="1" stopColor="#fff" stopOpacity="0" />
        </radialGradient>
        <filter id={`${id}blur`} x="-30%" y="-30%" width="160%" height="160%">
          <feGaussianBlur stdDeviation="8" />
        </filter>
        <filter id={`${id}soft`} x="-10%" y="-10%" width="120%" height="120%">
          <feGaussianBlur stdDeviation="1.4" />
        </filter>
      </defs>

      {/* The thickness, under everything. */}
      <g transform={`translate(${DEPTH.x} ${DEPTH.y})`}>
        <path d={BODY_PATH} fill="#5e43da" />
        <path d={STUB_PATH} fill="#5636c4" />
      </g>

      <g clipPath={u('body')}>
        <rect width={BODY} height={BOTTOM} fill={u('sky')} />
        <rect width={BODY} height={BOTTOM} fill={u('pink')} />
        <rect width={BODY} height={BOTTOM} fill={u('ice')} />
        <g className={styles.rays}>
          <path d={RAYS} fill="#fff" opacity="0.2" />
        </g>
        <rect width={BODY} height={BOTTOM} fill={u('halo')} />

        <g className={styles.coins}>
          {[
            { x: 44, y: 62, r: -12, k: 'a' },
            { x: 56, y: 116, r: -20, k: 'b' },
          ].map((coin) => (
            <g key={coin.k} className={styles.coin} data-coin={coin.k}>
              <g transform={`translate(${coin.x} ${coin.y}) rotate(${coin.r})`}>
                <ellipse cx="0" cy="6" rx="24" ry="14" fill="#2f9a56" />
                <ellipse cx="0" cy="4" rx="24" ry="14" fill="#5bc95f" />
                <ellipse cx="0" cy="0" rx="24" ry="14" fill={u('coin')} />
                <ellipse
                  cx="0"
                  cy="0.4"
                  rx="16"
                  ry="6.8"
                  fill="none"
                  stroke="#c9ffb0"
                  strokeOpacity="0.7"
                  strokeWidth="1.4"
                />
                <ellipse cx="-8" cy="-6" rx="7" ry="2.4" fill="#fff" opacity="0.6" />
              </g>
            </g>
          ))}
        </g>

        <g className={styles.ghost} data-part="ghost">
          {/* The glow, then the ghost: a dome, and a cloudy arm at its lower left; its lower
              half sinks into the lavender. */}
          <g filter={u('blur')} opacity="0.9">
            <path d={GHOST} fill="#fff" />
            <path d={ARM} fill="#fff" />
          </g>
          <g filter={u('soft')}>
            <g clipPath={u('dome')}>
              <rect x="40" y="40" width="190" height="190" fill={u('ghost')} />
              <rect x="40" y="40" width="190" height="190" fill={u('sheen')} />
              <rect x="40" y="40" width="190" height="190" fill={u('fade')} />
            </g>
            {/* The arm, over the body, lit from above. */}
            <path d={ARM} fill={u('arm')} stroke="#cfc6fb" strokeOpacity="0.6" />
          </g>
          <g className={styles.eye}>
            <ellipse cx="104" cy="124" rx="6.4" ry="8.2" fill="#5f2f7f" />
          </g>
          <path
            d="M134 127q8-9.5 16 0"
            fill="none"
            stroke="#5f2f7f"
            strokeWidth="3.8"
            strokeLinecap="round"
          />
        </g>

        <rect width={BODY} height={BOTTOM} fill={u('gloss')} />
        <g className={styles.shine}>
          <ellipse cx="103" cy="96" rx="110" ry="80" fill={u('shine')} />
        </g>
      </g>

      <g clipPath={u('stub')}>
        <rect x={STUB - 4} width={W - STUB + 4} height={BOTTOM} fill={u('tab')} />
        <rect x={STUB - 4} width="12" height={BOTTOM} fill="#ded4ff" opacity="0.9" />
        <rect x={STUB + 8} width="10" height={BOTTOM} fill="#fff" opacity="0.1" />
        <g transform={`translate(${(STUB + W) / 2 + 2} ${n((TOP + BOTTOM) / 2)}) rotate(90)`}>
          <Mark x={-74} y={-30} size={27} cut="#9387fb" />
          <text x={-44} y={-2} className={styles.brand} fill="#fff" fontSize="46" fontWeight="700">
            {name}
          </text>
        </g>
        <rect x={STUB - 4} width={W - STUB + 4} height={BOTTOM} fill={u('gloss')} />
        <g className={styles.shine}>
          <ellipse cx="103" cy="96" rx="110" ry="80" fill={u('shine')} />
        </g>
      </g>
    </svg>
  );
}

/** The back of the pass: the stub's colours all over, and the mark large in the middle. */
export function PassBack({ name }: { name: string }) {
  const id = useId().replace(/:/g, '');
  return (
    <svg className={styles.art} viewBox={VIEW} aria-hidden="true" data-part="back">
      <defs>
        <linearGradient id={`${id}back`} x1="0" y1="0" x2="0.4" y2="1">
          <stop offset="0" stopColor="#9b8efc" />
          <stop offset="1" stopColor="#b06cf8" />
        </linearGradient>
      </defs>
      {/* Seen from behind, the stub is on the left: the outlines are mirrored, the words not. */}
      <g transform={`translate(${W + DEPTH.x} 0) scale(-1 1)`}>
        <g transform={`translate(${-DEPTH.x} ${DEPTH.y})`}>
          <path d={BODY_PATH} fill="#5e43da" />
          <path d={STUB_PATH} fill="#5636c4" />
        </g>
        <path d={BODY_PATH} fill={`url(#${id}back)`} />
        <path d={STUB_PATH} fill={`url(#${id}back)`} />
      </g>
      <Mark x={BACK_MIDDLE - 30} y={50} size={60} cut="#a07efa" />
      <text
        x={BACK_MIDDLE}
        y={150}
        textAnchor="middle"
        className={styles.brand}
        fill="#fff"
        fontSize="22"
        fontWeight="700"
        opacity="0.9"
      >
        {name}
      </text>
    </svg>
  );
}
```

### ghost-pass/ghost-pass.module.css

```css
/* Ghost pass (ghost-pass.tsx): a lavender wash fading to white down the stage, the pass floating
   at the top, the words, the code pill and the two buttons under it. At night the wash is a deep
   violet over near-black; the pass itself keeps its pastel colours.

   Every size is a multiple of --u: 1px on a stage 880px wide or more, scaling down with the
   stage's width (a container query) to 0.66px, under which the headline wraps instead.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page, or the one its colorScheme prop sets.

   Motion. The entrance is CSS animations keyed by data-phase (wait, intro, ready). The float, the
   rays, the coins and the blink are endless CSS animations, paused offscreen (data-paused) and
   off under reduced motion. The lean and the spin are a transform the script writes. */

.root {
  --surface: #fff;
  --wash: #e1dffd;
  --wash-2: #f3f1ff;
  --wash-3: #fdfcff;
  --wash-side: #d9d4fe;
  --ink: #252128;
  --muted: #64616b;
  --faint: #9c9aa2;
  --accent: #9078fa;
  --accent-press: #7f66f2;
  --accent-ink: #9079f9;
  --action-ink: #7a62ee;
  --chip: #f5f3f6;
  --soft: #f2f0fe;
  --soft-hover: #e9e5fd;
  --shadow: rgb(160 145 245 / 0.55);
  --ray: rgb(255 255 255 / 0.85);
  --line: rgb(0 0 0 / 0.06);
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --u: clamp(0.66px, 100cqi / 880, 1px);
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  overflow: hidden;
  overflow: clip;
  width: 100%;
  min-width: 0;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#fff, #111016);
    --wash: light-dark(#e1dffd, #2a2352);
    --wash-2: light-dark(#f3f1ff, #1d1936);
    --wash-3: light-dark(#fdfcff, #141220);
    --wash-side: light-dark(#d9d4fe, #31296a);
    --ink: light-dark(#252128, #f3f1f8);
    --muted: light-dark(#64616b, #aaa6b8);
    --faint: light-dark(#9c9aa2, #75717f);
    --accent: light-dark(#9078fa, #8a72f7);
    --accent-press: light-dark(#7f66f2, #7a61ef);
    --accent-ink: light-dark(#9079f9, #a996ff);
    --action-ink: light-dark(#7a62ee, #b8a8ff);
    --chip: light-dark(#f5f3f6, #1f1c29);
    --soft: light-dark(#f2f0fe, #251f40);
    --soft-hover: light-dark(#e9e5fd, #2e2750);
    --shadow: light-dark(rgb(160 145 245 / 0.55), rgb(0 0 0 / 0.6));
    --ray: light-dark(rgb(255 255 255 / 0.85), rgb(190 175 255 / 0.22));
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root[data-paused] *,
.root[data-paused] *::before,
.root[data-paused] *::after {
  animation-play-state: paused !important;
}

/* The backdrop: the lavender wash, and the burst of rays the entrance opens behind the pass. */
.backdrop {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
}
.wash {
  position: absolute;
  inset: 0 0 auto;
  height: calc(560 * var(--u));
  background:
    radial-gradient(
      calc(420 * var(--u)) calc(260 * var(--u)) at 0% 46%,
      var(--wash-side),
      transparent
    ),
    radial-gradient(
      calc(420 * var(--u)) calc(260 * var(--u)) at 100% 30%,
      var(--wash-side),
      transparent
    ),
    linear-gradient(
      180deg,
      var(--wash) 0%,
      var(--wash) 50%,
      var(--wash-2) 63%,
      var(--wash-3) 80%,
      var(--surface) 96%
    );
  opacity: 0.94;
  transform-origin: 50% 0;
  transition:
    transform 0.6s cubic-bezier(0.6, 0, 0.3, 1),
    opacity 0.6s ease;
}
.root[data-phase='wait'] .wash,
.root[data-phase='intro'] .wash {
  opacity: 0.55;
  transform: scaleY(0.5);
}
.burst {
  position: absolute;
  top: 50%;
  left: 50%;
  width: calc(1500 * var(--u));
  height: calc(1500 * var(--u));
  margin: calc(-750 * var(--u)) 0 0 calc(-750 * var(--u));
  background: repeating-conic-gradient(var(--ray) 0deg 3.6deg, transparent 3.6deg 15deg);
  mask-image:
    radial-gradient(closest-side, transparent 9%, #000 16%, #000 48%, transparent 92%),
    linear-gradient(180deg, #000 30%, transparent 58%);
  mask-composite: intersect;
  opacity: 0;
  filter: blur(calc(5 * var(--u)));
}
.root[data-phase='intro'] .burst,
.root[data-phase='ready']:not([data-instant]) .burst {
  animation: burst 1.65s cubic-bezier(0.3, 0, 0.4, 1) 0.3s both;
}
@keyframes burst {
  0% {
    opacity: 0;
    transform: scale(0.55) rotate(-6deg);
  }
  22% {
    opacity: 1;
  }
  62% {
    opacity: 0.9;
  }
  100% {
    opacity: 0;
    transform: scale(1.12) rotate(10deg);
  }
}

/* The pass: its place at the top, then the layers that move it. */
.slot {
  position: relative;
  z-index: 1;
  height: calc(336 * var(--u));
}
.flight {
  --w: calc(200 * var(--u));
  --h: calc(149 * var(--u));
  position: absolute;
  top: calc(140 * var(--u));
  left: 50%;
  width: var(--w);
  height: var(--h);
  margin: calc(var(--h) / -2) 0 0 calc(var(--w) / -2);
  transition: transform 0.42s cubic-bezier(0.55, 0, 0.3, 1);
}
.root[data-phase='intro'] .flight {
  transform: translateY(var(--drop, 0px)) scale(1.38);
}
.root[data-phase='wait'] .flight {
  opacity: 0;
}
.entry {
  display: block;
  width: 100%;
  height: 100%;
}
.root[data-phase='intro'] .entry {
  animation: enter 0.6s cubic-bezier(0.2, 0.85, 0.3, 1.15) 0.08s both;
}
@keyframes enter {
  0% {
    opacity: 0;
    transform: translate(calc(30 * var(--u)), calc(270 * var(--u))) rotate(-40deg) scale(0.4);
  }
  12% {
    opacity: 1;
  }
  100% {
    opacity: 1;
    transform: none;
  }
}
.float {
  width: 100%;
  height: 100%;
  animation: float 4.6s ease-in-out infinite;
}
@keyframes float {
  0%,
  100% {
    transform: translateY(0) rotate(0deg);
  }
  50% {
    transform: translateY(calc(-7 * var(--u))) rotate(1.4deg);
  }
}
.card {
  all: unset;
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  perspective: calc(700 * var(--u));
  cursor: grab;
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
}
.card:active {
  cursor: grabbing;
}
.card:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(14 * var(--u));
  border-radius: calc(28 * var(--u));
}
.tilt,
.pose,
.face {
  position: absolute;
  inset: 0;
  display: block;
  transform-style: preserve-3d;
}
.tilt {
  will-change: transform;
}
.pose {
  transform: rotateZ(-14deg) rotateY(-9deg) rotateX(6deg);
}
.face {
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}
.back {
  transform: rotateY(180deg);
}
.art {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  filter: drop-shadow(0 calc(10 * var(--u)) calc(14 * var(--u)) rgb(110 80 230 / 0.16));
}
.shadow {
  position: absolute;
  top: calc(281 * var(--u));
  left: 50%;
  width: calc(150 * var(--u));
  height: calc(16 * var(--u));
  margin-left: calc(-75 * var(--u));
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--shadow), transparent);
  animation: shadow 4.6s ease-in-out infinite;
  transition: opacity 0.4s ease;
}
.root[data-phase='wait'] .shadow,
.root[data-phase='intro'] .shadow {
  opacity: 0;
}
@keyframes shadow {
  0%,
  100% {
    transform: scaleX(1);
  }
  50% {
    transform: scaleX(0.88);
    opacity: 0.75;
  }
}

/* Inside the drawing: the rays turn, the coins bob, the open eye blinks, the shine follows the
   lean (custom properties the script writes on the tilt). */
.rays {
  transform-box: view-box;
  transform-origin: 150px 96px;
  animation: turn 48s linear infinite;
}
@keyframes turn {
  to {
    transform: rotate(360deg);
  }
}
.coin {
  animation: bob 3.4s ease-in-out infinite;
}
.coin[data-coin='b'] {
  animation-delay: -1.5s;
}
@keyframes bob {
  0%,
  100% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(-3.5px);
  }
}
.ghost {
  animation: hover 4.6s ease-in-out -1s infinite;
}
@keyframes hover {
  0%,
  100% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(-2.5px);
  }
}
.eye {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: blink 5.2s ease-in-out 1.6s infinite;
}
@keyframes blink {
  0%,
  92%,
  100% {
    transform: scaleY(1);
  }
  95% {
    transform: scaleY(0.12);
  }
}
.shine {
  transform: translate(calc(var(--shine-x, 0) * 1px), calc(var(--shine-y, 0) * 1px));
}
.brand {
  font-family: ui-rounded, 'SF Pro Rounded', 'Nunito', system-ui, sans-serif;
  letter-spacing: -0.02em;
}

/* The words. */
.copy {
  position: relative;
  z-index: 1;
  display: grid;
  justify-items: center;
  padding: 0 16px calc(45 * var(--u));
  text-align: center;
}
.headline {
  margin: 0;
  max-width: 100%;
  font-size: calc(48 * var(--u));
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.045em;
  text-wrap: balance;
  color: var(--ink);
}
.word {
  display: inline-block;
}
.word[data-accent] {
  color: var(--accent-ink);
}
.lede {
  margin: calc(10 * var(--u)) 0 0;
  max-width: calc(400 * var(--u));
  font-size: calc(18 * var(--u));
  line-height: calc(25.5 * var(--u));
  color: var(--muted);
  text-wrap: balance;
}
.code {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: calc(12 * var(--u));
  box-sizing: border-box;
  width: min(100%, calc(320 * var(--u)));
  height: calc(53 * var(--u));
  margin-top: calc(28 * var(--u));
  padding: 0 calc(8 * var(--u)) 0 calc(19 * var(--u));
  border-radius: 999px;
  background: var(--chip);
}
.value {
  font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
  font-size: calc(22 * var(--u));
  font-weight: 700;
  letter-spacing: 0.06em;
  color: var(--ink);
  user-select: all;
  -webkit-user-select: all;
}
.copyButton {
  display: grid;
  place-items: center;
  flex: none;
  width: calc(104 * var(--u));
  height: calc(37 * var(--u));
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: var(--accent);
  color: #fff;
  font: inherit;
  font-size: calc(15 * var(--u));
  font-weight: 500;
  cursor: pointer;
  transition:
    transform 0.16s ease,
    background-color 0.16s ease;
}
.copyButton > span {
  grid-area: 1 / 1;
  display: inline-flex;
  align-items: center;
  gap: calc(4 * var(--u));
  transition:
    opacity 0.2s ease,
    transform 0.25s cubic-bezier(0.3, 0.7, 0.2, 1);
}
.copyButton:hover {
  background: var(--accent-press);
}
.copyButton:active,
.copyButton[data-pressed] {
  transform: scale(0.95);
}
.copyButton:focus-visible,
.action:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.copiedLabel {
  opacity: 0;
  transform: translateY(40%);
}
.copyButton[data-copied] .copyLabel {
  opacity: 0;
  transform: translateY(-40%);
}
.copyButton[data-copied] .copiedLabel {
  opacity: 1;
  transform: none;
}
.check {
  width: calc(13 * var(--u));
  height: calc(13 * var(--u));
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: calc(12 * var(--u)) calc(15 * var(--u));
  margin-top: calc(23.5 * var(--u));
}
.action {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: calc(8 * var(--u));
  height: calc(44 * var(--u));
  padding: 0 calc(24 * var(--u));
  border: 0;
  border-radius: 999px;
  background: var(--soft);
  color: var(--action-ink);
  font: inherit;
  font-size: calc(17 * var(--u));
  font-weight: 500;
  white-space: nowrap;
  text-decoration: none;
  cursor: pointer;
  transition:
    transform 0.16s ease,
    background-color 0.16s ease;
}
.action:hover {
  background: var(--soft-hover);
}
.action:active {
  transform: scale(0.97);
}
.primary {
  background: var(--accent);
  color: #fff;
}
.primary:hover {
  background: var(--accent-press);
}
.icon {
  width: calc(19 * var(--u));
  height: calc(19 * var(--u));
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.note {
  margin: calc(19.5 * var(--u)) 0 0;
  max-width: calc(300 * var(--u));
  font-size: calc(14 * var(--u));
  line-height: calc(18.4 * var(--u));
  color: var(--faint);
}

/* The words come in once the pass has risen: the headline word by word, then the rest. */
.root:is([data-phase='wait'], [data-phase='intro']) :is(.word, .lede, .code, .actions, .note) {
  opacity: 0;
}
.root[data-phase='ready']:not([data-instant]) .word {
  animation: word 0.42s cubic-bezier(0.2, 0.7, 0.2, 1) calc(0.42s + var(--i) * 0.05s) both;
}
.root[data-phase='ready']:not([data-instant]) :is(.lede, .code, .actions, .note) {
  animation: rise 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) calc(1s + var(--i) * 0.11s) both;
}
@keyframes word {
  from {
    opacity: 0;
    filter: blur(calc(6 * var(--u)));
    transform: translateY(0.22em);
  }
}
@keyframes rise {
  from {
    opacity: 0;
    filter: blur(calc(4 * var(--u)));
    transform: translateY(calc(10 * var(--u)));
  }
}

.status {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The demo's pointer: sized here, one glyph shown at a time. */
.pointer {
  width: 22px;
  height: 22px;
}
.arrow,
.hand {
  visibility: hidden;
}
.pointer[data-glyph='arrow'] .arrow,
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

.root[data-reduced] :is(.float, .shadow, .rays, .coin, .ghost, .eye, .burst) {
  animation: none;
}
```

### ghost-pass/demo-pointer.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it (the pass, which
 * tilts towards it). The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

export type Point = { x: number; y: number };

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const glide: Demo['glide'] = (to, ms, along) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
        y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (ghost-pass.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over a button. It is
 * decorative: what it does shows on the pass itself. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### ghost-pass/use-autoplay.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input. `visible` and `reduced` are passed on too, so
 * the component can pause its own loops offscreen and keep still under reduced motion.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(false);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, visible, reduced, playing: visible && !reduced && !taken };
}
```