# Promo sheet

> A phone bottom sheet that rises over a listings screen while a gold wax seal forms in it; drag it down or tap X to dismiss.

A short promo for a listings app: the sheet rises over the search screen, which stays visible behind a scrim and recedes into a card. A lemon drop swells into a gold wax seal with a check pressed into it. Drag the sheet down, tap the X or the scrim, or press Escape to dismiss it; the Vetted chip brings it back.

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

Category: Mobile · Overlays.

## Anatomy

1. **Status bar**: Time, island and icons over everything; its words turn white while the sheet is up, over the dark top.
2. **Page behind**: The listings screen, inert while the sheet is up: moved 58 down and shrunk to 95.5% into a rounded card.
3. **Vetted chip**: The way back: a button with aria-haspopup="dialog" that opens the sheet again and moves focus into it.
4. **Sheet**: A modal dialog from the bottom edge, named by its heading. It follows a drag and closes past a third of its height or on a flick.
5. **Close**: A 44-point X in the corner; it closes the sheet and sends focus back to the chip.
6. **Seal**: Decorative gold wax that forms in 1.5 s every time the sheet opens, finished at rest.
7. **Heading and line**: The promise in 25.5-point type, and the line that backs it up.
8. **Action**: The one primary button, full width. It closes the sheet and calls `onAction`.

## When to use

- One short promise to tell a phone user once, over the screen it is about: a new badge, a guarantee, a feature that just shipped.
- A message that earns a moment of attention but should be easy to wave away: X, a drag down, the scrim or Escape.
- When the screen behind must stay in view, so the reader knows where they are and where the promo leads.
- A promise with one next step: a heading, a line and a single button, with a way back (the chip) once dismissed.

## When not to use

- A choice the reader must make before going on (delete, pay, sign out). Use an alert dialog with two plain buttons.
- A list of options or a form. Use an action sheet or a full-screen modal with its own navigation.
- Something to say on every visit. Show it once and keep it behind the chip, or use an inline banner in the list.
- An error or a status that needs no decision. Use a toast or an inline message.
- A desktop layout. It is drawn as a 390-point phone; on a wide page use a centred dialog or a card.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/promo-sheet.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.

Copy these media files to the same paths under your public folder:

- https://brunodelorence.com/experiments/promo-sheet/gold-seal-3d-v1.webp
- https://brunodelorence.com/experiments/promo-sheet/camera-photo-v1.webp
- https://brunodelorence.com/experiments/promo-sheet/chef-photo-v1.webp
- https://brunodelorence.com/experiments/promo-sheet/massage-photo-v1.webp
- https://brunodelorence.com/experiments/promo-sheet/training-photo-v1.webp
- https://brunodelorence.com/experiments/promo-sheet/home-3d-v1.webp
- https://brunodelorence.com/experiments/promo-sheet/balloon-3d-v1.webp
- https://brunodelorence.com/experiments/promo-sheet/cloche-3d-v1.webp
- https://brunodelorence.com/experiments/swipe-deck/shore-photo-v1.webp
- https://brunodelorence.com/experiments/swipe-deck/dusk-photo-v1.webp
- https://brunodelorence.com/experiments/swipe-deck/window-photo-v1.webp

## Usage

```tsx
import { PromoSheet } from './promo-sheet';

export function VettedPromo() {
  return (
    <PromoSheet
      title="Services on Stayline are vetted for quality"
      actionLabel="Explore services"
      onAction={() => console.log('explore')}
      autoplay={false}
    />
  );
}
```

## States

- **Open**: The sheet up with its seal formed, the scrim at 45% and the page receded into a card.
- **Seal: sphere**: At 400 ms of the forming, a lemon sphere swells out of a speck in the middle of the sheet.
- **Seal: forming**: At 540 ms the gold sphere gives way to the rendered wax seal, which spreads out with a slight turn.
- **Dragged**: Held 30% of the way down: the scrim and the page follow its progress. Let go here and it springs back; past a third it closes.
- **Action pressed**: The button darkens and shrinks to 97% under the finger; on release the sheet closes.
- **Closed**: The listings at full screen with the status bar back in the page’s ink; the Vetted chip brings the sheet back.
- **Dark**: A near-black page and a warm charcoal sheet; the action turns light, the gold stays gold.

## How it works

- The phone is drawn in CSS at its design size, a 390 × 844 screen in a 412 × 864 body, and scaled as one piece to fit the stage. The scale comes from the stage’s own size (a size container; tan(atan2()) of two lengths is their ratio), so nothing inside it lays out again.
- Opening is three CSS transitions on one 500 ms quint ease-out: the sheet slides up from below, the scrim fades in to 45% black, and the page behind moves 58 px down and shrinks to 95.5% width, so it reads as a card. The status bar turns white on black. Closing runs the same transitions back in 340 ms.
- A transparent raster 3D render supplies the gold wax texture, raised rim and embossed check. A CSS sphere swells from a speck between 240 and 460 ms, then the rendered seal spreads into place with a slight turn and overshoot until about 950 ms. Service tiles and featured sessions use photographic images.
- Dragging the sheet sets its transform directly, frame by frame, with a rubber band upwards. The scrim and the receding page follow its progress. On release, the styles go back to the CSS transitions: past a third of the sheet’s height, or flicked faster than 0.5 px/ms, it closes on a faster 260 ms curve; otherwise it springs back.
- A drag that starts on a button is not a press of it: the click is swallowed once the finger has moved 6 px.
- While on screen and untouched, a demo finger taps the X, reopens the sheet with the chip, drags it away, and presses its button, all in a loop. Any input from the reader hands control to them.
- Each layer’s resting style is its final state, so under reduced motion the sheet and the finished seal simply appear.

## API reference

- **title** (`string`, default `'Services on Stayline are vetted for quality'`): The sheet’s heading, and its accessible name.
- **description** (`string`, default `'Services are evaluated for expertise and reputation.'`): The line under the heading.
- **actionLabel** (`string`, default `'Explore services'`): The primary button’s words.
- **onAction** (`() => void`): Called when the reader presses the primary button; the sheet closes as well.
- **defaultOpen** (`boolean`, default `true`): The sheet rises on its own the first time the phone comes on screen.
- **defaultState** (`'open' | 'closed' | 'dragging'`): Opens already in this state, with no rise and no demo: the sheet up with its seal formed, the listings with the sheet away, or the sheet held 30% of the way down as if dragged.
- **sealAt** (`number`): Holds the first seal still at this many milliseconds of its 1.5 s forming (the sheet opens up, with no demo).
- **autoplay** (`boolean`, default `true`): While visible and untouched, a demo finger closes the sheet, drags it away and reopens it. Any input from the reader stops it.
- **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`): Added to the stage element.

## Accessibility

- The sheet is a modal dialog (role="dialog", aria-modal) named by its heading and described by its text. While it is open the page behind is inert; while it is closed the sheet is.
- Opening it from the chip moves focus into the sheet. Escape, the X or a tap on the scrim closes it, and focus goes back to the chip, which has aria-haspopup="dialog" and aria-expanded.
- Every control is a real button with a visible :focus-visible ring. The category tabs and the save hearts use aria-pressed.
- A live region announces a dismissal by drag and a press of the primary button.
- The phone frame, status bar, service photographs, seal and demo finger are decorative and hidden from assistive technology.
- Under reduced motion the sheet appears and leaves without sliding, the seal appears finished, and the demo finger never plays.

## Source

### promo-sheet/promo-sheet.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './promo-sheet.module.css';

/*
 * Promo sheet: a phone showing a listings screen, over which a bottom sheet rises to tell the
 * reader that the services listed are vetted. A gold wax seal forms in the sheet as it lands; an
 * X and a drag down dismiss it, and the "Vetted" chip by the section title brings it back.
 *
 * - The phone is drawn in CSS at its design size (a 390 × 844 screen in a 412 × 864 body) and
 *   scaled as one piece to fit the stage, by a scale worked out in CSS from the stage's own size
 *   (a container query), so nothing inside it ever lays out again.
 * - Open, the sheet slides up in 500 ms on a quint ease-out, the scrim darkens the screen to 45%
 *   and the page behind recedes into a card (58 px down, 95.5% wide, rounded), under a status bar
 *   that turns white on black. Closed, all three go back in 340 ms.
 * - The seal uses a raster 3D render with a CSS animation that starts when the sheet opens: a lemon
 *   sphere swells from a speck as it rises, then the dimensional wax render spreads into place
 *   with a slight turn. The embossed check and surface highlights come from the image;
 *   only transforms and opacities move.
 * - Dragging the sheet moves it with the finger (and rubber-bands upwards); the scrim and the
 *   page follow its progress. Let go past a third of its height, or flicked down, it closes;
 *   otherwise it springs back. The drag writes styles straight to the three layers, one frame at
 *   a time, and hands back to the CSS transitions on release.
 * - While on screen and untouched, a demo finger closes it by the X, brings it back with the chip,
 *   drags it away, and presses its button. Any input of the reader's hands control to them.
 * - 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.
 */

/** The page behind, receded: how far down it moves and how much it shrinks. */
const RECEDE_Y = 58;
const RECEDE_SCALE = 0.955;
/** The drag: how far before it counts as one, and the line and the speed (px per ms) that close it. */
const SLOP = 6;
const CLOSE_AT = 1 / 3;
const FLICK = 0.5;
/** The phone's body width in design px, to turn screen px into design px. */
const PHONE_WIDTH = 412;
/** How long the sheet waits, once first on screen, before it rises. */
const FIRST_SHOW = 450;

type Step = (ms: number) => Promise<void>;

type Listing = { title: string; note: string; art: 'camera' | 'plate' | 'hands' | 'weights' };
const LISTINGS: Listing[] = [
  { title: 'Photography', note: '132 available', art: 'camera' },
  { title: 'Chefs', note: '12 available', art: 'plate' },
  { title: 'Massage', note: '1 available', art: 'hands' },
  { title: 'Training', note: '14 available', art: 'weights' },
];
const FEATURED = [
  {
    title: 'Golden hour portraits',
    lines: ['From $45 / guest', '★ 5.0'],
    art: 'harbour' as const,
    tag: 'Popular',
  },
  {
    title: 'Outdoor portrait sessions',
    lines: ['From $45 / group', '★ 4.86'],
    art: 'street' as const,
  },
  {
    title: 'Natural light portraits',
    lines: ['From $60 / guest', '★ 4.9'],
    art: 'studio' as const,
  },
];
const TABS = [
  { id: 'homes', label: 'Homes', badge: false },
  { id: 'experiences', label: 'Experiences', badge: true },
  { id: 'services', label: 'Services', badge: true },
] as const;
type TabId = (typeof TABS)[number]['id'];

export function PromoSheet({
  title = 'Services on Stayline are vetted for quality',
  description = 'Services are evaluated for expertise and reputation.',
  actionLabel = 'Explore services',
  onAction,
  defaultOpen = true,
  defaultState,
  sealAt,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The sheet's heading. */
  title?: string;
  /** The line under it. */
  description?: string;
  /** The primary button's words. */
  actionLabel?: string;
  /** Called when the primary button is pressed, after the sheet closes. */
  onAction?: () => void;
  /** The sheet rises on its own the first time the phone comes on screen. */
  defaultOpen?: boolean;
  /**
   * Opens already in this state, with no rise and no demo: the sheet up with its seal formed, the
   * listings with the sheet away, or the sheet held 30% of the way down as if dragged.
   */
  defaultState?: 'open' | 'closed' | 'dragging';
  /** Holds the first seal still at this many ms of its 1.5 s forming (with no demo). */
  sealAt?: number;
  /** While on screen and untouched, a demo finger closes, drags and reopens the sheet. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  /** Opened in a given state: no rise, no demo, and the first seal held still. */
  const held = defaultState !== undefined || sealAt !== undefined;
  const startsOpen = defaultState === 'open' || defaultState === 'dragging' || sealAt !== undefined;
  const [open, setOpen] = useState(startsOpen);
  /** Bumped on every opening, so the seal plays again. */
  const [run, setRun] = useState(startsOpen ? 1 : 0);
  /** Where the first seal is held: finished, unless sealAt says otherwise. */
  const [heldSeal] = useState(() => (startsOpen ? (sealAt ?? 1500) : undefined));
  const [tab, setTab] = useState<TabId>('services');
  const [liked, setLiked] = useState<string[]>([]);
  const [message, setMessage] = useState('');
  const titleId = useId();
  const descriptionId = useId();

  const phone = useRef<HTMLDivElement>(null);
  const screen = useRef<HTMLDivElement>(null);
  const page = useRef<HTMLDivElement>(null);
  const scrim = useRef<HTMLDivElement>(null);
  const sheet = useRef<HTMLDivElement>(null);
  const closeButton = useRef<HTMLButtonElement>(null);
  const actionButton = useRef<HTMLButtonElement>(null);
  const chip = useRef<HTMLButtonElement>(null);
  const finger = useRef<HTMLSpanElement>(null);
  const openRef = useRef(false);
  openRef.current = open;
  /** The focus moves into the sheet on the next render (the reader opened it). */
  const focusSheet = useRef(false);
  /** The focus goes back to the chip on the next render (the reader closed it from inside). */
  const focusChip = useRef(false);
  const releaseTimer = useRef(0);
  const drag = useRef({
    id: -1,
    startY: 0,
    dy: 0,
    lastY: 0,
    lastT: 0,
    velocity: 0,
    active: false,
    moved: false,
  });

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

  const show = useCallback((byReader: boolean) => {
    if (openRef.current) return;
    focusSheet.current = byReader;
    setOpen(true);
    setRun((count) => count + 1);
  }, []);

  const hide = useCallback(
    (byReader: boolean, fling = false) => {
      if (!openRef.current) return;
      const element = root.current;
      if (element && fling) {
        // A flick closes faster, carrying the finger's speed on.
        element.dataset.release = 'fling';
        window.clearTimeout(releaseTimer.current);
        releaseTimer.current = window.setTimeout(() => delete element.dataset.release, 400);
      }
      // The focus goes back to the chip once the page behind is no longer inert.
      focusChip.current = byReader && !!sheet.current?.contains(document.activeElement);
      setOpen(false);
    },
    [root],
  );

  useEffect(() => {
    if (open && focusSheet.current) sheet.current?.focus({ preventScroll: true });
    if (!open && focusChip.current) chip.current?.focus({ preventScroll: true });
    focusSheet.current = false;
    focusChip.current = false;
  }, [open]);

  useEffect(() => () => window.clearTimeout(releaseTimer.current), []);

  // The sheet first rises once the phone is on screen.
  useEffect(() => {
    const element = root.current;
    if (!element || !defaultOpen || held) return;
    let timer = 0;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        observer.disconnect();
        timer = window.setTimeout(() => show(false), FIRST_SHOW);
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    return () => {
      observer.disconnect();
      window.clearTimeout(timer);
    };
  }, [defaultOpen, held, root, show]);

  /** Screen px to the phone's design px. */
  const scaleOf = () => {
    const body = phone.current;
    return body ? body.getBoundingClientRect().width / PHONE_WIDTH || 1 : 1;
  };

  /** Draws the sheet `dy` design px below its place, and the scrim and the page to match. */
  const place = (dy: number) => {
    const panel = sheet.current;
    const shade = scrim.current;
    const behind = page.current;
    const element = root.current;
    if (!panel || !shade || !behind || !element) return;
    // Upwards it gives a little, then less and less.
    const y = dy < 0 ? -(1 - 1 / (1 - dy / 160)) * 48 : dy;
    const progress = Math.min(1, Math.max(0, 1 - y / panel.offsetHeight));
    element.dataset.dragging = 'true';
    panel.style.transform = `translateY(${y.toFixed(1)}px)`;
    shade.style.opacity = progress.toFixed(3);
    behind.style.transform = `translateY(${(RECEDE_Y * progress).toFixed(1)}px) scale(${(
      1 -
      (1 - RECEDE_SCALE) * progress
    ).toFixed(4)})`;
  };

  // Opened as if dragged: the sheet held 30% of the way down, the scrim and page to match.
  const holdDrag = useEffectEvent(() => place((sheet.current?.offsetHeight ?? 0) * 0.3));
  useLayoutEffect(() => {
    if (defaultState === 'dragging') holdDrag();
  }, [defaultState]);

  /** Hands the three layers back to their CSS: back in place, or closed. */
  const release = (dy: number, velocity: number, byReader: boolean) => {
    const element = root.current;
    for (const layer of [sheet.current, scrim.current, page.current]) {
      if (!layer) continue;
      layer.style.transform = '';
      layer.style.opacity = '';
    }
    if (element) delete element.dataset.dragging;
    const height = sheet.current?.offsetHeight ?? 1;
    if (dy > height * CLOSE_AT || velocity > FLICK) {
      hide(byReader, true);
      if (byReader) announce('Dismissed');
    }
  };

  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.button !== 0 || !open) return;
    const state = drag.current;
    state.id = event.pointerId;
    state.startY = event.clientY;
    state.lastY = event.clientY;
    state.lastT = event.timeStamp;
    state.velocity = 0;
    state.dy = 0;
    state.active = false;
    state.moved = false;
  };
  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    const state = drag.current;
    if (event.pointerId !== state.id) return;
    const scale = scaleOf();
    const dy = (event.clientY - state.startY) / scale;
    if (!state.active) {
      if (Math.abs(dy) < SLOP) return;
      state.active = true;
      state.moved = true;
      event.currentTarget.setPointerCapture(event.pointerId);
    }
    const elapsed = event.timeStamp - state.lastT;
    if (elapsed > 0) {
      const now = (event.clientY - state.lastY) / scale / elapsed;
      state.velocity = state.velocity * 0.4 + now * 0.6;
    }
    state.lastY = event.clientY;
    state.lastT = event.timeStamp;
    state.dy = dy;
    place(dy);
  };
  const onPointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
    const state = drag.current;
    if (event.pointerId !== state.id) return;
    state.id = -1;
    if (!state.active) return;
    state.active = false;
    // A finger held still before letting go has no speed left.
    const velocity = event.timeStamp - state.lastT > 80 ? 0 : state.velocity;
    release(state.dy, event.type === 'pointercancel' ? 0 : velocity, true);
  };

  const press = (byReader: boolean) => {
    hide(byReader);
    if (byReader) {
      announce(`${actionLabel}`);
      onAction?.();
    }
  };

  // The demo: a finger closes the sheet by its X, reopens it with the chip, drags it away,
  // reopens it, and presses its button; over and over while nobody touches it.
  const demoShow = useEffectEvent(() => show(false));
  const demoHide = useEffectEvent((fling: boolean) => hide(false, fling));
  const demoPlace = useEffectEvent((dy: number) => place(dy));
  const demoRelease = useEffectEvent((dy: number, velocity: number) =>
    release(dy, velocity, false),
  );
  const demoIsOpen = useEffectEvent(() => openRef.current);
  useEffect(() => {
    const element = root.current;
    const glass = screen.current;
    const dot = finger.current;
    if (!autoplay || held || !playing || !element || !glass || !dot) return;
    let cancelled = false;
    let dragging = 0;
    let moving: Animation | null = null;
    const timers = new Set<number>();
    const wait: Step = (ms) =>
      new Promise((resolve, reject) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          if (cancelled) reject(new Error('stopped'));
          else resolve();
        }, ms);
        timers.add(timer);
      });
    let at = { x: 300, y: 900 };
    const scale = () => glass.getBoundingClientRect().width / glass.offsetWidth || 1;
    /** The middle of an element, in the screen's design px. */
    const middleOf = (target: Element | null) => {
      if (!target) return at;
      const frame = glass.getBoundingClientRect();
      const box = target.getBoundingClientRect();
      const s = scale();
      return {
        x: (box.left + box.width / 2 - frame.left) / s,
        y: (box.top + box.height / 2 - frame.top) / s,
      };
    };
    const put = (point: { x: number; y: number }, down = false) =>
      `translate(${point.x.toFixed(1)}px, ${point.y.toFixed(1)}px) scale(${down ? 0.82 : 1})`;
    const glide = async (to: { x: number; y: number }, ms: number) => {
      moving?.cancel();
      moving = dot.animate([{ transform: put(at) }, { transform: put(to) }], {
        duration: ms,
        easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
        fill: 'forwards',
      });
      at = to;
      await wait(ms);
    };
    const appear = async (to: { x: number; y: number }) => {
      at = { x: to.x + 40, y: to.y + 120 };
      dot.dataset.shown = 'true';
      await glide(to, 520);
    };
    const tap = async (target: HTMLElement | null) => {
      if (!target) return;
      moving?.cancel();
      moving = dot.animate([{ transform: put(at) }, { transform: put(at, true) }], {
        duration: 120,
        easing: 'ease-out',
        fill: 'forwards',
      });
      target.dataset.pressed = 'true';
      await wait(160);
      delete target.dataset.pressed;
      moving?.cancel();
      moving = dot.animate([{ transform: put(at, true) }, { transform: put(at) }], {
        duration: 160,
        easing: 'ease-out',
        fill: 'forwards',
      });
    };
    const leave = async () => {
      await wait(220);
      delete dot.dataset.shown;
      await wait(260);
    };
    /** Drags the sheet down by `distance` design px in `ms`, then lets go. */
    const pull = (distance: number, ms: number) =>
      new Promise<void>((resolve, reject) => {
        const from = at;
        const start = performance.now();
        moving?.cancel();
        const frame = (now: number) => {
          if (cancelled) return reject(new Error('stopped'));
          const t = Math.min(1, (now - start) / ms);
          const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
          const dy = distance * eased;
          at = { x: from.x, y: from.y + dy };
          dot.style.transform = put(at, true);
          demoPlace(dy);
          if (t < 1) dragging = requestAnimationFrame(frame);
          else {
            dragging = 0;
            demoRelease(distance, 0.9);
            resolve();
          }
        };
        dragging = requestAnimationFrame(frame);
      });

    const play = async () => {
      if (!demoIsOpen()) {
        await wait(900);
        demoShow();
      }
      for (;;) {
        // The X.
        await wait(2600);
        await appear(middleOf(closeButton.current));
        await wait(180);
        await tap(closeButton.current);
        demoHide(false);
        await leave();
        // The chip brings it back.
        await wait(700);
        await appear(middleOf(chip.current));
        await wait(160);
        await tap(chip.current);
        demoShow();
        await leave();
        // A drag down.
        await wait(2600);
        const grip = middleOf(sheet.current?.querySelector('h2') ?? null);
        await appear({ x: grip.x + 30, y: grip.y });
        moving?.cancel();
        dot.style.transform = put(at, true);
        await wait(140);
        await pull(230, 420);
        dot.style.transform = put(at);
        await leave();
        await wait(700);
        await appear(middleOf(chip.current));
        await wait(160);
        await tap(chip.current);
        demoShow();
        await leave();
        // The button.
        await wait(2600);
        await appear(middleOf(actionButton.current));
        await wait(180);
        await tap(actionButton.current);
        demoHide(false);
        await leave();
        await wait(700);
        await appear(middleOf(chip.current));
        await wait(160);
        await tap(chip.current);
        demoShow();
        await leave();
      }
    };
    play().catch(() => {});
    return () => {
      cancelled = true;
      for (const timer of timers) window.clearTimeout(timer);
      cancelAnimationFrame(dragging);
      moving?.cancel();
      dot.style.transform = '';
      delete dot.dataset.shown;
      for (const target of [closeButton.current, chip.current, actionButton.current]) {
        if (target) delete target.dataset.pressed;
      }
      // A drag the demo was making goes back to its place.
      if (element.dataset.dragging) demoRelease(0, 0);
    };
  }, [autoplay, held, playing, root]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-open={open}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onWheel={take}
      onKeyDown={(event) => {
        if (event.key === 'Escape' && open) {
          event.stopPropagation();
          hide(true);
        }
      }}
    >
      <div ref={phone} className={styles.phone} data-part="phone">
        <div ref={screen} className={styles.screen} data-part="screen">
          <div ref={page} className={styles.page} data-part="page" inert={open}>
            <div className={styles.header} data-part="header">
              <div className={styles.search} data-part="search">
                <svg className={styles.searchIcon} viewBox="0 0 16 16" aria-hidden="true">
                  <circle cx="7" cy="7" r="4.6" />
                  <path d="M10.4 10.4 14 14" />
                </svg>
                <span>Start your search</span>
              </div>
              <div className={styles.tabs} data-part="tabs">
                {TABS.map((item) => (
                  <button
                    key={item.id}
                    type="button"
                    className={styles.tab}
                    aria-pressed={tab === item.id}
                    onClick={() => setTab(item.id)}
                  >
                    <span className={styles.tabArt} aria-hidden="true">
                      <TabArt id={item.id} />
                      {item.badge && <span className={styles.badge}>NEW</span>}
                    </span>
                    <span className={styles.tabLabel}>{item.label}</span>
                  </button>
                ))}
              </div>
            </div>
            <div className={styles.body} data-part="listings">
              <div className={styles.sectionHead}>
                <h3 className={styles.sectionTitle}>
                  {tab === 'homes' ? 'Homes' : tab === 'experiences' ? 'Experiences' : 'Services'}{' '}
                  in New York City
                </h3>
                <button
                  ref={chip}
                  type="button"
                  className={styles.chip}
                  data-part="reopen"
                  aria-haspopup="dialog"
                  aria-expanded={open}
                  onClick={() => show(true)}
                >
                  <span className={styles.chipSeal} aria-hidden="true" />
                  Vetted
                </button>
              </div>
              <ul className={styles.row} aria-label="Categories">
                {LISTINGS.map((item) => (
                  <li key={item.title} className={styles.thumbItem}>
                    <span className={styles.thumb} data-art={item.art} aria-hidden="true">
                      <ThumbArt art={item.art} />
                    </span>
                    <span className={styles.thumbTitle}>{item.title}</span>
                    <span className={styles.thumbNote}>{item.note}</span>
                  </li>
                ))}
              </ul>
              <h3 className={styles.sectionTitle}>
                Photography <span aria-hidden="true">›</span>
              </h3>
              <ul className={styles.cards} aria-label="Photography">
                {FEATURED.map((item) => (
                  <li key={item.title} className={styles.card}>
                    <span className={styles.cardArt} data-art={item.art} aria-hidden="true">
                      <CardArt art={item.art} />
                    </span>
                    {item.tag && <span className={styles.tag}>{item.tag}</span>}
                    <button
                      type="button"
                      className={styles.heart}
                      aria-label={`Save ${item.title}`}
                      aria-pressed={liked.includes(item.title)}
                      onClick={() =>
                        setLiked((now) =>
                          now.includes(item.title)
                            ? now.filter((title) => title !== item.title)
                            : [...now, item.title],
                        )
                      }
                    >
                      <svg viewBox="0 0 24 24" aria-hidden="true">
                        <path d="M12 20.5s-7.5-4.6-7.5-10.2A4.3 4.3 0 0 1 12 7.6a4.3 4.3 0 0 1 7.5 2.7c0 5.6-7.5 10.2-7.5 10.2Z" />
                      </svg>
                    </button>
                    <span className={styles.cardTitle}>{item.title}</span>
                    <span className={styles.cardNote}>{item.lines.join('  ·  ')}</span>
                  </li>
                ))}
              </ul>
              <h3 className={styles.sectionTitle}>Chefs</h3>
            </div>
          </div>

          <div
            ref={scrim}
            className={styles.scrim}
            data-part="scrim"
            aria-hidden="true"
            onClick={() => hide(true)}
          />

          <div
            ref={sheet}
            className={styles.sheet}
            data-part="sheet"
            role="dialog"
            aria-modal="true"
            aria-labelledby={titleId}
            aria-describedby={descriptionId}
            tabIndex={-1}
            inert={!open}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={onPointerEnd}
            onPointerCancel={onPointerEnd}
            onClickCapture={(event) => {
              // A drag that started on a button is not a press of it.
              if (drag.current.moved) {
                drag.current.moved = false;
                event.preventDefault();
                event.stopPropagation();
              }
            }}
          >
            <button
              ref={closeButton}
              type="button"
              className={styles.close}
              data-part="close"
              aria-label="Close"
              onClick={() => hide(true)}
            >
              <svg viewBox="0 0 12 12" aria-hidden="true">
                <path d="M1 1l10 10M11 1 1 11" />
              </svg>
            </button>
            <div className={styles.sealBox} aria-hidden="true">
              {run > 0 && <Seal key={run} at={run === 1 ? heldSeal : undefined} />}
            </div>
            <h2 id={titleId} className={styles.title} data-part="title">
              {title}
            </h2>
            <p id={descriptionId} className={styles.description} data-part="description">
              {description}
            </p>
            <button
              ref={actionButton}
              type="button"
              className={styles.action}
              data-part="action"
              onClick={() => press(true)}
            >
              {actionLabel}
            </button>
          </div>

          <div className={styles.status} data-part="status-bar" aria-hidden="true">
            <span className={styles.time}>9:41</span>
            <span className={styles.island} />
            <span className={styles.icons}>
              <svg className={styles.signal} viewBox="0 0 18 12">
                <rect x="0" y="8" width="3" height="4" rx="0.8" />
                <rect x="5" y="5.5" width="3" height="6.5" rx="0.8" />
                <rect x="10" y="3" width="3" height="9" rx="0.8" />
                <rect x="15" y="0" width="3" height="12" rx="0.8" />
              </svg>
              <svg className={styles.wifi} viewBox="0 0 17 12">
                <path d="M8.5 2.3c2.4 0 4.6.9 6.3 2.5l1.2-1.2A10.5 10.5 0 0 0 8.5.6 10.5 10.5 0 0 0 1 3.6l1.2 1.2a8.8 8.8 0 0 1 6.3-2.5Z" />
                <path d="M8.5 5.7c1.5 0 2.8.6 3.9 1.5l1.2-1.2A7.2 7.2 0 0 0 8.5 4a7.2 7.2 0 0 0-5.1 2l1.2 1.2a5.5 5.5 0 0 1 3.9-1.5Z" />
                <path d="M8.5 9c.6 0 1.1.2 1.5.6l-1.5 1.6L7 9.6c.4-.4.9-.6 1.5-.6Z" />
              </svg>
              <span className={styles.battery}>
                <span />
              </span>
            </span>
          </div>
          <span className={styles.homeBar} aria-hidden="true" />
          <span className={styles.fingerLayer} aria-hidden="true">
            <span ref={finger} className={styles.finger} />
          </span>
        </div>
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** A growing sphere gives way to the dimensional gold wax render. */
function Seal({ at }: { at?: number }) {
  return (
    <span
      className={styles.seal}
      data-part="seal"
      data-held={at === undefined ? undefined : 'true'}
      style={at === undefined ? undefined : ({ '--seal-at': `${-at}ms` } as CSSProperties)}
    >
      <span className={styles.sealShadow} />
      <span className={styles.sphere} />
      <img
        className={styles.sealArt}
        src="/experiments/promo-sheet/gold-seal-3d-v1.webp"
        width={300}
        height={300}
        alt=""
        draggable={false}
        decoding="async"
      />
    </span>
  );
}

/** Small dimensional object images distinguish the three service tabs. */
function TabArt({ id }: { id: TabId }) {
  const object = { homes: 'home', experiences: 'balloon', services: 'cloche' }[id];
  return (
    <img
      className={styles.tabSvg}
      src={`/experiments/promo-sheet/${object}-3d-v1.webp`}
      width={128}
      height={128}
      alt=""
      draggable={false}
      decoding="async"
    />
  );
}

/** Realistic service photographs, cropped by each tile. */
const SERVICE_PHOTOS = {
  camera: 'camera',
  plate: 'chef',
  hands: 'massage',
  weights: 'training',
} as const;
function ThumbArt({ art }: { art: Listing['art'] }) {
  return (
    <img
      className={styles.art}
      src={`/experiments/promo-sheet/${SERVICE_PHOTOS[art]}-photo-v1.webp`}
      width={400}
      height={400}
      alt=""
      draggable={false}
      decoding="async"
    />
  );
}

/** Photographic examples of the portrait sessions shown in this row. */
function CardArt({ art }: { art: 'harbour' | 'street' | 'studio' }) {
  const scene = { harbour: 'shore', street: 'dusk', studio: 'window' }[art];
  return (
    <img
      className={styles.art}
      src={`/experiments/swipe-deck/${scene}-photo-v1.webp`}
      width={600}
      height={1000}
      alt=""
      draggable={false}
      decoding="async"
    />
  );
}
```

### promo-sheet/promo-sheet.module.css

```css
/* Promo sheet (promo-sheet.tsx): a phone on a quiet stage. Everything inside the phone is drawn at
   its design size (a 390 × 844 screen in a 412 × 864 body) and the phone is scaled as one piece
   to fit the stage, by a ratio CSS works out from the stage's size (a size container): tan(atan2)
   of two lengths is their ratio as a plain number.

   Colours are the tokens below, light-dark(light, dark) where the two differ: 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).

   Memory. The stage is one layout boundary sized from outside; the sheet, the scrim, the page
   behind and the seal's layers move by transforms and opacities only. */

.root {
  /* The stage. */
  --stage: #f4f4f3;
  --stage-line: rgb(0 0 0 / 0.05);
  /* The listings page: its header fades from grey to white. */
  --page: #fff;
  --header-top: #e3e3e3;
  --header-bottom: #fcfcfc;
  --header-shadow: rgb(0 0 0 / 0.08);
  --ink: #222;
  --muted: #6a6a6a;
  --pill: #fff;
  --pill-shadow: rgb(0 0 0 / 0.12);
  --chip: #f1f1f1;
  /* The sheet. */
  --sheet: #f6efeb;
  --sheet-ink: #1d1b1b;
  --sheet-muted: #3f3c3a;
  --action: #1f1c1f;
  --action-pressed: #3a3639;
  --action-ink: #fff;
  --press: rgb(0 0 0 / 0.07);
  --scrim: rgb(0 0 0 / 0.45);
  /* The status bar's words on the page, and the home indicator. */
  --status-ink: #000;
  --home: #000;
  /* The same on both schemes: the phone's body, the focus ring, the demo finger. */
  --body: #050505;
  --focus: #387bff;
  --finger: rgb(120 120 128 / 0.42);
  --finger-edge: rgb(255 255 255 / 0.75);
  --badge: #3a4766;
  position: relative;
  container-type: size;
  contain: size layout;
  width: 100%;
  aspect-ratio: 6 / 5;
  min-height: min(600px, 150vw);
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  box-shadow: inset 0 0 0 1px var(--stage-line);
  font-family:
    -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', Roboto, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#f4f4f3, #141414);
    --stage-line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --page: light-dark(#fff, #121212);
    --header-top: light-dark(#e3e3e3, #2a2a2a);
    --header-bottom: light-dark(#fcfcfc, #161616);
    --header-shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5));
    --ink: light-dark(#222, #f1f1f1);
    --muted: light-dark(#6a6a6a, #a0a0a0);
    --pill: light-dark(#fff, #2c2c2c);
    --pill-shadow: light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.5));
    --chip: light-dark(#f1f1f1, #262626);
    --sheet: light-dark(#f6efeb, #22201f);
    --sheet-ink: light-dark(#1d1b1b, #f6f2ef);
    --sheet-muted: light-dark(#3f3c3a, #bdb7b2);
    --action: light-dark(#1f1c1f, #f4efea);
    --action-pressed: light-dark(#3a3639, #d9d3cd);
    --action-ink: light-dark(#fff, #1d1b1b);
    --press: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.1));
    --scrim: light-dark(rgb(0 0 0 / 0.45), rgb(0 0 0 / 0.6));
    --status-ink: light-dark(#000, #fff);
    --home: light-dark(#000, #fff);
    --badge: light-dark(#3a4766, #5b6b95);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The phone, scaled down (never up) to fit the stage with a margin. */
.phone {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 412px;
  height: 864px;
  margin: -432px 0 0 -206px;
  box-sizing: border-box;
  padding: 10px 11px;
  border-radius: 66px;
  background: var(--body);
  box-shadow:
    0 0 0 1px rgb(255 255 255 / 0.04),
    0 24px 60px -20px rgb(0 0 0 / 0.3);
  scale: min(1, tan(atan2(100cqh - 40px, 864px)), tan(atan2(100cqw - 32px, 412px)));
}
.screen {
  position: relative;
  width: 390px;
  height: 844px;
  overflow: hidden;
  overflow: clip;
  border-radius: 55px;
  background: #000;
  isolation: isolate;
  user-select: none;
  -webkit-user-select: none;
}

/* The page behind: full screen while the sheet is away, a receding card while it is up. */
.page {
  position: absolute;
  inset: 0;
  overflow: hidden;
  overflow: clip;
  border-radius: 19px;
  background: var(--page);
  color: var(--ink);
  transform-origin: 50% 0;
  transition: transform 340ms cubic-bezier(0.32, 0.72, 0, 1);
}
.root[data-open='true'] .page {
  transform: translateY(58px) scale(0.955);
  transition-duration: 500ms;
  transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}

.header {
  position: relative;
  height: 199px;
  background: linear-gradient(var(--header-top), var(--header-bottom));
  box-shadow: 0 1px 6px var(--header-shadow);
}
.search {
  position: absolute;
  top: 59px;
  left: 24px;
  right: 24px;
  height: 56px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  border-radius: 28px;
  background: var(--pill);
  box-shadow: 0 3px 12px var(--pill-shadow);
  font-size: 13.5px;
  font-weight: 600;
  letter-spacing: -0.01em;
}
.searchIcon {
  width: 13px;
  height: 13px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}
.tabs {
  position: absolute;
  top: 131px;
  left: 30px;
  right: 30px;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
}
.tab {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  height: 68px;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: var(--muted);
  font: inherit;
  font-size: 11.5px;
  font-weight: 500;
  cursor: pointer;
}
.tab[aria-pressed='true'] {
  color: var(--ink);
  font-weight: 650;
}
.tab[aria-pressed='true']::after {
  content: '';
  position: absolute;
  bottom: 3px;
  left: 50%;
  width: 47px;
  height: 2px;
  margin-left: -23.5px;
  border-radius: 1px;
  background: var(--ink);
}
.tabArt {
  position: relative;
  width: 38px;
  height: 38px;
}
.tabSvg {
  width: 38px;
  height: 38px;
}
.badge {
  position: absolute;
  top: -3px;
  left: 26px;
  padding: 2px 5px 1.5px;
  border-radius: 7px;
  background: var(--badge);
  color: #fff;
  font-size: 7.5px;
  font-weight: 700;
  letter-spacing: 0.04em;
  line-height: 1;
}
.tabLabel {
  margin-top: 6px;
  line-height: 15px;
}

.body {
  padding: 37px 0 0 24px;
}
.sectionHead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-right: 24px;
}
.sectionTitle {
  margin: 0;
  font-size: 17.5px;
  font-weight: 650;
  line-height: 24px;
  letter-spacing: -0.02em;
}
.chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  height: 26px;
  margin: 0;
  padding: 0 10px 0 6px;
  border: 0;
  border-radius: 13px;
  background: var(--chip);
  color: var(--ink);
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
  transition:
    transform 120ms ease,
    background-color 120ms ease;
}
.chipSeal {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: radial-gradient(circle at 38% 34%, #ffe27a, #eebb32 55%, #c98e14);
  box-shadow: inset 0 0 0 2px rgb(255 236 160 / 0.5);
}
.chip:active,
.chip[data-pressed] {
  transform: scale(0.94);
}
.row {
  display: flex;
  gap: 13px;
  margin: 13px 0 0;
  padding: 0;
  list-style: none;
}
.thumbItem {
  flex: none;
  display: flex;
  flex-direction: column;
  width: 105px;
}
.thumb {
  display: block;
  width: 105px;
  height: 100px;
  overflow: hidden;
  border-radius: 15px;
}
.art {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.thumbTitle {
  margin-top: 9px;
  font-size: 13px;
  font-weight: 600;
  line-height: 16px;
}
.thumbNote {
  margin-top: 2px;
  color: var(--muted);
  font-size: 11.5px;
  line-height: 15px;
}
.body > .sectionTitle {
  margin-top: 26px;
}
.cards {
  display: flex;
  gap: 14px;
  margin: 15px 0 0;
  padding: 0;
  list-style: none;
}
.card {
  position: relative;
  flex: none;
  display: flex;
  flex-direction: column;
  width: 164px;
}
.cardArt {
  display: block;
  width: 164px;
  height: 156px;
  overflow: hidden;
  border-radius: 15px;
}
.tag {
  position: absolute;
  top: 10px;
  left: 10px;
  padding: 4px 9px;
  border-radius: 10px;
  background: #fff;
  color: #222;
  font-size: 11px;
  font-weight: 650;
}
.heart {
  position: absolute;
  top: 2px;
  right: 2px;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
.heart svg {
  width: 22px;
  height: 22px;
  fill: rgb(0 0 0 / 0.5);
  stroke: #fff;
  stroke-width: 1.8;
  transition:
    fill 150ms ease,
    transform 200ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
.heart[aria-pressed='true'] svg {
  fill: #ff385c;
}
.heart:active svg {
  transform: scale(0.85);
}
.cardTitle {
  margin-top: 9px;
  font-size: 13px;
  font-weight: 600;
  line-height: 16px;
}
.cardNote {
  margin-top: 3px;
  color: var(--muted);
  font-size: 11.5px;
  line-height: 15px;
  white-space: pre;
}

/* The scrim over the page; a tap on it closes the sheet. */
.scrim {
  position: absolute;
  inset: 0;
  background: var(--scrim);
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 340ms ease,
    visibility 0s linear 340ms;
}
.root[data-open='true'] .scrim {
  opacity: 1;
  visibility: visible;
  transition:
    opacity 500ms cubic-bezier(0.22, 1, 0.36, 1),
    visibility 0s;
}

/* The sheet. */
.sheet {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  box-sizing: border-box;
  padding: 0 23px 46px;
  border-radius: 16px 16px 0 0;
  background: var(--sheet);
  color: var(--sheet-ink);
  text-align: center;
  touch-action: none;
  cursor: grab;
  transform: translateY(calc(100% + 24px));
  visibility: hidden;
  transition:
    transform 340ms cubic-bezier(0.32, 0, 0.67, 0),
    visibility 0s linear 340ms;
}
.root[data-open='true'] .sheet {
  transform: none;
  visibility: visible;
  transition:
    transform 500ms cubic-bezier(0.22, 1, 0.36, 1),
    visibility 0s;
}
.sheet:focus {
  outline: none;
}
/* Following a finger: no transition, the drag writes the styles itself. */
.root[data-dragging] .sheet,
.root[data-dragging] .scrim,
.root[data-dragging] .page {
  transition: none;
  cursor: grabbing;
}
/* A flick closes on, at the finger's pace. */
.root[data-release='fling'] .sheet {
  transition:
    transform 260ms cubic-bezier(0.2, 0.7, 0.3, 1),
    visibility 0s linear 260ms;
}
.root[data-release='fling'] .page,
.root[data-release='fling'] .scrim {
  transition-duration: 260ms;
}

.close {
  position: absolute;
  top: 6px;
  right: 10px;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--sheet-ink);
  cursor: pointer;
  transition:
    background-color 120ms ease,
    transform 120ms ease;
}
.close svg {
  width: 12px;
  height: 12px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
}
.close:active,
.close[data-pressed] {
  background: var(--press);
  transform: scale(0.92);
}

.sealBox {
  position: relative;
  flex: none;
  width: 100px;
  height: 100px;
  margin-top: 65px;
}
.title {
  margin: 30.5px 0 0;
  max-width: 300px;
  font-size: 25.5px;
  font-weight: 650;
  line-height: 30px;
  letter-spacing: 0.005em;
}
.description {
  margin: 17px 0 0;
  max-width: 310px;
  color: var(--sheet-muted);
  font-size: 16px;
  line-height: 22px;
  letter-spacing: 0.01em;
}
.action {
  align-self: stretch;
  height: 48px;
  margin: 40px 0 0;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: var(--action);
  color: var(--action-ink);
  font: inherit;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition:
    transform 140ms ease,
    background-color 140ms ease;
}
.action:active,
.action[data-pressed] {
  background: var(--action-pressed);
  transform: scale(0.97);
}

.chip:focus,
.tab:focus,
.heart:focus,
.close:focus,
.action:focus {
  outline: none;
}
.chip:focus-visible,
.tab:focus-visible,
.heart:focus-visible,
.close:focus-visible,
.action:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The seal: a sphere, a disc and the wax, one over the other, each on its own clock from the
   moment the sheet opens (the timings are fractions of 1.5 s). Each layer's own style is its
   final state, so without animations (reduced motion) the seal is simply there. */
.seal {
  position: absolute;
  inset: 0;
}
.sealShadow {
  position: absolute;
  inset: 12px 10px 8px 12px;
  border-radius: 50%;
  box-shadow: 2px 5px 9px rgb(100 70 20 / 0.32);
  animation: promo-sheet-shadow 1500ms linear both;
}
.sphere {
  position: absolute;
  inset: 5px;
  border-radius: 50%;
  background: radial-gradient(
    circle at 36% 30%,
    #fffbd6 0%,
    #ffe65e 20%,
    #f8cf2c 58%,
    #dfa816 100%
  );
  box-shadow: 8px 10px 10px -3px rgb(60 55 45 / 0.42);
  opacity: 0;
  animation: promo-sheet-sphere 1500ms linear both;
}
.sealArt {
  position: absolute;
  inset: 0;
  width: 100px;
  height: 100px;
  overflow: visible;
  object-fit: contain;
  transform-origin: center;
  animation: promo-sheet-wax 1500ms both;
}
/* Held still (sealAt, defaultState): every layer paused at the same moment of its forming. */
.seal[data-held] :is(.sealShadow, .sphere, .sealArt) {
  animation-delay: var(--seal-at);
  animation-play-state: paused;
}

@keyframes promo-sheet-sphere {
  0%,
  15.9% {
    opacity: 0;
    transform: translateY(6px) scale(0.12);
  }
  16% {
    opacity: 1;
    transform: translateY(6px) scale(0.16);
  }
  22% {
    transform: translateY(5px) scale(0.28);
  }
  24.7% {
    transform: translateY(3px) scale(0.55);
  }
  26.7% {
    transform: translateY(2px) scale(0.62);
  }
  28.7% {
    transform: translateY(1px) scale(0.7);
  }
  30.5% {
    opacity: 1;
    transform: translateY(0.5px) scale(0.78);
  }
  32.5%,
  100% {
    opacity: 0;
    transform: translateY(0) scale(0.84);
  }
}
@keyframes promo-sheet-wax {
  0%,
  37% {
    opacity: 0;
    transform: rotate(-12deg) scale(0.88);
    animation-timing-function: cubic-bezier(0.2, 0.8, 0.3, 1);
  }
  43% {
    opacity: 1;
  }
  55% {
    transform: rotate(0deg) scale(1.02);
    animation-timing-function: ease-in-out;
  }
  63%,
  100% {
    opacity: 1;
    transform: rotate(0deg) scale(1);
  }
}
@keyframes promo-sheet-shadow {
  0%,
  33% {
    opacity: 0;
  }
  45%,
  100% {
    opacity: 1;
  }
}
/* The status bar, the island and the home indicator, over everything. */
.status {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  height: 54px;
  color: var(--status-ink);
  pointer-events: none;
  transition: color 340ms ease;
}
.root[data-open='true'] .status {
  color: #fff;
}
.time {
  position: absolute;
  top: 17px;
  left: 0;
  width: 141px;
  font-size: 17px;
  font-weight: 600;
  line-height: 22px;
  letter-spacing: -0.02em;
  text-align: center;
}
.island {
  position: absolute;
  top: 11px;
  left: 50%;
  width: 124px;
  height: 36px;
  margin-left: -62px;
  border-radius: 18px;
  background: #000;
}
.icons {
  position: absolute;
  top: 22px;
  right: 35px;
  display: flex;
  align-items: center;
  gap: 6px;
}
.signal,
.wifi {
  height: 12px;
  fill: currentColor;
}
.signal {
  width: 18px;
}
.wifi {
  width: 17px;
}
.battery {
  position: relative;
  box-sizing: border-box;
  width: 26px;
  height: 13px;
  padding: 2px;
  border: 1px solid currentColor;
  border-radius: 4px;
  opacity: 0.95;
}
.battery span {
  display: block;
  height: 100%;
  border-radius: 2px;
  background: currentColor;
}
.battery::after {
  content: '';
  position: absolute;
  top: 3.5px;
  right: -3.5px;
  width: 1.5px;
  height: 4px;
  border-radius: 0 1px 1px 0;
  background: currentColor;
  opacity: 0.5;
}
.homeBar {
  position: absolute;
  bottom: 8px;
  left: 50%;
  width: 134px;
  height: 5px;
  margin-left: -67px;
  border-radius: 3px;
  background: var(--home);
  pointer-events: none;
}

/* The demo finger: a soft touch mark, as a screen recording shows one. */
.fingerLayer {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.finger {
  position: absolute;
  top: -22px;
  left: -22px;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--finger);
  box-shadow: inset 0 0 0 1.5px var(--finger-edge);
  opacity: 0;
  transition: opacity 200ms ease;
}
.finger[data-shown] {
  opacity: 1;
}

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

@media (prefers-reduced-motion: reduce) {
  .page,
  .scrim,
  .sheet,
  .status,
  .chip,
  .close,
  .action,
  .heart svg {
    transition-duration: 0s !important;
    transition-delay: 0s !important;
  }
  .sphere,
  .sealArt,
  .sealShadow {
    animation: none;
  }
}
```

### promo-sheet/use-autoplay.ts

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

'use client';

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

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
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 };
}
```