# Tear coupon

> Guest-pass stubs that peel off a ticket like stickers, then hand you a share sheet.

A ticket of guest passes on a phone screen, each pass a stub under a dotted perforation. Drag one sideways and the paper rolls up under your finger, showing its blank back and a soft shadow. Roll up a third of it and it tears away, its place reads Used, and a share sheet slides up; let go early and it lies back flat.

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

Category: Motion.

## Anatomy

1. **Ticket**: A header and three stubs on one sheet of paper, each piece casting its own shadow so a gone stub leaves a clean edge.
2. **Title**: The ticket’s name over the crowd art, which fades into the paper under it.
3. **Stub**: One pass under a dotted perforation: a button. Drag it sideways to peel, or tap, Enter or Space to peel it by itself.
4. **Offer**: What the pass gives, the same on every stub; it is the stub’s accessible description.
5. **Barcode and code**: A barcode drawn as one CSS gradient and the pass’s code under it, different on each stub.
6. **Curl**: The paper’s blank back, rolled up under the finger and shaded like a cylinder; it widens as it gathers paper, up to 30 points round.
7. **Used place**: Where a torn stub was: its guest line and Used, in faint ink.
8. **Restore**: Puts the pass back: it settles into its place from a little above, 420 ms.

## When to use

- Giving away a small, countable thing: guest passes, invites, referral codes, gift days.
- When the act of handing it over should feel deliberate and a little special, not like a menu item.
- Two to five items of the same kind, where seeing what is left (and what is used) matters.
- A touch-first screen, where a sideways drag is natural; a tap or Enter peels it too.

## When not to use

- Plain sharing of a link or a file. Use the system share sheet or a Copy link button.
- Many items, or items that differ: a list with a Share button on each row reads faster.
- Anything that can’t be undone or costs money. A playful peel hides the weight; use a confirmation.
- Dense dashboards or settings, where a 290 px phone mock-up and its motion take too much room.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/tear-coupon.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 { TearCoupon } from './tear-coupon';

export function GuestPasses() {
  return (
    <TearCoupon
      autoplay={false}
      onShare={(pass, target) => console.log(`Pass ${pass} shared: ${target}`)}
    />
  );
}
```

## States

- **All passes**: Three stubs on the ticket, flat. Each is a button that peels on a drag, a tap or a key.
- **Peeling**: Rolled up from the right edge under the finger. Let go before a third and it lays back flat.
- **Peeling from the left**: A drag to the right lifts the left edge instead; the curl and its shadow mirror.
- **One used**: Torn off: its place reads Used, with Restore. The stubs next to it round their corners.
- **Share sheet up**: Right after a tear the sheet slides up over a dim; its header names the pass.
- **All used**: Every pass given: the header stands alone, rounded on all four corners.

## How it works

- A press starts the roll where you touch, and dragging sideways rolls the paper up under the finger. The stub is cut away only between the press and the finger, so the rest of it stays flat on both sides.
- The roll is a fixed-size box of the paper’s blank back, shaded like a cylinder. It widens as it gathers paper and stretches down past the stub, the way paper lifted towards you would, with a soft shadow on the bare side. A drag writes a few CSS variables on the stub’s slot without re-rendering; the cut is a clip-path polygon.
- Let go with less than a third rolled up and it rolls back and lies flat. Let go past that, flick it, or drag the roll near the far edge, and it tears: the roll runs to the far edge, the rest of the stub goes into it, and it lifts off. A requestAnimationFrame loop runs only during the roll-back or the tear.
- After the tear, the stub’s place reads Used and has a Restore button, and the ticket’s corners and shadow re-form around the pieces left. The share sheet then slides up over a dimmed screen.
- The phone is a 390 × 844 screen measured in points times one unit, laid out at the size it is shown rather than scaled down with a transform. Only the stub being peeled, the open sheet and the pointer get layers of their own.
- Every colour is a light-dark() token on the stage, so it follows the page’s color-scheme, or the colorScheme prop. At night the phone’s app goes dark as an app’s own dark mode would: a warm near-black screen, the ticket on dark paper with light ink and a light barcode, and a dark share sheet; the crowd art stays as drawn.

## API reference

- **title** (`string`, default `'Cozy Crowd'`): The ticket’s name, under its art.
- **offer** (`string`, default `'30 days of cozy for free.'`): What each pass gives, on its stub.
- **defaultState** (`TearCouponState`): Opens in this state, held still until the reader acts on it: passes (each 'ready' or 'used', in order), sheet (the share sheet up for this pass, from 1, which is then used) and peel ({ pass, share, side? }: a pass held part-way peeled, share from 0 to 1, lifting from the right or left). Turns the demo off.
- **onTear** (`(pass: number) => void`): Called when a pass is torn off, with its number (from 1).
- **onShare** (`(pass: number, target: string) => void`): Called when a torn pass is shared, with its number and the target picked in the sheet.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a ghost pointer peels a pass, shares it and puts it back.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- Each stub is a real button (“Share guest pass 1”): click, Enter or Space peels it with the same motion as a drag. Horizontal drags use touch-action: pan-y, so a vertical swipe still scrolls the page.
- The share sheet is a modal dialog and the ticket behind it is inert. Opening it moves focus to Close, Escape closes it, and closing moves focus to that pass’s Restore button; after Restore, focus returns to the stub.
- Tears, shares and restores done by the reader are announced in a polite live region; the demo stays silent. Every button has a visible focus ring, drawn above the paper.
- Under reduced motion a tear is instant and there is no demo. Without JavaScript the full ticket renders.

## Source

### tear-coupon/tear-coupon.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import { Crowd } from './crowd';
import { DemoPointer, runDemo, type Demo } from './demo-pointer';
import { ShareSheet } from './share-sheet';
import { useAutoplay } from './use-autoplay';
import styles from './tear-coupon.module.css';

/*
 * Tear coupon: a ticket of guest passes on a phone screen. Each pass is a stub under a dotted
 * perforation; drag one sideways and it peels off like a sticker.
 *
 * - The paper curls up from the finger: from where the stub was pressed, it rolls up under the
 *   finger as it moves, so the stub is cut away between the two, while the rest of it stays flat
 *   on both sides. The roll shows the paper's blank back, shaded like a cylinder; it widens as it
 *   gathers paper and, lifted towards the eye, reaches down past the stub, with a soft shadow on
 *   the bare side.
 * - Let go before a third of the stub is rolled up (or before a quick flick) and it rolls back and
 *   lays flat. Past that, or once the roll nears the far edge, it tears away: the roll runs to the
 *   far edge as the rest of the stub goes into it, and lifts off; the stub's place reads "Used",
 *   and a share sheet slides up.
 * - The peel is a few CSS variables written on the stub's slot (where the cut begins and ends,
 *   where the roll and its shadow are, how wide and tall the roll is), set on each pointer move
 *   or, when it rolls back or tears, by a requestAnimationFrame loop that runs only then. The roll
 *   and its shadow are boxes of a fixed size, moved and stretched by transforms.
 * - Each stub is a button: a click, Enter or Space peels it with the same motion. A used stub's
 *   place has a Restore button that puts it back.
 *
 * The phone is a 390 × 844 screen whose measures are in points, a point being --u on the stage
 * (0.69 px). The peel works in points too: pointer moves are divided by the size of a point on
 * the screen, and the variables are written in px.
 *
 * Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 * color-scheme, or the one the colorScheme prop sets. At night the app on the phone goes dark
 * with it; the crowd art, the phone and the share sheet's pictures stay as they are.
 */

type Phase = 'ready' | 'torn' | 'used';

/**
 * A state to open in, held still: the demo is off. Passes are numbered from 1.
 */
export type TearCouponState = {
  /** Each pass, in order: on the ticket, or used (its place reads Used, with Restore). */
  passes?: ('ready' | 'used')[];
  /** The share sheet up for this pass, which is then used. */
  sheet?: number;
  /** A pass held part-way peeled: how much of it is rolled up (0–1), and which edge lifts. */
  peel?: { pass: number; share: number; side?: 'right' | 'left' };
};

/** How many passes the ticket carries. */
const PASSES = 3;
/** A stub's width and height, in points. */
const WIDTH = 348;
const STUB = 78;
/** The roll's widest radius, in points: its box is twice that wide. */
const CURL = 30;
/** How far the roll reaches below the stub at its widest, in points: lifted towards the eye. */
const REACH = 56;
/** Half the width of the roll's shadow box, in points. */
const CAST = CURL + 10;
/**
 * When it tears away: let go with TEAR_AT of the stub rolled up, or drag the roll past TEAR_NOW
 * of the way to the far edge (with at least TEAR_MIN rolled up).
 */
const TEAR_AT = 0.3;
const TEAR_NOW = 0.86;
const TEAR_MIN = 0.06;
/** A flick: let go moving at least this fast (points per ms) with FLICK_FROM rolled up. */
const FLICK = 0.6;
const FLICK_FROM = 0.1;
/** How far a press moves sideways before it peels, in px. Up or down first, the page scrolls. */
const DRAG_START = 5;

/** Each pass's code, printed under its barcode. */
const CODES = ['01 · 4K7 · Q2MX', '02 · 9TD · H5RW', '03 · 7BZ · C8NF'];

/** A barcode as a CSS gradient: guard bars, then bars and gaps of one to three units. */
function barcode(seed: number) {
  let state = seed * 9301 + 49297;
  const random = () => {
    state = (state * 9301 + 49297) % 233280;
    return state / 233280;
  };
  const runs = [1, 1, 1];
  while (runs.length < 34) runs.push(1 + Math.floor(random() * (runs.length % 2 ? 2 : 3)));
  runs.push(1, 1, 1);
  const total = runs.reduce((sum, run) => sum + run, 0);
  let at = 0;
  const stops = runs.map((run, i) => {
    const from = ((at / total) * 100).toFixed(2);
    at += run;
    const to = ((at / total) * 100).toFixed(2);
    return `${i % 2 ? 'transparent' : 'currentColor'} ${from}% ${to}%`;
  });
  return `linear-gradient(90deg, ${stops.join(', ')})`;
}
const BARCODES = CODES.map((_, i) => barcode(i + 3));

const pad = (n: number) => String(n).padStart(2, '0');

/** A peel in progress: a press on a stub, by a pointer, the keyboard or the demo. */
type Peel = {
  index: number;
  /** The pointer, or -1 for the keyboard and the demo. */
  pointerId: number;
  /** Where the press began, on the screen. */
  startX: number;
  startY: number;
  /** The stub's place on the screen when pressed, and the size of a point there, in px. */
  rect: DOMRect;
  scale: number;
  /** Which edge lifts: 1 the right one (the drag goes left), -1 the left one; 0 until it moves. */
  side: 1 | -1 | 0;
  /** Where the press began, in points from the lifting edge: the roll starts there. */
  from: number;
  /** Where the roll is now, under the finger, in points from the lifting edge. */
  peeled: number;
  /** How fast it peels, in points per ms, from the last moves. */
  speed: number;
  time: number;
  /** The demo's press: silent. */
  demo: boolean;
};

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const easeIn = (t: number) => t * t * t;
const easeOut = (t: number) => 1 - (1 - t) ** 3;
const easeInOut = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);

export function TearCoupon({
  title = 'Cozy Crowd',
  offer = '30 days of cozy for free.',
  onTear,
  onShare,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The ticket's name, under its art. */
  title?: string;
  /** What each pass gives, on its stub. */
  offer?: string;
  /** Called when a pass is torn off, with its number (from 1). */
  onTear?: (pass: number) => void;
  /** Called when a torn pass is shared, with its number and the target picked in the sheet. */
  onShare?: (pass: number, target: string) => void;
  /** While on screen and untouched, a ghost pointer peels a pass, shares it and puts it back. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens in this state, held still until the reader acts on it; turns the demo off. */
  defaultState?: TearCouponState;
  /** Extra class for the root, a stage of its own. */
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(8000);
  /** The opening sheet's pass, from 0, when defaultState asks for one. */
  const startSheet =
    defaultState?.sheet && defaultState.sheet >= 1 && defaultState.sheet <= PASSES
      ? defaultState.sheet - 1
      : null;
  const [phases, setPhases] = useState<Phase[]>(() =>
    Array.from({ length: PASSES }, (_, i) =>
      i === startSheet || defaultState?.passes?.[i] === 'used' ? 'used' : 'ready',
    ),
  );
  /** The pass whose share sheet is open, and the last one shared (the sheet's header names it). */
  const [sheet, setSheet] = useState<number | null>(startSheet);
  const [shared, setShared] = useState(startSheet ?? PASSES - 1);
  const [message, setMessage] = useState('');
  /** Keyboard focus is on the stage: the ghost leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);

  const slots = useRef<(HTMLDivElement | null)[]>([]);
  const stubs = useRef<(HTMLButtonElement | null)[]>([]);
  const papers = useRef<(HTMLSpanElement | null)[]>([]);
  const curls = useRef<(HTMLSpanElement | null)[]>([]);
  const restores = useRef<(HTMLButtonElement | null)[]>([]);
  const closeButton = useRef<HTMLButtonElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const peel = useRef<Peel | null>(null);
  /** The spring back or the tear running now, which `settle()` can finish at once. */
  const motion = useRef<{ frame: number; finish: () => void } | null>(null);
  const flights = useRef(new Set<Animation>());
  const timers = useRef(new Set<number>());
  /** The last press dragged: the click that follows it is not a tap. */
  const dragged = useRef(false);
  /** Where the reader's focus goes after the next render: the sheet, a Restore button, a stub. */
  const focusNext = useRef<{ to: 'sheet' | 'restore' | 'stub'; index: number } | null>(null);
  /** The phases and the open sheet, for the handlers and the demo between renders. */
  const phasesNow = useRef(phases);
  const sheetNow = useRef(sheet);
  useLayoutEffect(() => {
    phasesNow.current = phases;
    sheetNow.current = sheet;
  }, [phases, sheet]);
  const stopGhost = useRef<(() => void) | null>(null);
  const id = useId();

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

  const later = (ms: number, run: () => void) => {
    const timer = window.setTimeout(() => {
      timers.current.delete(timer);
      run();
    }, ms);
    timers.current.add(timer);
  };

  const setPhase = (index: number, phase: Phase) =>
    setPhases((now) => now.map((was, i) => (i === index ? phase : was)));

  /**
   * Draws stub `index` rolled up from `from` to `to`, both in points from its `side` edge: the
   * paper between them is cut away and gathered into the roll at `to`.
   */
  const draw = (index: number, side: 1 | -1, to: number, from = 0) => {
    const slot = slots.current[index];
    if (!slot) return;
    // The size of a point, in px.
    const unit = slot.offsetWidth / WIDTH;
    const px = (points: number) => `${(points * unit).toFixed(2)}px`;
    const start = Math.max(0, from);
    const end = Math.max(start, to);
    const gathered = end - start;
    // The roll grows quickly as it gathers paper, but never wider than what is up.
    const radius = Math.min(CURL, 6 + 42 * Math.sqrt(gathered / WIDTH), 1.5 + gathered / 2);
    // The roll's middle, from the left edge.
    const middle = side === 1 ? WIDTH - end : end;
    const peeling = gathered > 0.25;
    if (slot.hasAttribute('data-peeling') !== peeling)
      slot.toggleAttribute('data-peeling', peeling);
    const edge = side === 1 ? 'right' : 'left';
    if (slot.dataset.side !== edge) slot.dataset.side = edge;
    slot.style.setProperty('--cut', px(end));
    // A cut from the very edge leaves no paper there: its boundary goes out of the box.
    slot.style.setProperty('--from', px(start > 0.5 ? start : -40));
    slot.style.setProperty('--curl-x', px(middle - radius));
    slot.style.setProperty('--curl-s', (radius / CURL).toFixed(4));
    // The roll box is the stub's height plus REACH; a thin roll stays within the stub.
    const reach = (REACH * Math.max(0, radius - 4)) / (CURL - 4);
    slot.style.setProperty('--curl-h', ((STUB + reach) / (STUB + REACH)).toFixed(4));
    // Its shadow falls a little on the bare side.
    slot.style.setProperty('--cast-x', px(middle + side * radius * 0.6 - CAST));
    slot.style.setProperty('--cast-s', ((radius + 10) / CAST).toFixed(4));
    slot.style.setProperty('--bend-x', px(side === 1 ? middle - 18 : middle));
    slot.style.setProperty('--lift', Math.min(1, gathered / 36).toFixed(3));
  };

  // A peel asked for by defaultState is drawn once, where the reader can take it from.
  const startPeel = defaultState?.peel;
  useLayoutEffect(() => {
    if (!startPeel) return;
    const index = startPeel.pass - 1;
    if (phasesNow.current[index] !== 'ready') return;
    const share = Math.min(TEAR_NOW - 0.01, Math.max(0, startPeel.share));
    draw(index, startPeel.side === 'left' ? -1 : 1, share * WIDTH);
    // Drawn once, on mount.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  /** Lays stub `index` flat at once, with no peel drawn. */
  const flatten = (index: number) => {
    const slot = slots.current[index];
    if (!slot) return;
    slot.removeAttribute('data-peeling');
    slot.style.setProperty('--cut', '0px');
    slot.style.setProperty('--from', '0px');
  };

  /** Finishes the spring back or the tear in progress at once. */
  const settle = () => {
    const running = motion.current;
    if (!running) return;
    cancelAnimationFrame(running.frame);
    motion.current = null;
    running.finish();
  };

  /**
   * Plays the peel of stub `index` from one roll to another, each [where the roll is, where it
   * began] in points from the `side` edge, over `ms`, then runs `done`.
   */
  const play = (
    index: number,
    side: 1 | -1,
    [to0, from0]: [number, number],
    [to1, from1]: [number, number],
    ms: number,
    ease: (t: number) => number,
    done: () => void,
  ) => {
    settle();
    const start = performance.now();
    const running = {
      frame: 0,
      finish: () => {
        draw(index, side, to1, from1);
        done();
      },
    };
    const tick = (now: number) => {
      // A frame's time can come before `start`: clamped at both ends.
      const t = Math.min(1, Math.max(0, (now - start) / ms));
      const k = ease(t);
      draw(index, side, to0 + (to1 - to0) * k, from0 + (from1 - from0) * k);
      if (t < 1) running.frame = requestAnimationFrame(tick);
      else {
        motion.current = null;
        done();
      }
    };
    running.frame = requestAnimationFrame(tick);
    motion.current = running;
  };

  /** The sheet for pass `index` comes up; the reader's focus goes into it. */
  const openSheet = (index: number, byReader: boolean) => {
    if (byReader) focusNext.current = { to: 'sheet', index };
    setShared(index);
    setSheet(index);
  };

  const closeSheet = (byReader: boolean) => {
    const index = sheetNow.current;
    if (index === null) return;
    if (byReader) focusNext.current = { to: 'restore', index };
    setSheet(null);
  };

  /**
   * Tears stub `index` away, its roll at `peeled` and begun at `from` (points from the `side`
   * edge): the rest of the stub goes into the roll, which lifts off; the place reads Used.
   */
  const tear = (index: number, side: 1 | -1, peeled: number, from: number, byReader: boolean) => {
    const slot = slots.current[index];
    if (!slot || phasesNow.current[index] !== 'ready') return;
    phasesNow.current = phasesNow.current.map((was, i) => (i === index ? 'torn' : was));
    setPhase(index, 'torn');
    if (byReader) {
      announce(`Guest pass ${index + 1} torn off. Choose who gets it.`);
      onTear?.(index + 1);
    }
    const done = () => {
      flatten(index);
      setPhase(index, 'used');
      later(reducedMotion() ? 0 : 140, () => openSheet(index, byReader));
    };
    if (reducedMotion()) return done();
    // The roll runs to the far edge and past it, taking the paper on both sides, then lifts off.
    const end = WIDTH + CURL;
    const rest = 1 - (peeled - from) / WIDTH;
    play(index, side, [peeled, from], [end, 0], 120 + 260 * rest, easeIn, () => {
      const curl = curls.current[index];
      if (!curl) return done();
      const unit = slot.offsetWidth / WIDTH;
      const x = (side === 1 ? -2 * CURL : WIDTH) * unit;
      const flight = curl.animate(
        [
          { transform: `translateX(${x}px) scaleX(1)`, opacity: 1 },
          {
            transform: `translate(${x - side * 30 * unit}px, ${-34 * unit}px) rotate(${-side * 14}deg) scale(1.15)`,
            opacity: 0,
          },
        ],
        { duration: 380, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)', fill: 'forwards' },
      );
      flights.current.add(flight);
      slot.setAttribute('data-flying', '');
      flight.finished
        .then(() => {
          slot.removeAttribute('data-flying');
          done();
        })
        .catch(() => slot.removeAttribute('data-flying'))
        .finally(() => {
          flights.current.delete(flight);
          flight.cancel();
        });
    });
  };

  /** Lays stub `index` back flat: its roll at `peeled` unrolls back to `from`, where it began. */
  const layBack = (index: number, side: 1 | -1, peeled: number, from: number) => {
    const gathered = peeled - from;
    if (reducedMotion() || gathered < 0.5) return flatten(index);
    play(index, side, [peeled, from], [from, from], 220 + gathered * 0.9, easeOut, () =>
      flatten(index),
    );
  };

  /** Peels stub `index` by itself, as a hand would, and tears it: a click, Enter or Space. */
  const peelByItself = (index: number, byReader: boolean) => {
    const slot = slots.current[index];
    if (!slot || phasesNow.current[index] !== 'ready' || peel.current) return;
    if (reducedMotion()) return tear(index, 1, WIDTH, 0, byReader);
    const reach = WIDTH * 0.62;
    play(index, 1, [0, 0], [reach, 0], 520, easeInOut, () => tear(index, 1, reach, 0, byReader));
  };

  /** Puts pass `index` back on the ticket. */
  const restore = (index: number, byReader: boolean) => {
    if (phasesNow.current[index] !== 'used') return;
    setPhase(index, 'ready');
    flatten(index);
    const paper = papers.current[index];
    if (paper && !reducedMotion()) {
      // It settles back into its place from a little above.
      const drop = paper.animate(
        [
          { transform: 'translateY(-10px) scale(1.02)', opacity: 0 },
          { transform: 'none', opacity: 1 },
        ],
        { duration: 420, easing: 'cubic-bezier(0.25, 1, 0.3, 1)' },
      );
      flights.current.add(drop);
      void drop.finished.catch(() => undefined).finally(() => flights.current.delete(drop));
    }
    if (byReader) {
      announce(`Guest pass ${index + 1} restored.`);
      focusNext.current = { to: 'stub', index };
    }
  };

  /* The peel under a pointer, or the demo's. */

  const begin = (
    index: number,
    clientX: number,
    clientY: number,
    pointerId: number,
    demo: boolean,
  ) => {
    const slot = slots.current[index];
    if (!slot || peel.current || phasesNow.current[index] !== 'ready') return false;
    settle();
    const rect = slot.getBoundingClientRect();
    peel.current = {
      index,
      pointerId,
      startX: clientX,
      startY: clientY,
      rect,
      scale: rect.width / WIDTH || 1,
      side: 0,
      from: 0,
      peeled: 0,
      speed: 0,
      time: performance.now(),
      demo,
    };
    return true;
  };

  /** The press moves to `clientX`: the curl follows. Returns false when the press is dropped. */
  const follow = (clientX: number, clientY: number) => {
    const current = peel.current;
    if (!current) return false;
    const { rect, scale } = current;
    const x = (clientX - rect.left) / scale;
    if (current.side === 0) {
      const dx = clientX - current.startX;
      const dy = clientY - current.startY;
      // Up or down first: the page scrolls.
      if (!current.demo && Math.abs(dy) > DRAG_START && Math.abs(dy) > Math.abs(dx)) {
        peel.current = null;
        return false;
      }
      if (Math.abs(dx) < DRAG_START) return true;
      // The drag goes left: the right edge lifts, and the other way round.
      current.side = dx < 0 ? 1 : -1;
      const start = (current.startX - rect.left) / scale;
      current.from = Math.min(WIDTH, Math.max(0, current.side === 1 ? WIDTH - start : start));
      current.peeled = current.from;
      dragged.current = true;
    }
    const side = current.side;
    // The finger's distance from the lifting edge: the roll is under it, from where it began.
    const away = side === 1 ? WIDTH - x : x;
    const peeled = Math.min(WIDTH, Math.max(current.from, away));
    const now = performance.now();
    const elapsed = Math.max(1, now - current.time);
    current.speed = current.speed * 0.4 + ((peeled - current.peeled) / elapsed) * 0.6;
    current.time = now;
    current.peeled = peeled;
    settle();
    if (peeled / WIDTH >= TEAR_NOW && (peeled - current.from) / WIDTH >= TEAR_MIN) {
      peel.current = null;
      tear(current.index, side, peeled, current.from, !current.demo);
      return false;
    }
    draw(current.index, side, peeled, current.from);
    return true;
  };

  /** The press ends: past the threshold (or flicked) it tears, otherwise it lays back. */
  const release = () => {
    const current = peel.current;
    if (!current) return;
    peel.current = null;
    if (current.side === 0) return;
    const share = (current.peeled - current.from) / WIDTH;
    const flicked = current.speed > FLICK && share > FLICK_FROM;
    if (share >= TEAR_AT || flicked)
      tear(current.index, current.side, current.peeled, current.from, !current.demo);
    else layBack(current.index, current.side, current.peeled, current.from);
  };

  const onPointerDown = (index: number) => (event: PointerEvent<HTMLButtonElement>) => {
    if (event.button !== 0) return;
    dragged.current = false;
    if (!begin(index, event.clientX, event.clientY, event.pointerId, false)) return;
    if (event.pointerType === 'mouse') event.currentTarget.setPointerCapture(event.pointerId);
  };
  const onPointerMove = (event: PointerEvent<HTMLButtonElement>) => {
    const current = peel.current;
    if (!current || current.pointerId !== event.pointerId) return;
    const before = current.side;
    if (follow(event.clientX, event.clientY) && before === 0 && current.side !== 0) {
      // A touch peels from now on, wherever it goes.
      event.currentTarget.setPointerCapture(event.pointerId);
    }
  };
  const onPointerUp = (event: PointerEvent<HTMLButtonElement>) => {
    if (peel.current?.pointerId === event.pointerId) release();
  };
  const onPointerCancel = (event: PointerEvent<HTMLButtonElement>) => {
    const current = peel.current;
    if (!current || current.pointerId !== event.pointerId) return;
    peel.current = null;
    if (current.side !== 0) layBack(current.index, current.side, current.peeled, current.from);
  };

  const pick = (target: string) => {
    const index = sheetNow.current;
    if (index === null) return;
    announce(`Guest pass ${index + 1} shared: ${target}.`);
    onShare?.(index + 1, target);
    closeSheet(true);
  };

  // The focus follows what the reader opens, closes or puts back, once it is on screen.
  useEffect(() => {
    const next = focusNext.current;
    if (!next) return;
    focusNext.current = null;
    if (next.to === 'sheet') closeButton.current?.focus();
    else if (next.to === 'restore') restores.current[next.index]?.focus();
    else stubs.current[next.index]?.focus();
  }, [sheet, phases]);

  // Everything running stops with the component.
  useEffect(() => {
    const runningFlights = flights.current;
    const runningTimers = timers.current;
    return () => {
      if (motion.current) cancelAnimationFrame(motion.current.frame);
      motion.current = null;
      runningFlights.forEach((flight) => flight.cancel());
      runningTimers.forEach((timer) => window.clearTimeout(timer));
    };
  }, []);

  /* The demo: the ghost peels the last pass a little and lets go, then peels it off, closes the
     sheet and puts the pass back. */
  const demo = autoplay && !defaultState && playing && !keyboard;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const index = PASSES - 1;
    /** Lets go of a demo press in progress: the stub lays back. */
    const drop = () => {
      const current = peel.current;
      if (!current?.demo) return;
      peel.current = null;
      if (current.side !== 0) layBack(current.index, current.side, current.peeled, current.from);
    };
    /** Grabs the stub at `at` (shares of its size) and drags it to `to`, over `ms`. */
    const grab = async (
      { wait, moveToPoint, drag }: Demo,
      at: number,
      to: number,
      ms: number,
      hold: number,
    ) => {
      const slot = slots.current[index];
      if (!slot) return;
      const box = slot.getBoundingClientRect();
      const start = { x: box.left + box.width * at, y: box.top + box.height * 0.55 };
      const end = { x: box.left + box.width * to, y: start.y + box.height * 0.06 };
      await moveToPoint(start);
      await wait(140);
      begin(index, start.x, start.y, -1, true);
      await drag(end, ms, (share) =>
        follow(start.x + (end.x - start.x) * share, start.y + (end.y - start.y) * share),
      );
      await wait(hold);
      release();
    };

    const stop = runDemo(stage, arrow, async (steps) => {
      const { wait, moveTo, press } = steps;
      await wait(900);
      for (;;) {
        // Whatever the reader left open or used goes back first.
        if (sheetNow.current !== null) {
          await moveTo(closeButton.current);
          await press(() => closeSheet(false));
          await wait(700);
        }
        for (let i = 0; i < PASSES; i += 1) {
          while (phasesNow.current[i] === 'torn') await wait(150);
          if (phasesNow.current[i] !== 'used') continue;
          await moveTo(restores.current[i]);
          await press(() => restore(i, false));
          await wait(600);
        }
        // A short peel, let go: it lays back flat.
        await grab(steps, 0.86, 0.64, 620, 280);
        await wait(900);
        // All the way: it tears off, and the sheet comes up.
        await grab(steps, 0.9, 0.08, 1150, 120);
        for (let tries = 0; sheetNow.current === null && tries < 25; tries += 1) await wait(120);
        await wait(1900);
        await moveTo(closeButton.current);
        await press(() => closeSheet(false));
        await wait(900);
        await moveTo(restores.current[index]);
        await press(() => restore(index, false));
        await wait(2200);
      }
    });
    stopGhost.current = () => {
      stop();
      drop();
    };
    return () => {
      stop();
      drop();
    };
    // The handlers read refs only; the demo restarts when it may play or not.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo, root]);

  /** The reader takes over: the ghost stops at once and waits until they leave it alone. */
  const readerActs = () => {
    stopGhost.current?.();
    take();
  };

  const onFocus = (event: FocusEvent<HTMLDivElement>) => {
    if (event.target.matches(':focus-visible')) setKeyboard(true);
  };
  const onBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget)) setKeyboard(false);
  };
  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === 'Escape' && sheetNow.current !== null) {
      event.preventDefault();
      closeSheet(true);
    }
  };

  const ready = (index: number) => index < 0 || phases[index] === 'ready';
  const label = `${title} guest pass ${pad(shared + 1)} · ${offer}`;

  return (
    <div
      ref={root}
      className={`${styles.stage} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onKeyDown={onKeyDown}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      <div className={styles.phone} data-part="phone">
        <div className={styles.screen}>
          <div className={styles.page} inert={sheet !== null} data-part="page">
            <p className={styles.behind} aria-hidden="true">
              Every pass lets a friend try all six themes for a month, on us.
              <br />
              Passes you keep come back to you next year.
            </p>
            <svg className={styles.dismiss} viewBox="0 0 16 16" aria-hidden="true">
              <path d="m3 3 10 10M13 3 3 13" />
            </svg>
            <p className={styles.heading}>Share a little cozy</p>

            <div
              className={styles.card}
              data-part="ticket"
              data-head-bottom={!ready(0) || undefined}
            >
              <span className={`${styles.shadow} ${styles.headShadow}`} aria-hidden="true" />
              <div className={styles.head} data-part="head">
                <Crowd className={styles.art} />
                <span className={styles.fade} aria-hidden="true" />
                <p className={styles.title} data-part="title">
                  {title}
                </p>
                <p className={styles.subtitle}>Six playful themes, free for a friend to try.</p>
              </div>

              {phases.map((phase, index) => (
                <div
                  key={index}
                  ref={(element) => {
                    slots.current[index] = element;
                  }}
                  className={styles.slot}
                  data-part="slot"
                  data-pass={index + 1}
                  data-phase={phase}
                  data-top={!ready(index - 1) || undefined}
                  data-bottom={index === PASSES - 1 || !ready(index + 1) || undefined}
                >
                  <div className={styles.ghost} data-part="used">
                    <span className={styles.guest}>
                      GUEST {pad(index + 1)} / {pad(PASSES)}
                    </span>
                    <span className={styles.used}>Used</span>
                    <button
                      type="button"
                      ref={(element) => {
                        restores.current[index] = element;
                      }}
                      className={styles.restore}
                      data-part="restore"
                      aria-label={`Restore guest pass ${index + 1}`}
                      onClick={() => restore(index, true)}
                    >
                      Restore
                    </button>
                  </div>
                  <button
                    type="button"
                    ref={(element) => {
                      stubs.current[index] = element;
                    }}
                    className={styles.stub}
                    data-part="stub"
                    aria-label={`Share guest pass ${index + 1}`}
                    aria-describedby={`${id}-offer`}
                    onPointerDown={onPointerDown(index)}
                    onPointerMove={onPointerMove}
                    onPointerUp={onPointerUp}
                    onPointerCancel={onPointerCancel}
                    onLostPointerCapture={(event) => {
                      // A touch is first captured by the element under the finger: its loss,
                      // when the stub takes the capture, is not the end of the press.
                      if (event.target === event.currentTarget && peel.current?.side) release();
                    }}
                    onClick={(event) => {
                      // A click that ends a drag is not a tap (a key's click has no detail).
                      if (event.detail > 0 && dragged.current) return;
                      peelByItself(index, true);
                    }}
                  >
                    <span className={styles.shadow} aria-hidden="true" />
                    <span
                      ref={(element) => {
                        papers.current[index] = element;
                      }}
                      className={styles.paper}
                      data-part="paper"
                      aria-hidden="true"
                    >
                      <span
                        className={styles.offer}
                        data-part="offer"
                        id={index ? undefined : `${id}-offer`}
                      >
                        {offer}
                      </span>
                      <span className={styles.guest}>
                        GUEST {pad(index + 1)} / {pad(PASSES)}
                      </span>
                      <span
                        className={styles.bars}
                        data-part="barcode"
                        style={{ backgroundImage: BARCODES[index] }}
                      />
                      <span className={styles.code} data-part="code">
                        {CODES[index]}
                      </span>
                      <span className={styles.bend} />
                    </span>
                    <span className={styles.cast} aria-hidden="true" />
                    <span
                      ref={(element) => {
                        curls.current[index] = element;
                      }}
                      className={styles.curl}
                      data-part="curl"
                      aria-hidden="true"
                    />
                  </button>
                </div>
              ))}
            </div>

            <p className={styles.note}>
              Each pass gives a friend 30 days of cozy, free. You can share three a year.
            </p>
            <p className={styles.small}>Passes are good for 60 days.</p>
          </div>

          <span
            className={styles.scrim}
            data-open={sheet !== null || undefined}
            aria-hidden="true"
            onClick={() => closeSheet(true)}
          />
          <ShareSheet
            open={sheet !== null}
            label={label}
            onClose={() => closeSheet(true)}
            onPick={pick}
            closeRef={closeButton}
          />

          <div className={styles.status} aria-hidden="true">
            <span className={styles.time}>9:37</span>
            <span className={styles.island} />
            <svg className={styles.signal} viewBox="0 0 70 14">
              <rect x="0" y="9" width="3.2" height="4.4" rx="1" />
              <rect x="4.8" y="6.6" width="3.2" height="6.8" rx="1" />
              <rect x="9.6" y="4" width="3.2" height="9.4" rx="1" />
              <rect x="14.4" y="1.4" width="3.2" height="12" rx="1" />
              <path d="M23.2 4.6a10.4 10.4 0 0 1 14.6 0l-1.5 1.6a8.2 8.2 0 0 0-11.6 0zm2.9 3a6.3 6.3 0 0 1 8.8 0l-1.6 1.6a4 4 0 0 0-5.6 0zm4.4 5.4-2.3-2.4a3.3 3.3 0 0 1 4.6 0z" />
              <rect
                x="42.5"
                y="1.5"
                width="23"
                height="11"
                rx="3.4"
                fill="none"
                stroke="currentColor"
                opacity="0.4"
              />
              <rect x="44.5" y="3.5" width="16.5" height="7" rx="1.8" />
              <path d="M67 5.3v3.4a1.9 1.9 0 0 0 0-3.4z" opacity="0.45" />
            </svg>
          </div>
          <span className={styles.home} aria-hidden="true" />
        </div>
      </div>

      <p className={styles.hidden} role="status">
        {message}
      </p>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer ref={pointer} className={styles.pointer} />
      </div>
    </div>
  );
}
```

### tear-coupon/tear-coupon.module.css

```css
/* Tear coupon (tear-coupon.tsx): a light stage with a phone in its middle, or at night a
   near-black one. The phone is a 390 × 844 screen in a 414 × 868 body, and every measure inside
   it is a number of the screen's points times --u, the size of one point on the stage. It is
   laid out at the size it is shown, not scaled down by a transform, so that what moves inside it
   is drawn at that size too.

   Memory and layout. The stage is one layout boundary of a fixed height, so nothing that changes
   inside it lays out the page around it. What moves stays off the page's tiles: a stub being
   peeled is a small layer of its own, with its curl and the curl's shadow (boxes of a fixed size,
   moved and stretched by transforms) on layers inside it; the share sheet is a layer while it is
   up; the demo's pointer has its own. A peel writes a few variables on the stub's slot (--from,
   --cut, --curl-x, --cast-x and --bend-x in px, --curl-s, --curl-h, --cast-s and --lift as
   numbers), which repaint that stub alone.
   Nothing inside an SVG ever changes.

   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). At night the stage goes dark and the app on the phone follows, as an
   app's dark mode would: a warm near-black screen, the ticket on dark paper with light ink, a
   dark share sheet. The phone itself, the crowd art and the share sheet's pictures (people and
   app icons) stay as they are. */

.stage {
  /* The stage, its dots and its edge. */
  --surface: #ececee;
  --dots: rgb(0 0 0 / 0.07);
  --line: rgb(0 0 0 / 0.06);
  /* The phone, the same on both: its body, its rim and inner edge, its buttons, its shadow near
     and far, and the island. */
  --device: #1c1c1e;
  --device-rim: #56565b;
  --device-edge: #141416;
  --device-key: #3a3a3d;
  --device-shadow: rgb(0 0 0 / 0.38);
  --device-shadow-near: rgb(0 0 0 / 0.16);
  --island: #050505;
  /* The screen: its background, its words, the status bar and home bar, the close mark, the
     faint print of the page behind, and the notes under the ticket. */
  --surface-2: #f4f3f1;
  --ink: #3d3833;
  --status-ink: #111;
  --home: #1d1d1f;
  --dismiss: #5c5650;
  --behind: #c3bcb4;
  --ink-muted: #aaa39b;
  --ink-faint: #c2bcb5;
  /* The ticket's paper, and the art's fade into it, from clear to solid. */
  --paper: #f9f3e7;
  --paper-0: rgb(249 243 231 / 0);
  --paper-55: rgb(249 243 231 / 0.55);
  --paper-92: rgb(249 243 231 / 0.92);
  /* On the paper: the name and the line under it, a stub's offer, its guest line and code, its
     barcode, and the perforation. */
  --title: #5b544d;
  --subtitle: #958c82;
  --offer: #6e665e;
  --print: #a1988e;
  --barcode: #3b3632;
  --perforation: #d3c8b8;
  /* A used stub's place: its words, and Restore, then under the pointer. */
  --used: #b7b0a8;
  --restore: #c9c2b9;
  --restore-hover: #8f877e;
  /* The ticket's shadow, near and far; where a stub bends up, the shade along the cut. */
  --shadow-near: rgb(84 64 40 / 0.1);
  --shadow: rgb(84 64 40 / 0.2);
  --bend-0: rgb(110 88 56 / 0);
  --bend: rgb(110 88 56 / 0.16);
  /* The curl, the paper's blank back shaded like a cylinder lit from the top left: its shade at
     the top and bottom, its edge, its six tones across; then the shadow it casts. */
  --curl-top: rgb(90 70 40 / 0.07);
  --curl-bottom: rgb(90 70 40 / 0.1);
  --curl-edge: rgb(90 70 40 / 0.12);
  --curl-1: #d6cec1;
  --curl-2: #f1ece4;
  --curl-3: #fffefc;
  --curl-4: #fdfbf7;
  --curl-5: #ece6dc;
  --curl-6: #cfc6b7;
  --cast: rgb(70 50 24 / 0.3);
  --cast-soft: rgb(70 50 24 / 0.12);
  /* The dim behind the share sheet. */
  --scrim: rgb(0 0 0 / 0.22);
  /* The share sheet (share-sheet.module.css): its background, its top edge and shadow, its
     words, people's names, its grey words and marks, its icons, the wells of the quick actions
     and the white group of more actions (dark grey at night), and what is cut into their
     icons. */
  --sheet: #f5f5f7;
  --sheet-edge: rgb(0 0 0 / 0.04);
  --sheet-shadow: rgb(0 0 0 / 0.12);
  --sheet-ink: #1c1c1e;
  --sheet-name: #3a3a3c;
  --sheet-muted: #8e8e93;
  --sheet-icon: #3a3a3c;
  --well: #e8e8ed;
  --well-cut: #ececef;
  --list: #fff;
  /* Send, green, with its halo (a deeper green at night, so its white arrow keeps 3.5:1); the
     white marks on it and on a person's app badge. */
  --accent: #34c759;
  --accent-halo: rgb(52 199 89 / 0.16);
  --accent-ink: #fff;
  --focus: #0a84ff;
  /* The demo's pointer looks like the system's on both: a dark arrow with a white edge, or a
     white hand with a dark edge while it drags. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  height: 640px;
  overflow: hidden;
  overflow: clip;
  container-type: inline-size;
  contain: layout;
  border-radius: 18px;
  background-color: var(--surface);
  background-image: radial-gradient(circle, var(--dots) 1px, transparent 1.4px);
  background-position: center;
  background-size: 18px 18px;
  box-shadow: inset 0 0 0 1px var(--line);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --surface: light-dark(#ececee, #111113);
    --dots: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.06));
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --surface-2: light-dark(#f4f3f1, #171512);
    --ink: light-dark(#3d3833, #ebe5dc);
    --status-ink: light-dark(#111, #f5f5f7);
    --home: light-dark(#1d1d1f, #f5f5f7);
    --dismiss: light-dark(#5c5650, #b3aba1);
    --behind: light-dark(#c3bcb4, #4d4740);
    --ink-muted: light-dark(#aaa39b, #9a9288);
    --ink-faint: light-dark(#c2bcb5, #8a8279);
    --paper: light-dark(#f9f3e7, #262119);
    --paper-0: light-dark(rgb(249 243 231 / 0), rgb(38 33 25 / 0));
    --paper-55: light-dark(rgb(249 243 231 / 0.55), rgb(38 33 25 / 0.55));
    --paper-92: light-dark(rgb(249 243 231 / 0.92), rgb(38 33 25 / 0.92));
    --title: light-dark(#5b544d, #e3dbd0);
    --subtitle: light-dark(#958c82, #a0978c);
    --offer: light-dark(#6e665e, #c9c0b4);
    --print: light-dark(#a1988e, #9d9489);
    --barcode: light-dark(#3b3632, #e6ded3);
    --perforation: light-dark(#d3c8b8, #4f473c);
    --used: light-dark(#b7b0a8, #857d74);
    --restore: light-dark(#c9c2b9, #8a8279);
    --restore-hover: light-dark(#8f877e, #c9c2b9);
    --shadow-near: light-dark(rgb(84 64 40 / 0.1), rgb(0 0 0 / 0.35));
    --shadow: light-dark(rgb(84 64 40 / 0.2), rgb(0 0 0 / 0.55));
    --bend-0: light-dark(rgb(110 88 56 / 0), rgb(0 0 0 / 0));
    --bend: light-dark(rgb(110 88 56 / 0.16), rgb(0 0 0 / 0.4));
    --curl-top: light-dark(rgb(90 70 40 / 0.07), rgb(0 0 0 / 0.25));
    --curl-bottom: light-dark(rgb(90 70 40 / 0.1), rgb(0 0 0 / 0.3));
    --curl-edge: light-dark(rgb(90 70 40 / 0.12), rgb(255 255 255 / 0.06));
    --curl-1: light-dark(#d6cec1, #1f1b16);
    --curl-2: light-dark(#f1ece4, #2f2a23);
    --curl-3: light-dark(#fffefc, #474037);
    --curl-4: light-dark(#fdfbf7, #433c33);
    --curl-5: light-dark(#ece6dc, #2e2922);
    --curl-6: light-dark(#cfc6b7, #1c1914);
    --cast: light-dark(rgb(70 50 24 / 0.3), rgb(0 0 0 / 0.35));
    --cast-soft: light-dark(rgb(70 50 24 / 0.12), rgb(0 0 0 / 0.15));
    --scrim: light-dark(rgb(0 0 0 / 0.22), rgb(0 0 0 / 0.5));
    --sheet: light-dark(#f5f5f7, #1c1c1e);
    --sheet-edge: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.08));
    --sheet-shadow: light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.5));
    --sheet-ink: light-dark(#1c1c1e, #f5f5f7);
    --sheet-name: light-dark(#3a3a3c, #d1d1d6);
    --sheet-icon: light-dark(#3a3a3c, #e5e5ea);
    --well: light-dark(#e8e8ed, #2c2c2e);
    --well-cut: light-dark(#ececef, #2c2c2e);
    --list: light-dark(#fff, #2c2c2e);
    --accent: light-dark(#34c759, #2a9d4b);
    --accent-halo: light-dark(rgb(52 199 89 / 0.16), rgb(42 157 75 / 0.24));
    --focus: light-dark(#0a84ff, #3594ff);
  }
}
/* The colorScheme prop. Without it the stage sets nothing and inherits the page's color-scheme. */
.stage[data-color-scheme='light'] {
  color-scheme: light;
}
.stage[data-color-scheme='dark'] {
  color-scheme: dark;
}
.stage[data-color-scheme='system'] {
  color-scheme: light dark;
}
.stage *,
.stage *::before,
.stage *::after {
  box-sizing: border-box;
}
.stage :where(p) {
  margin: 0;
}

/* The phone: a dark body with a lighter rim, its buttons, and the screen inside. */
.phone {
  --u: 0.69px;
  position: absolute;
  top: 50%;
  left: 50%;
  width: calc(414 * var(--u));
  height: calc(868 * var(--u));
  margin: calc(-434 * var(--u)) 0 0 calc(-207 * var(--u));
  border-radius: calc(68 * var(--u));
  background: var(--device);
  box-shadow:
    inset 0 0 0 calc(2 * var(--u)) var(--device-rim),
    inset 0 0 0 calc(5 * var(--u)) var(--device-edge),
    0 calc(30 * var(--u)) calc(60 * var(--u)) calc(-18 * var(--u)) var(--device-shadow),
    0 calc(10 * var(--u)) calc(22 * var(--u)) calc(-6 * var(--u)) var(--device-shadow-near);
}
/* The action button and the volume buttons on the left, the side button on the right. */
.phone::before,
.phone::after {
  content: '';
  position: absolute;
  width: calc(4 * var(--u));
  border-radius: calc(2 * var(--u));
  background: var(--device-key);
}
.phone::before {
  top: calc(168 * var(--u));
  left: calc(-3 * var(--u));
  height: calc(34 * var(--u));
  box-shadow:
    0 calc(68 * var(--u)) 0 0 var(--device-key),
    0 calc(132 * var(--u)) 0 0 var(--device-key);
}
.phone::after {
  top: calc(250 * var(--u));
  right: calc(-3 * var(--u));
  height: calc(104 * var(--u));
}

.screen {
  position: absolute;
  inset: calc(12 * var(--u));
  overflow: hidden;
  overflow: clip;
  isolation: isolate;
  border-radius: calc(56 * var(--u));
  background: var(--surface-2);
  color: var(--ink);
  font:
    400 calc(15 * var(--u))/1.2 'Avenir Next',
    Avenir,
    'Nunito Sans',
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
}

/* The status bar, over everything. */
.status {
  position: absolute;
  z-index: 4;
  inset: 0 0 auto;
  height: calc(54 * var(--u));
  color: var(--status-ink);
  pointer-events: none;
}
.time {
  position: absolute;
  top: calc(17 * var(--u));
  left: calc(50 * var(--u));
  font:
    600 calc(17 * var(--u))/calc(22 * var(--u)) -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    'Segoe UI',
    sans-serif;
  letter-spacing: -0.01em;
}
.island {
  position: absolute;
  top: calc(11 * var(--u));
  left: 50%;
  width: calc(124 * var(--u));
  height: calc(36 * var(--u));
  margin-left: calc(-62 * var(--u));
  border-radius: calc(18 * var(--u));
  background: var(--island);
}
.signal {
  position: absolute;
  top: calc(21 * var(--u));
  right: calc(28 * var(--u));
  width: calc(70 * var(--u));
  height: calc(14 * var(--u));
  fill: currentColor;
}
.home {
  position: absolute;
  z-index: 5;
  bottom: calc(8 * var(--u));
  left: 50%;
  width: calc(134 * var(--u));
  height: calc(5 * var(--u));
  margin-left: calc(-67 * var(--u));
  border-radius: calc(3 * var(--u));
  background: var(--home);
  pointer-events: none;
}

/* The page: its close mark and heading, the ticket, the notes under it. */
.page {
  position: absolute;
  inset: 0;
}
/* Faint small print from the page behind, just under the status bar. */
.behind {
  position: absolute;
  top: calc(48 * var(--u));
  right: calc(20 * var(--u));
  left: calc(20 * var(--u));
  overflow: hidden;
  color: var(--behind);
  font-size: calc(11.5 * var(--u));
  line-height: calc(14 * var(--u));
  text-align: center;
  white-space: nowrap;
}
.dismiss {
  position: absolute;
  top: calc(68 * var(--u));
  left: calc(29 * var(--u));
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
  fill: none;
  stroke: var(--dismiss);
  stroke-width: 1.7;
  stroke-linecap: round;
}
.heading {
  position: absolute;
  top: calc(96 * var(--u));
  left: calc(21 * var(--u));
  color: var(--ink);
  font-size: calc(15 * var(--u));
  font-weight: 600;
  line-height: calc(20 * var(--u));
}
.note,
.small {
  position: absolute;
  right: calc(28 * var(--u));
  left: calc(28 * var(--u));
  color: var(--ink-muted);
  font-size: calc(9.5 * var(--u));
  line-height: calc(13 * var(--u));
  text-align: center;
}
.note {
  top: calc(703 * var(--u));
}
.small {
  top: calc(748 * var(--u));
  color: var(--ink-faint);
}

/* The ticket: the art and its name, then the stubs. Each piece casts its shadow from a box under
   its paper; a stub's shadow never rises above its top edge, so it never shades the piece above,
   and the piece below covers what falls on it. */
.card {
  position: absolute;
  top: calc(131 * var(--u));
  left: calc(21 * var(--u));
  width: calc(348 * var(--u));
  isolation: isolate;
}
.shadow {
  position: absolute;
  inset: 0;
  border-radius: 0;
  box-shadow:
    0 calc(0.5 * var(--u)) calc(1.5 * var(--u)) var(--shadow-near),
    0 calc(12 * var(--u)) calc(26 * var(--u)) calc(-8 * var(--u)) var(--shadow);
  transition: opacity 0.2s ease;
}
.headShadow {
  z-index: -1;
  bottom: auto;
  height: calc(319 * var(--u));
  border-radius: calc(5 * var(--u)) calc(5 * var(--u)) 0 0;
}
.head {
  position: relative;
  height: calc(319 * var(--u));
  overflow: hidden;
  overflow: clip;
  border-radius: calc(5 * var(--u)) calc(5 * var(--u)) 0 0;
  background: var(--paper);
}
.card[data-head-bottom] .head,
.card[data-head-bottom] .headShadow {
  border-radius: calc(5 * var(--u));
}
.art {
  position: absolute;
  top: 0;
  left: 0;
  display: block;
  width: calc(348 * var(--u));
  height: calc(300 * var(--u));
  opacity: 0.74;
}
.fade {
  position: absolute;
  inset: 0 0 auto;
  height: calc(302 * var(--u));
  background: linear-gradient(
    var(--paper-0) 34%,
    var(--paper-55) 62%,
    var(--paper-92) 84%,
    var(--paper) 100%
  );
}
.title {
  position: absolute;
  top: calc(253 * var(--u));
  left: calc(22 * var(--u));
  color: var(--title);
  font-size: calc(25 * var(--u));
  font-weight: 600;
  line-height: calc(30 * var(--u));
  letter-spacing: -0.005em;
}
.subtitle {
  position: absolute;
  top: calc(288.5 * var(--u));
  left: calc(22 * var(--u));
  color: var(--subtitle);
  font-size: calc(11 * var(--u));
  line-height: calc(14 * var(--u));
}

/* A pass: its place (which reads Used once it is gone), and the stub over it. */
.slot {
  --cut: calc(0 * var(--u));
  --from: calc(0 * var(--u));
  --curl-x: calc(0 * var(--u));
  --curl-s: 0;
  --cast-x: calc(0 * var(--u));
  --cast-s: 0;
  --curl-h: 0.58;
  --bend-x: calc(0 * var(--u));
  --lift: 0;
  position: relative;
  height: calc(78 * var(--u));
}
.slot[data-top] .shadow,
.slot[data-top] .paper {
  border-top-left-radius: calc(5 * var(--u));
  border-top-right-radius: calc(5 * var(--u));
}
.slot[data-bottom] .shadow,
.slot[data-bottom] .paper {
  border-bottom-left-radius: calc(5 * var(--u));
  border-bottom-right-radius: calc(5 * var(--u));
}
.slot .shadow {
  clip-path: inset(0 calc(-40 * var(--u)) calc(-40 * var(--u)));
}
.slot[data-top] .shadow {
  clip-path: none;
}
.slot:not([data-phase='ready']) .shadow {
  opacity: 0;
}
/* Peeling, the shadow fades out where the paper is up. */
.slot[data-peeling] .shadow {
  mask-image: linear-gradient(
    90deg,
    #000 calc(100% - var(--cut) - 8 * var(--u)),
    transparent calc(100% - var(--cut) + 10 * var(--u)),
    transparent calc(100% - var(--from) - 10 * var(--u)),
    #000 calc(100% - var(--from) + 8 * var(--u))
  );
}
.slot[data-peeling][data-side='left'] .shadow {
  mask-image: linear-gradient(
    270deg,
    #000 calc(100% - var(--cut) - 8 * var(--u)),
    transparent calc(100% - var(--cut) + 10 * var(--u)),
    transparent calc(100% - var(--from) - 10 * var(--u)),
    #000 calc(100% - var(--from) + 8 * var(--u))
  );
}

.ghost {
  position: absolute;
  inset: 0;
  color: var(--used);
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 0.2s ease,
    visibility 0s linear 0.2s;
}
.slot:not([data-phase='ready']) .ghost {
  opacity: 1;
  visibility: visible;
  transition:
    opacity 0.35s ease 0.15s,
    visibility 0s;
}
.ghost .guest {
  top: calc(24.5 * var(--u));
  color: var(--used);
}
.used {
  position: absolute;
  top: calc(42 * var(--u));
  left: calc(22 * var(--u));
  font-size: calc(11 * var(--u));
  line-height: calc(15 * var(--u));
}
.restore {
  position: absolute;
  top: calc(30 * var(--u));
  right: calc(9 * var(--u));
  height: calc(20 * var(--u));
  margin: 0;
  padding: 0 calc(8 * var(--u));
  border: 0;
  border-radius: calc(6 * var(--u));
  background: none;
  color: var(--restore);
  font: inherit;
  font-size: calc(11 * var(--u));
  cursor: pointer;
}
.restore:hover {
  color: var(--restore-hover);
}

.stub {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: grab;
  /* A sideways drag peels it; up and down still scroll the page. */
  touch-action: pan-y;
  -webkit-user-select: none;
  user-select: none;
}
.stub:active {
  cursor: grabbing;
}
.stub > * {
  pointer-events: none;
}
.slot[data-phase='used'] .stub {
  visibility: hidden;
}
/* Peeling, the stub and its curl are layers of their own, over the pieces below it. */
.slot[data-peeling],
.slot[data-flying] {
  z-index: 1;
}
.slot[data-peeling] .stub,
.slot[data-peeling] .curl,
.slot[data-peeling] .cast {
  will-change: transform;
}
/* The focus ring, drawn over the paper. */
.stub:focus-visible {
  outline: none;
}
.stub:focus-visible::after {
  content: '';
  position: absolute;
  inset: 3px;
  border-radius: 11px;
  box-shadow: 0 0 0 2.5px var(--focus);
  pointer-events: none;
}
.restore:focus-visible {
  outline: 2.5px solid var(--focus);
  outline-offset: 0;
}

/* The stub's paper: its perforation, its words and its barcode. Peeling, it is cut away between
   --from and --cut, measured from the edge the drag moves away from. */
.paper {
  position: absolute;
  inset: 0;
  overflow: hidden;
  overflow: clip;
  background: var(--paper);
}
.paper::before {
  content: '';
  position: absolute;
  top: calc(-0.4 * var(--u));
  right: calc(8 * var(--u));
  left: calc(8 * var(--u));
  height: calc(2.8 * var(--u));
  background: radial-gradient(
      circle,
      var(--perforation) calc(1.1 * var(--u)),
      transparent calc(1.35 * var(--u))
    )
    50% 50% / calc(8.5 * var(--u)) calc(2.8 * var(--u)) repeat-x;
}
.slot[data-peeling] .paper {
  clip-path: polygon(
    calc(-30 * var(--u)) calc(-30 * var(--u)),
    calc(100% - var(--cut)) calc(-30 * var(--u)),
    calc(100% - var(--cut)) calc(100% + 30 * var(--u)),
    calc(100% - var(--from)) calc(100% + 30 * var(--u)),
    calc(100% - var(--from)) calc(-30 * var(--u)),
    calc(100% + 30 * var(--u)) calc(-30 * var(--u)),
    calc(100% + 30 * var(--u)) calc(100% + 60 * var(--u)),
    calc(-30 * var(--u)) calc(100% + 60 * var(--u))
  );
}
.slot[data-peeling][data-side='left'] .paper {
  clip-path: polygon(
    calc(-30 * var(--u)) calc(-30 * var(--u)),
    var(--from) calc(-30 * var(--u)),
    var(--from) calc(100% + 30 * var(--u)),
    var(--cut) calc(100% + 30 * var(--u)),
    var(--cut) calc(-30 * var(--u)),
    calc(100% + 30 * var(--u)) calc(-30 * var(--u)),
    calc(100% + 30 * var(--u)) calc(100% + 60 * var(--u)),
    calc(-30 * var(--u)) calc(100% + 60 * var(--u))
  );
}
.slot[data-phase='torn']:not([data-peeling]) .paper {
  visibility: hidden;
}
.offer {
  position: absolute;
  top: calc(24 * var(--u));
  left: calc(22 * var(--u));
  color: var(--offer);
  font-size: calc(11 * var(--u));
  line-height: calc(15 * var(--u));
}
.guest {
  position: absolute;
  top: calc(42.5 * var(--u));
  left: calc(22 * var(--u));
  color: var(--print);
  font:
    500 calc(9.5 * var(--u))/calc(14 * var(--u)) ui-monospace,
    'SF Mono',
    Menlo,
    Consolas,
    monospace;
  letter-spacing: 0.13em;
}
.bars {
  position: absolute;
  top: calc(21 * var(--u));
  right: calc(19 * var(--u));
  width: calc(89 * var(--u));
  height: calc(25 * var(--u));
  color: var(--barcode);
}
.code {
  position: absolute;
  top: calc(51.5 * var(--u));
  right: calc(19 * var(--u));
  width: calc(89 * var(--u));
  color: var(--print);
  font:
    500 calc(7 * var(--u))/calc(9 * var(--u)) ui-monospace,
    'SF Mono',
    Menlo,
    Consolas,
    monospace;
  letter-spacing: 0.08em;
  text-align: center;
  white-space: nowrap;
}
/* Where the paper starts to bend up, a shade along the cut. */
.bend {
  position: absolute;
  inset: 0 auto 0 0;
  width: calc(18 * var(--u));
  background: linear-gradient(90deg, var(--bend-0), var(--bend));
  opacity: 0;
  transform: translateX(var(--bend-x));
}
.slot[data-side='left'] .bend {
  background: linear-gradient(270deg, var(--bend-0), var(--bend));
}
.slot[data-peeling] .bend {
  opacity: var(--lift);
}

/* The curl: a tube of the paper's blank back, lit from the top left, and its soft shadow. Both
   keep their size and move by transforms. */
.curl,
.cast {
  position: absolute;
  left: 0;
  opacity: 0;
  transform-origin: 0 0;
}
/* It is as tall as the stub and REACH more (134 points), from just under the perforation, and
   grows down past the stub's bottom edge as it widens (--curl-h), as paper lifted towards the
   eye would. */
.curl {
  top: calc(2 * var(--u));
  width: calc(60 * var(--u));
  height: calc(134 * var(--u));
  border-radius: calc(30 * var(--u)) / calc(5 * var(--u));
  background:
    linear-gradient(var(--curl-top), transparent 12%, transparent 88%, var(--curl-bottom)),
    linear-gradient(
      90deg,
      var(--curl-1) 0%,
      var(--curl-2) 12%,
      var(--curl-3) 34%,
      var(--curl-4) 56%,
      var(--curl-5) 80%,
      var(--curl-6) 100%
    );
  box-shadow: inset 0 0 0 calc(0.5 * var(--u)) var(--curl-edge);
  transform: translateX(var(--curl-x)) scale(var(--curl-s), var(--curl-h));
}
.slot[data-side='left'] .curl {
  background:
    linear-gradient(var(--curl-top), transparent 12%, transparent 88%, var(--curl-bottom)),
    linear-gradient(
      270deg,
      var(--curl-1) 0%,
      var(--curl-2) 12%,
      var(--curl-3) 34%,
      var(--curl-4) 56%,
      var(--curl-5) 80%,
      var(--curl-6) 100%
    );
}
.cast {
  top: calc(3 * var(--u));
  width: calc(80 * var(--u));
  height: calc(134 * var(--u));
  background: radial-gradient(closest-side, var(--cast), var(--cast-soft) 60%, transparent);
  transform: translateX(var(--cast-x)) scale(var(--cast-s), var(--curl-h));
  transform-origin: 50% 0;
}
.slot[data-peeling] .curl {
  opacity: 1;
}
.slot[data-peeling] .cast {
  opacity: var(--lift);
}
.slot[data-flying] .cast {
  opacity: 0;
  transition: opacity 0.2s ease;
}

/* The dim behind the share sheet; a click on it closes the sheet. */
.scrim {
  position: absolute;
  z-index: 2;
  inset: 0;
  background: var(--scrim);
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 0.3s ease,
    visibility 0s linear 0.3s;
}
.scrim[data-open] {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.35s ease;
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The demo's pointer: an arrow, or a closed hand while it drags. It moves over the whole stage,
   in a box of its own, contained, so that nothing in it can ever lay out the page around it. */
.pointerLayer {
  position: absolute;
  z-index: 10;
  inset: 0;
  contain: strict;
  pointer-events: none;
}
.pointer [data-glyph='hand'],
.pointer[data-grab] [data-glyph='arrow'] {
  visibility: hidden;
}
.pointer[data-grab] [data-glyph='hand'] {
  visibility: visible;
}

/* A narrow stage: a smaller phone. */
@container (max-width: 330px) {
  .phone {
    --u: 0.6px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .ghost,
  .slot:not([data-phase='ready']) .ghost,
  .scrim,
  .scrim[data-open],
  .shadow,
  .slot[data-flying] .cast {
    transition: none;
  }
}
```

### tear-coupon/share-sheet.tsx

```tsx
import type { Ref } from 'react';
import styles from './share-sheet.module.css';

/*
 * The share sheet that slides up once a stub is torn off: people nearby, apps, quick actions and
 * a list of more, all made up. Every target is a button that hands its name to `onPick`. The
 * sheet is always in the page, out of view below the screen while closed (hidden and inert), so
 * opening it moves one layer and adds nothing.
 */

/**
 * People to share with: illustrated, with a shirt, a skin tone and a hair style each. They are
 * pictures, the same in light and dark, like the app icons below.
 */
const PEOPLE = [
  { name: 'Iris Vale', bg: '#f3dfd6', skin: '#f1c8a8', hair: '#5b4636', style: 'bob' },
  { name: 'Remy Sol', bg: '#dfe6f3', skin: '#c99a74', hair: '#2f2a28', style: 'short' },
  { name: 'Noor Haddad', bg: '#e6eedb', skin: '#e2b48e', hair: '#3b2c25', style: 'long' },
] as const;

type Person = (typeof PEOPLE)[number];

function Avatar({ person }: { person: Person }) {
  const { bg, skin, hair, style } = person;
  return (
    <svg className={styles.avatar} viewBox="0 0 60 60" aria-hidden="true">
      <circle cx="30" cy="30" r="30" fill={bg} />
      {style === 'long' && <path d="M14 30c0-14 7-21 16-21s16 7 16 21v20H14z" fill={hair} />}
      <path d="M9 60c2-12 10-17 21-17s19 5 21 17z" fill="#fff" opacity="0.85" />
      <rect x="25" y="34" width="10" height="10" rx="4" fill={skin} />
      <ellipse cx="30" cy="27" rx="11" ry="12.5" fill={skin} />
      {style === 'bob' && (
        <path d="M17 30c-1-12 5-19 13-19s14 6 13 19c-3-6-6-9-9-10-5 4-11 6-17 10z" fill={hair} />
      )}
      {style === 'short' && (
        <path d="M18.5 24c0-9 5-13 11.5-13S42 15 41.5 24c-4-4-10-6-23 0z" fill={hair} />
      )}
      {style === 'long' && (
        <path d="M18.5 26c1-10 6-14 11.5-14s11 4 11.5 14c-5-5-14-7-23 0z" fill={hair} />
      )}
      <circle cx="26" cy="28" r="1.3" fill="#3a2f2a" />
      <circle cx="34" cy="28" r="1.3" fill="#3a2f2a" />
      <path
        d="M27.5 33q2.5 2 5 0"
        fill="none"
        stroke="#3a2f2a"
        strokeWidth="1.2"
        strokeLinecap="round"
      />
    </svg>
  );
}

/** The small mark on a person's picture: the app they would get it in. */
function Badge() {
  return (
    <span className={styles.badge} aria-hidden="true">
      <svg viewBox="0 0 16 16">
        <path d="M8 3.2c-3 0-5.2 1.9-5.2 4.3 0 1.3.7 2.5 1.8 3.3l-.4 2 2.2-1.2c.5.1 1 .2 1.6.2 3 0 5.2-1.9 5.2-4.3S11 3.2 8 3.2z" />
      </svg>
    </span>
  );
}

/** The apps: generic tiles, white glyphs on grey. */
const APPS = [
  {
    name: 'Nearby',
    glyph: (
      <>
        <circle cx="16" cy="16" r="3" fill="#fff" />
        <path d="M10.5 10.5a7.8 7.8 0 0 0 0 11M21.5 10.5a7.8 7.8 0 0 1 0 11M7 7a12.7 12.7 0 0 0 0 18M25 7a12.7 12.7 0 0 1 0 18" />
      </>
    ),
  },
  {
    name: 'Chat',
    glyph: (
      <path
        d="M16 7.5c-5.5 0-9.5 3.4-9.5 7.8 0 2.4 1.2 4.5 3.2 6l-.8 3.6 4-2.1c1 .2 2 .4 3.1.4 5.5 0 9.5-3.5 9.5-7.9S21.5 7.5 16 7.5z"
        fill="#fff"
        stroke="none"
      />
    ),
  },
  {
    name: 'Email',
    glyph: (
      <>
        <rect x="6.5" y="9.5" width="19" height="13" rx="2.5" fill="#fff" stroke="none" />
        <path d="m7.5 11 8.5 6.4 8.5-6.4" stroke="#9c9ca1" />
      </>
    ),
  },
  {
    name: 'Groups',
    glyph: (
      <>
        <path
          d="M12.5 8.5c-4 0-7 2.5-7 5.7 0 1.7.9 3.2 2.3 4.3l-.5 2.6 2.9-1.5c.7.1 1.5.2 2.3.2 4 0 7-2.5 7-5.6s-3-5.7-7-5.7z"
          fill="#fff"
          stroke="none"
        />
        <path
          d="M20.3 13.3c3.5.2 6.2 2.5 6.2 5.3 0 1.4-.7 2.7-1.9 3.6l.4 2.2-2.4-1.3c-.6.1-1.2.2-1.9.2-2.4 0-4.5-1-5.6-2.6"
          fill="#fff"
          stroke="#9c9ca1"
          strokeWidth="1.4"
        />
      </>
    ),
  },
] as const;

/** Quick actions, in round wells. */
const ACTIONS = [
  {
    name: 'Copy',
    glyph: (
      <>
        <rect x="8.5" y="5.5" width="10" height="12" rx="2" />
        <path d="M15.5 17.5v1a2 2 0 0 1-2 2h-6a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h1" />
      </>
    ),
  },
  {
    name: 'Save Image',
    glyph: (
      <>
        <path d="M8 9.5H6.5a1.5 1.5 0 0 0-1.5 1.5v7a1.5 1.5 0 0 0 1.5 1.5h11a1.5 1.5 0 0 0 1.5-1.5v-7a1.5 1.5 0 0 0-1.5-1.5H16" />
        <path d="M12 4.5v10m-3.2-3.2L12 14.5l3.2-3.2" />
      </>
    ),
  },
  {
    name: 'Add to Contact',
    glyph: (
      <>
        <circle cx="12" cy="12" r="8" />
        <circle cx="12" cy="10.2" r="2.6" />
        <path d="M7.4 17.2c1-2 2.7-3 4.6-3s3.6 1 4.6 3" />
      </>
    ),
  },
  {
    name: 'Print',
    glyph: (
      <>
        <path d="M7.5 9V5.5h9V9" />
        <rect x="4.5" y="9" width="15" height="7.5" rx="2" />
        <path d="M7.5 14h9v5.5h-9z" className={styles.wellCut} />
      </>
    ),
  },
] as const;

/** More actions, as a list. */
const MORE = [
  {
    name: 'Add to New Note',
    glyph: (
      <>
        <rect x="4.5" y="4.5" width="15" height="15" rx="3" />
        <path d="M4.5 9h15M8 13h8M8 16h5" />
      </>
    ),
  },
  {
    name: 'Create Wallpaper',
    glyph: (
      <>
        <rect x="7" y="3.5" width="10" height="17" rx="2.5" />
        <path d="m8 16.5 3-3.5 2 2 1.5-1.5 2.5 3" />
      </>
    ),
  },
  {
    name: 'Save to Files',
    glyph: (
      <path d="M4.5 7.5a2 2 0 0 1 2-2h3.2l2 2h5.8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2z" />
    ),
  },
  {
    name: 'Open in Editor',
    glyph: (
      <>
        <rect x="4.5" y="5.5" width="15" height="13" rx="2.5" className={styles.solid} />
        <path d="m9 12.5 2 2 4-4.5" className={styles.listCut} />
      </>
    ),
  },
  {
    name: 'Scan Text',
    glyph: (
      <>
        <rect x="4.5" y="5.5" width="15" height="13" rx="2.5" className={styles.solid} />
        <path d="M8 10h8M8 13.5h5.5" className={styles.listCut} />
      </>
    ),
  },
  {
    name: 'Save as PDF',
    glyph: (
      <>
        <rect x="4.5" y="5.5" width="15" height="13" rx="2.5" className={styles.solid} />
        <path d="M8 9.5h3.5M8 12h3.5M8 14.5h3.5M14 9.5v5" className={styles.listCut} />
      </>
    ),
  },
] as const;

export function ShareSheet({
  open,
  label,
  onClose,
  onPick,
  closeRef,
}: {
  /** Shown, or out of view below the screen. */
  open: boolean;
  /** What is shared, in the sheet's header. */
  label: string;
  /** The close button. */
  onClose: () => void;
  /** A target was chosen: a person, an app or an action, by name. */
  onPick: (target: string) => void;
  /** The close button, which takes the focus when the reader opens the sheet. */
  closeRef: Ref<HTMLButtonElement>;
}) {
  return (
    <section
      className={styles.sheet}
      data-part="sheet"
      data-open={open || undefined}
      role="dialog"
      aria-modal="true"
      aria-label="Share guest pass"
      inert={!open}
    >
      <header className={styles.head} data-part="sheet-head">
        <span className={styles.thumb} aria-hidden="true">
          <svg viewBox="0 0 24 24">
            <path d="M5 4.5h14a1 1 0 0 1 1 1v4a2.5 2.5 0 0 0 0 5v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-4a2.5 2.5 0 0 0 0-5v-4a1 1 0 0 1 1-1z" />
            <path d="M14.5 5v14" strokeDasharray="1.6 2" />
          </svg>
        </span>
        <span className={styles.label}>{label}</span>
        <button
          type="button"
          className={styles.send}
          data-part="send"
          aria-label="Send"
          onClick={() => onPick('Send')}
        >
          <svg viewBox="0 0 24 24" aria-hidden="true">
            <path d="M12 18V6.5M7 11l5-5 5 5" />
          </svg>
        </button>
        <button
          type="button"
          ref={closeRef}
          className={styles.close}
          aria-label="Close"
          onClick={onClose}
        >
          <svg viewBox="0 0 24 24" aria-hidden="true">
            <path d="m7 7 10 10M17 7 7 17" />
          </svg>
        </button>
      </header>

      <div className={styles.row}>
        <button type="button" className={styles.target} onClick={() => onPick("Ana's Phone")}>
          <span className={styles.face}>
            <svg className={styles.avatar} viewBox="0 0 60 60" aria-hidden="true">
              <circle cx="30" cy="30" r="30" fill="#e8e8ec" />
              <rect
                x="21"
                y="12"
                width="18"
                height="34"
                rx="4.5"
                fill="#fff"
                stroke="#9a9aa0"
                strokeWidth="2"
              />
              <path d="M24.5 19h11v20h-11z" fill="#c9d9ee" />
            </svg>
          </span>
          <span className={styles.name}>
            Ana&rsquo;s
            <br />
            Phone
          </span>
        </button>
        {PEOPLE.map((person) => (
          <button
            key={person.name}
            type="button"
            className={styles.target}
            data-part="person"
            onClick={() => onPick(person.name)}
          >
            <span className={styles.face}>
              <Avatar person={person} />
              <Badge />
            </span>
            <span className={styles.name}>
              {person.name.split(' ')[0]}
              <br />
              {person.name.split(' ')[1]}
            </span>
          </button>
        ))}
      </div>

      <div className={styles.row}>
        {APPS.map(({ name, glyph }) => (
          <button
            key={name}
            type="button"
            className={styles.target}
            data-part="app"
            onClick={() => onPick(name)}
          >
            <svg className={styles.app} viewBox="0 0 32 32" aria-hidden="true">
              {glyph}
            </svg>
            <span className={styles.name}>{name}</span>
          </button>
        ))}
      </div>

      <div className={styles.row}>
        {ACTIONS.map(({ name, glyph }) => (
          <button
            key={name}
            type="button"
            className={styles.target}
            data-part="action"
            onClick={() => onPick(name)}
          >
            <span className={styles.well}>
              <svg viewBox="0 0 24 24" aria-hidden="true">
                {glyph}
              </svg>
            </span>
            <span className={styles.name}>{name}</span>
          </button>
        ))}
      </div>

      <ul className={styles.list} data-part="list">
        {MORE.map(({ name, glyph }) => (
          <li key={name}>
            <button
              type="button"
              className={styles.item}
              data-part="list-item"
              onClick={() => onPick(name)}
            >
              <svg viewBox="0 0 24 24" aria-hidden="true">
                {glyph}
              </svg>
              {name}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}
```

### tear-coupon/share-sheet.module.css

```css
/* The share sheet (share-sheet.tsx), measured in the screen's points times --u (a 390 × 844
   screen, set by the phone around it). It slides
   up, and stays up, on a layer of its own; closed, it waits below the screen, hidden, and gives
   that layer back. Its colours are the stage's tokens (tear-coupon.module.css), light or dark;
   the pictures of people and the app icons stay as they are. */

.sheet {
  position: absolute;
  z-index: 3;
  top: calc(64 * var(--u));
  right: calc(8 * var(--u));
  bottom: calc(-40 * var(--u));
  left: calc(8 * var(--u));
  padding: 0 0 calc(40 * var(--u));
  border-radius: calc(40 * var(--u));
  background: var(--sheet);
  box-shadow:
    0 calc(-1 * var(--u)) 0 var(--sheet-edge),
    0 calc(-10 * var(--u)) calc(40 * var(--u)) var(--sheet-shadow);
  color: var(--sheet-ink);
  font:
    400 calc(13 * var(--u))/1.2 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    'Segoe UI',
    sans-serif;
  transform: translateY(105%);
  visibility: hidden;
  transition:
    transform 0.34s cubic-bezier(0.4, 0, 0.9, 0.6),
    visibility 0s linear 0.34s;
}
.sheet[data-open] {
  transform: translateY(0);
  visibility: visible;
  will-change: transform;
  transition:
    transform 0.52s cubic-bezier(0.25, 1, 0.3, 1),
    visibility 0s;
}

.sheet :where(button) {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.sheet :where(button):focus-visible {
  outline: 2.5px solid var(--focus);
  outline-offset: 2px;
  border-radius: 12px;
}

.head {
  position: relative;
  display: flex;
  align-items: center;
  gap: calc(31 * var(--u));
  height: calc(108 * var(--u));
  padding: 0 calc(24 * var(--u)) 0 calc(32 * var(--u));
}
.thumb {
  flex: none;
  width: calc(34 * var(--u));
  height: calc(34 * var(--u));
}
.thumb svg {
  display: block;
  width: 100%;
  height: 100%;
  fill: none;
  stroke: var(--sheet-muted);
  stroke-width: 1.4;
  stroke-linejoin: round;
}
.label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  color: var(--sheet-muted);
  font-size: calc(13 * var(--u));
  white-space: nowrap;
  text-overflow: ellipsis;
}
.send {
  flex: none;
  display: grid;
  place-items: center;
  width: calc(42 * var(--u));
  height: calc(42 * var(--u));
  margin-right: calc(37 * var(--u));
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 calc(5 * var(--u)) var(--accent-halo);
}
.send svg {
  width: calc(22 * var(--u));
  height: calc(22 * var(--u));
  fill: none;
  stroke: var(--accent-ink);
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* A plain cross in the top right corner, above the send button's row. */
.close {
  position: absolute;
  top: calc(18 * var(--u));
  right: calc(14 * var(--u));
  display: grid;
  place-items: center;
  width: calc(30 * var(--u));
  height: calc(30 * var(--u));
  border-radius: 50%;
}
.close svg {
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
  fill: none;
  stroke: var(--sheet-muted);
  stroke-width: 1.8;
  stroke-linecap: round;
}

/* A row of four targets: people, apps, actions. */
.row {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  padding: 0 calc(10 * var(--u));
}
.row + .row {
  margin-top: calc(15 * var(--u));
}
.row + .row + .row {
  margin-top: calc(7 * var(--u));
}
.target {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: calc(7 * var(--u));
  padding: calc(6 * var(--u)) 0;
  border-radius: calc(14 * var(--u));
}
.face {
  position: relative;
  width: calc(62 * var(--u));
  height: calc(62 * var(--u));
}
.avatar {
  display: block;
  width: calc(62 * var(--u));
  height: calc(62 * var(--u));
  border-radius: 50%;
}
.badge {
  position: absolute;
  right: calc(-3 * var(--u));
  bottom: calc(-2 * var(--u));
  display: grid;
  place-items: center;
  width: calc(22 * var(--u));
  height: calc(22 * var(--u));
  border: calc(2 * var(--u)) solid var(--sheet);
  border-radius: calc(7 * var(--u));
  background: var(--sheet-muted);
}
.badge svg {
  width: calc(14 * var(--u));
  height: calc(14 * var(--u));
  fill: var(--accent-ink);
}
.name {
  min-height: calc(26 * var(--u));
  color: var(--sheet-name);
  font-size: calc(11 * var(--u));
  line-height: calc(13 * var(--u));
  text-align: center;
}
/* An app's icon: a picture, the same in light and dark. */
.app {
  display: block;
  width: calc(62 * var(--u));
  height: calc(62 * var(--u));
  padding: calc(13 * var(--u));
  border-radius: calc(15 * var(--u));
  background: linear-gradient(#b3b3b8, #929297);
  fill: none;
  stroke: #fff;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.well {
  display: grid;
  place-items: center;
  width: calc(54 * var(--u));
  height: calc(54 * var(--u));
  margin: calc(4 * var(--u)) 0;
  border-radius: 50%;
  background: var(--well);
}
.well svg {
  width: calc(26 * var(--u));
  height: calc(26 * var(--u));
  fill: none;
  stroke: var(--sheet-icon);
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* More actions: a white group of rows (dark grey at night). */
.list {
  margin: calc(13 * var(--u)) calc(7 * var(--u)) 0;
  padding: calc(4 * var(--u)) 0;
  border-radius: calc(16 * var(--u));
  background: var(--list);
  list-style: none;
}
.item {
  display: flex;
  align-items: center;
  gap: calc(14 * var(--u));
  width: 100%;
  height: calc(46 * var(--u));
  padding: 0 calc(18 * var(--u));
  border-radius: calc(12 * var(--u));
  color: var(--sheet-ink);
  font-size: calc(15 * var(--u));
  text-align: left;
}
.item svg {
  flex: none;
  width: calc(22 * var(--u));
  height: calc(22 * var(--u));
  fill: none;
  stroke: var(--sheet-icon);
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* What is cut into an icon: a quick action's paper, the lines on a filled list icon. */
.wellCut {
  fill: var(--well-cut);
}
.solid {
  fill: var(--sheet-icon);
}
.listCut {
  stroke: var(--list);
}

@media (prefers-reduced-motion: reduce) {
  .sheet,
  .sheet[data-open] {
    transition: none;
  }
}
```

### tear-coupon/crowd.tsx

```tsx
/*
 * The ticket's header art: a crowd of small, round animals packed shoulder to shoulder in warm,
 * muted colours, drawn as plain SVG shapes in a 348 × 300 box. Each animal is a head in a 100-unit
 * box centred on its face, with a body in a shirt under it, placed and scaled into the crowd back
 * row first, so that each row hides the bodies behind it up to the shoulders. The drawing never
 * changes, so it is one static SVG.
 */

import type { ReactNode } from 'react';

type Place = { x: number; y: number; s: number };
/** A body's shirt, plain or striped. */
type Shirt = { shirt: string; stripes?: string };

const LINE = '#8a7c6d';
const DARK = '#5a514a';
const CHEEK = '#eabdaa';

/** A head's outline style: a warm, dark line. */
const ink = {
  stroke: LINE,
  strokeWidth: 2.6,
  strokeLinejoin: 'round',
  strokeLinecap: 'round',
} as const;

function Group({ x, y, s, children }: Place & { children: ReactNode }) {
  return <g transform={`translate(${x} ${y}) scale(${s})`}>{children}</g>;
}

/** A body under a head: shoulders, in a shirt of `fill`, plain or with stripes of `stripes`. */
function Body({ fill, stripes }: { fill: string; stripes?: string }) {
  return (
    <>
      <path d="M-40 104c0-34 12-58 40-58s40 24 40 58z" fill={fill} {...ink} />
      {stripes && (
        <path d="M-27 62h54M-34 74h68M-37 86h74M-39 98h78" stroke={stripes} strokeWidth={5} />
      )}
      <path d="M-24 66q-4 18 2 32M24 66q4 18-2 32" fill="none" {...ink} />
    </>
  );
}

/** Two round eyes with a speck of light. */
function Eyes({ y = -2, gap = 15, r = 4.2 }: { y?: number; gap?: number; r?: number }) {
  return (
    <>
      <circle cx={-gap} cy={y} r={r} fill={DARK} />
      <circle cx={gap} cy={y} r={r} fill={DARK} />
      <circle cx={-gap + 1.4} cy={y - 1.5} r={1.3} fill="#fff" />
      <circle cx={gap + 1.4} cy={y - 1.5} r={1.3} fill="#fff" />
    </>
  );
}

function Cheeks({ y = 12, gap = 26 }: { y?: number; gap?: number }) {
  return (
    <>
      <ellipse cx={-gap} cy={y} rx={6.5} ry={3.6} fill={CHEEK} opacity={0.8} />
      <ellipse cx={gap} cy={y} rx={6.5} ry={3.6} fill={CHEEK} opacity={0.8} />
    </>
  );
}

/** A small smile: a "w" under the nose. */
function Smile({ y = 16 }: { y?: number }) {
  return (
    <path
      d={`M-7 ${y}q3.5 4 7 0q3.5 4 7 0`}
      fill="none"
      stroke={DARK}
      strokeWidth={2}
      strokeLinecap="round"
    />
  );
}

function Panda({ shirt, stripes, ...place }: Place & Shirt) {
  return (
    <Group {...place}>
      <Body fill={shirt} stripes={stripes} />
      <circle cx={-30} cy={-30} r={13} fill="#77706a" {...ink} />
      <circle cx={30} cy={-30} r={13} fill="#77706a" {...ink} />
      <ellipse cx={0} cy={0} rx={43} ry={38} fill="#fbf8f1" {...ink} />
      <ellipse cx={-16} cy={0} rx={10.5} ry={13} fill="#77706a" transform="rotate(28 -16 0)" />
      <ellipse cx={16} cy={0} rx={10.5} ry={13} fill="#77706a" transform="rotate(-28 16 0)" />
      <circle cx={-15} cy={-1} r={4.6} fill="#fbf8f1" />
      <circle cx={15} cy={-1} r={4.6} fill="#fbf8f1" />
      <circle cx={-14.4} cy={-0.4} r={2.6} fill={DARK} />
      <circle cx={15.6} cy={-0.4} r={2.6} fill={DARK} />
      <ellipse cx={0} cy={12} rx={5.5} ry={3.8} fill={DARK} />
      <Smile y={17} />
      <Cheeks y={16} gap={29} />
    </Group>
  );
}

function Koala({ shirt, stripes, ...place }: Place & Shirt) {
  return (
    <Group {...place}>
      <Body fill={shirt} stripes={stripes} />
      <circle cx={-38} cy={-16} r={21} fill="#bdb8b2" {...ink} />
      <circle cx={38} cy={-16} r={21} fill="#bdb8b2" {...ink} />
      <circle cx={-38} cy={-15} r={11} fill="#e5e0d9" />
      <circle cx={38} cy={-15} r={11} fill="#e5e0d9" />
      <ellipse cx={0} cy={2} rx={40} ry={36} fill="#bdb8b2" {...ink} />
      <Eyes y={-4} gap={17} r={3.8} />
      <ellipse cx={0} cy={9} rx={9} ry={12.5} fill="#6e6862" />
      <Cheeks y={14} gap={26} />
    </Group>
  );
}

function Bear({
  fur,
  muzzle,
  shirt,
  stripes,
  ...place
}: Place & Shirt & { fur: string; muzzle: string }) {
  return (
    <Group {...place}>
      <Body fill={shirt} stripes={stripes} />
      <circle cx={-29} cy={-29} r={12.5} fill={fur} {...ink} />
      <circle cx={29} cy={-29} r={12.5} fill={fur} {...ink} />
      <circle cx={-29} cy={-28} r={6} fill={muzzle} />
      <circle cx={29} cy={-28} r={6} fill={muzzle} />
      <ellipse cx={0} cy={0} rx={41} ry={37} fill={fur} {...ink} />
      <ellipse cx={0} cy={13} rx={16} ry={11.5} fill={muzzle} />
      <Eyes y={-5} gap={15} r={3.8} />
      <ellipse cx={0} cy={8} rx={5} ry={3.6} fill={DARK} />
      <Smile y={14} />
      <Cheeks y={9} gap={27} />
    </Group>
  );
}

function Cat({ fur, shirt, stripes, ...place }: Place & Shirt & { fur: string }) {
  return (
    <Group {...place}>
      <Body fill={shirt} stripes={stripes} />
      <path d="M-38-10-34-48-10-32z" fill={fur} {...ink} />
      <path d="M38-10 34-48 10-32z" fill={fur} {...ink} />
      <path d="M-31-18-30-38-19-30z" fill={CHEEK} />
      <path d="M31-18 30-38 19-30z" fill={CHEEK} />
      <ellipse cx={0} cy={0} rx={42} ry={36} fill={fur} {...ink} />
      <path d="M-6-35v9M0-36v10M6-35v9" stroke="#d9b867" strokeWidth={2.4} strokeLinecap="round" />
      <Eyes y={-2} gap={16} r={4} />
      <path d="M-3.5 7h7l-3.5 4z" fill="#d99a88" />
      <Smile y={12} />
      <path
        d="M-22 8h-15M-22 13l-14 4M22 8h15M22 13l14 4"
        stroke={LINE}
        strokeWidth={1.4}
        strokeLinecap="round"
      />
    </Group>
  );
}

function Bunny({ fur, shirt, stripes, ...place }: Place & Shirt & { fur: string }) {
  return (
    <Group {...place}>
      <Body fill={shirt} stripes={stripes} />
      <ellipse
        cx={-15}
        cy={-50}
        rx={10}
        ry={27}
        fill={fur}
        transform="rotate(-8 -15 -50)"
        {...ink}
      />
      <ellipse cx={15} cy={-50} rx={10} ry={27} fill={fur} transform="rotate(8 15 -50)" {...ink} />
      <ellipse cx={-15} cy={-48} rx={4.5} ry={18} fill={CHEEK} transform="rotate(-8 -15 -48)" />
      <ellipse cx={15} cy={-48} rx={4.5} ry={18} fill={CHEEK} transform="rotate(8 15 -48)" />
      <ellipse cx={0} cy={2} rx={39} ry={35} fill={fur} {...ink} />
      <Eyes y={0} gap={15} r={3.8} />
      <ellipse cx={0} cy={10} rx={4} ry={3} fill="#d99a88" />
      <Smile y={14} />
      <Cheeks y={12} gap={25} />
    </Group>
  );
}

function Mouse({ shirt, stripes, ...place }: Place & Shirt) {
  return (
    <Group {...place}>
      <Body fill={shirt} stripes={stripes} />
      <circle cx={-33} cy={-27} r={20} fill="#c3bdb5" {...ink} />
      <circle cx={33} cy={-27} r={20} fill="#c3bdb5" {...ink} />
      <circle cx={-33} cy={-26} r={11} fill="#ead6c8" />
      <circle cx={33} cy={-26} r={11} fill="#ead6c8" />
      <ellipse cx={0} cy={3} rx={38} ry={34} fill="#c3bdb5" {...ink} />
      <Eyes y={0} gap={14} r={3.8} />
      <ellipse cx={0} cy={11} rx={4.5} ry={3.4} fill="#d99a88" />
      <Smile y={15} />
      <Cheeks y={13} gap={24} />
    </Group>
  );
}

function Chick({ shirt, stripes, ...place }: Place & Shirt) {
  return (
    <Group {...place}>
      <Body fill={shirt} stripes={stripes} />
      <path d="M-4-36q4-14 10-4q4-10 8 2" fill="none" {...ink} />
      <ellipse cx={0} cy={0} rx={40} ry={37} fill="#f0d47c" {...ink} />
      <Eyes y={-4} gap={15} r={4} />
      <path
        d="M-7 6h14l-7 8z"
        fill="#eeb26c"
        stroke="#d99a55"
        strokeWidth={1.4}
        strokeLinejoin="round"
      />
      <Cheeks y={9} gap={25} />
    </Group>
  );
}

function Dog({ shirt, stripes, ...place }: Place & Shirt) {
  return (
    <Group {...place}>
      <Body fill={shirt} stripes={stripes} />
      <ellipse cx={0} cy={0} rx={40} ry={36} fill="#e2cca1" {...ink} />
      <ellipse
        cx={-40}
        cy={2}
        rx={12}
        ry={25}
        fill="#c19f70"
        transform="rotate(18 -40 2)"
        {...ink}
      />
      <ellipse
        cx={40}
        cy={2}
        rx={12}
        ry={25}
        fill="#c19f70"
        transform="rotate(-18 40 2)"
        {...ink}
      />
      <ellipse cx={14} cy={-6} rx={11} ry={10} fill="#d3b88c" />
      <Eyes y={-5} gap={14} r={3.8} />
      <ellipse cx={0} cy={8} rx={6} ry={4.2} fill={DARK} />
      <Smile y={14} />
      <path d="M-3 18q3 9 6 0z" fill="#e09a8c" />
    </Group>
  );
}

/** The crowd, back row first, so that each row overlaps the one behind it. */
export function Crowd({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 348 300"
      preserveAspectRatio="xMidYMin slice"
      aria-hidden="true"
    >
      <rect width="348" height="300" fill="#f1e5cb" />
      {/* Back row, cut by the ticket's top edge. */}
      <Chick x={6} y={34} s={0.7} shirt="#e9d39a" />
      <Bunny x={62} y={30} s={0.6} fur="#f6efe2" shirt="#d9c29a" />
      <Koala x={212} y={40} s={0.68} shirt="#e3cf9e" />
      <Bear x={128} y={22} s={0.86} fur="#c9a46f" muzzle="#e8d3ad" shirt="#d8b678" />
      <Cat x={346} y={32} s={0.66} fur="#efd88e" shirt="#cdb48c" />
      <Panda x={280} y={34} s={0.74} shirt="#ead18a" />
      {/* Between the rows, more of the crowd, half hidden. */}
      <Dog x={40} y={70} s={0.56} shirt="#e3cfa2" />
      <Mouse x={110} y={64} s={0.54} shirt="#d9c49a" />
      <Chick x={190} y={70} s={0.56} shirt="#d8bf8e" />
      <Bunny x={252} y={66} s={0.54} fur="#efe5d2" shirt="#e6cf94" />
      <Bear x={326} y={70} s={0.58} fur="#c9a878" muzzle="#ead8b6" shirt="#dcc59b" />
      {/* Second row. */}
      <Mouse x={14} y={114} s={0.6} shirt="#e2cfa6" />
      <Koala x={78} y={100} s={0.64} shirt="#cfc8bf" stripes="#a9a198" />
      <Bear x={224} y={112} s={0.66} fur="#d3ae8e" muzzle="#efdcc4" shirt="#e3c27c" />
      <Cat x={298} y={100} s={0.68} fur="#efd27a" shirt="#e9dcc0" stripes="#d4b878" />
      <Panda x={150} y={94} s={0.78} shirt="#efd27a" />
      <Cat x={44} y={146} s={0.56} fur="#e8d9b4" shirt="#d9c49a" />
      <Bear x={116} y={140} s={0.58} fur="#c39d6c" muzzle="#e6d0a8" shirt="#e3cc93" />
      <Koala x={190} y={144} s={0.54} shirt="#e6d2a4" />
      <Chick x={262} y={142} s={0.56} shirt="#d9c9a8" />
      <Dog x={330} y={140} s={0.56} shirt="#e3cfa2" />
      {/* Third row. */}
      <Bunny x={30} y={184} s={0.64} fur="#f3ebdc" shirt="#d9c9a8" />
      <Dog x={186} y={170} s={0.66} shirt="#cfc8bf" stripes="#b1a99f" />
      <Koala x={340} y={180} s={0.64} shirt="#e6cf94" />
      <Chick x={110} y={176} s={0.72} shirt="#e9c56c" />
      <Bear x={262} y={188} s={0.84} fur="#d6b373" muzzle="#efdcb4" shirt="#d2b98e" />
      <Mouse x={70} y={216} s={0.54} shirt="#e3cfa2" />
      <Bunny x={150} y={214} s={0.54} fur="#efe5d2" shirt="#dcc59b" />
      <Cat x={222} y={218} s={0.54} fur="#efd88e" shirt="#e6d2a4" />
      <Dog x={306} y={216} s={0.56} shirt="#d9c9a8" />
      {/* Front rows, fading into the paper. */}
      <Bear x={56} y={250} s={0.7} fur="#e2c896" muzzle="#f3e5c6" shirt="#e8d6ad" />
      <Cat x={134} y={246} s={0.66} fur="#ece1c9" shirt="#e3cc93" />
      <Bear x={206} y={240} s={0.7} fur="#dcc09a" muzzle="#f1e2c8" shirt="#e6d2a4" />
      <Panda x={288} y={250} s={0.74} shirt="#e7d6aa" />
      <Panda x={352} y={242} s={0.64} shirt="#e6cf94" />
      <Koala x={18} y={304} s={0.68} shirt="#ead7a8" />
      <Panda x={96} y={308} s={0.7} shirt="#e2cfa6" />
      <Cat x={172} y={304} s={0.68} fur="#efd88e" shirt="#e8d6ad" />
      <Bear x={250} y={310} s={0.72} fur="#d9bd8e" muzzle="#f1e2c8" shirt="#e3cc93" />
      <Bunny x={330} y={306} s={0.66} fur="#f3ebdc" shirt="#ead7a8" />
    </svg>
  );
}
```

### tear-coupon/demo-pointer.tsx

```tsx
'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,
 * glide the pointer onto a control and press it, or grab a stub and drag it, calling the
 * component's own handlers as a hand would. 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.
 *
 * Nothing the pointer does while the demo plays needs a layout, because one here in the same
 * frame as one in the phone would make the browser lay out the whole page (see the note at the
 * top of the stylesheet). So it moves by animations the compositor plays (a drag too: the peel
 * follows that animation's clock); the press scales a box that keeps a transform at rest, since a
 * box that gains or loses one is laid out again; and the arrow and the hand are HTML boxes that
 * swap by their visibility (a change of style inside an SVG lays the SVG out again).
 */

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

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /** Glides the pointer to a point of the screen, in client px. */
  moveToPoint: (point: Point) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
  /**
   * Grabs, drags to a point of the screen (client px) over `ms`, and lets go. `step` gets the
   * share of the way done on every frame, so the dragged value moves with the hand.
   */
  drag: (to: Point, ms: number, step: (share: number) => void) => Promise<void>;
};

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

/** Where the arrow's tip is, in its 20 × 20 box: the point the pointer acts on. */
const TIP: Point = { x: 3, y: 2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;
const clamp = (value: number) => Math.min(1, Math.max(0, value));
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);

/** 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;
  let frame = 0;
  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.82, y: stage.offsetHeight * 0.92 };

  /** 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);
      }),
    );

  /** A point of the screen in the stage's own px (the stage may sit in a scaled container). */
  const toStage = (point: Point): Point => {
    const frameBox = stage.getBoundingClientRect();
    const scale = frameBox.width / stage.offsetWidth || 1;
    return { x: (point.x - frameBox.left) / scale, y: (point.y - frameBox.top) / scale };
  };

  const glide = (to: Point) => {
    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.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          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,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const box = target.getBoundingClientRect();
    return glide(toStage({ x: box.left + box.width * share.x, y: box.top + box.height * share.y }));
  };

  const press: Demo['press'] = async (action) => {
    // The glyph dips towards its tip.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  const drag: Demo['drag'] = (point, ms, onStep) => {
    const from = at;
    const to = toStage(point);
    at = to;
    pointer.dataset.grab = '';
    // The hand's path, eased, as an animation; the value reads the same clock every frame.
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 16; i += 1) {
      const share = easeInOut(i / 16);
      keyframes.push({
        transform: place({
          x: from.x + (to.x - from.x) * share,
          y: from.y + (to.y - from.y) * share,
        }),
      });
    }
    flight?.cancel();
    pointer.style.transform = place(to);
    const motion = pointer.animate(keyframes, { duration: ms, easing: 'linear' });
    flight = motion;
    return step(
      new Promise<void>((resolve) => {
        const tick = () => {
          const time = motion.currentTime;
          // Clamped at both ends; a finished animation is the end of the way.
          const t =
            motion.playState === 'finished' ? 1 : clamp(typeof time === 'number' ? time / ms : 0);
          onStep(easeInOut(t));
          if (t < 1) frame = requestAnimationFrame(tick);
          else {
            frame = 0;
            delete pointer.dataset.grab;
            resolve();
          }
        };
        frame = requestAnimationFrame(tick);
      }),
    );
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({
    wait,
    moveTo,
    moveToPoint: (point) => glide(toStage(point)),
    press,
    drag,
  }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

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

/** The pointer's box: it moves, on a layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** Inside it, what dips towards the tip when the pointer presses, on a layer of its own. */
const PRESS: CSSProperties = {
  display: 'block',
  width: 22,
  height: 22,
  transform: 'scale(1)',
  transformOrigin: `${TIP.x * 1.1}px ${TIP.y * 1.1}px`,
  willChange: 'transform',
};

/** One glyph, over the other: the 20 × 20 drawing at 22 px. */
const GLYPH: CSSProperties = { position: 'absolute', top: 0, left: 0, display: 'block' };
const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };
/** The arrow's paint and the hand's, the stage's tokens (tear-coupon.module.css). */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };
const KNUCKLES: CSSProperties = { stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a closed hand while it drags. It is decorative:
 * the demo's effects show on the controls themselves.
 */
export function DemoPointer({ ref, className }: { ref: Ref<HTMLSpanElement>; className?: string }) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <span data-glyph="arrow" 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 data-glyph="hand" style={GLYPH}>
          <svg viewBox="0 0 20 20" style={DRAWING}>
            {/* The hand's middle sits on the arrow's tip. */}
            <g transform="translate(-6.6 -9.4)">
              <path
                d="M6.1 16.6c-1.2-1.2-1.8-2.6-1.8-4.3v-1.6c0-.7.5-1.2 1.1-1.2.2 0 .4 0 .6.1V8.1c0-.6.5-1.1 1.1-1.1s1.1.5 1.1 1.1v-.5c0-.6.5-1.1 1.1-1.1s1.1.5 1.1 1.1v.3c0-.6.5-1.1 1.1-1.1s1.1.5 1.1 1.1v.7c0-.5.5-1 1-1 .6 0 1.1.5 1.1 1.1v3.7c0 1.9-.8 3.2-1.8 4.1z"
                style={HAND}
                strokeWidth="1.1"
                strokeLinejoin="round"
              />
              <path
                d="M8.2 8.1v1.7M10.4 7.9v1.9M12.6 8.6v1.5"
                fill="none"
                style={KNUCKLES}
                strokeWidth="0.9"
                strokeLinecap="round"
              />
            </g>
          </svg>
        </span>
      </span>
    </span>
  );
}
```

### tear-coupon/use-autoplay.ts

```tsx
'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.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  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, playing: visible && !reduced && !taken };
}
```