# Vortex dismiss

> A close button that swallows what it closes: the card twists into a green vortex on the ×, collapses to a point and bursts.

Press the × and a vortex opens on it. A lime shock ring rolls out, a teal whirl fills the stage, and the card is torn open at the eye, twisted, folded edge on and drawn in until it is gone. The eye swells white, collapses to a point, flashes lime, and a pinwheel of light spreads out and fades. It wraps any content, and the sample card comes back for another go.

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

Category: Feedback.

## Anatomy

1. **Stage**: The pale field the vortex plays on: 4:3, never under 440 px tall, clipped to an 18 px radius. Its two canvases, under and over the content, show only while the vortex plays.
2. **Card**: The content, centred. Without children it is the sample promo card, with a hairline edge and a soft lift.
3. **Art**: One SVG of gradients only (no filters, no raster), so it stays sharp and cheap while the card twists.
4. **Title**: The card’s heading, in Inter 600.
5. **Copy**: The supporting copy under it, a step quieter than the title.
6. **Call to action**: A full-width pill button on a hairline edge.
7. **Close ×**: A white disc `closeInset` px in from the content’s top right, named by `closeLabel`. The vortex opens on its middle; it turns lime as the eye swells, then collapses.

## When to use

- Closing a promo, an offer or a celebration card where the leaving itself is part of the moment: a launch page, an onboarding reward, a playful product.
- A dismissal the reader should feel happened, once, on a card that will not come straight back.
- Any markup that should leave with a flourish: the content is twisted live through an SVG filter, never copied, so a real card with its own buttons can be swallowed.
- A showcase or landing stage with room around the content, where a 3.35 s effect can fill the stage without hiding anything else.

## When not to use

- Closing dialogs, toasts or banners many times a day. Use a plain close button with a short fade.
- Deleting or archiving something the reader may want back. Use a quiet removal with an Undo toast.
- Rows in a list or cards in a dense grid: the whirl fills the whole stage. Use a collapse or a slide out.
- Dense or busy screens where a burst of light over everything would pull focus from the work. Use a fade.
- Pages that must stay light on old phones or without WebGL budget to spare. Use a CSS exit animation.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 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 { VortexDismiss } from './vortex-dismiss';

export function Promo({ onClose }: { onClose: () => void }) {
  return (
    <VortexDismiss autoplay={false} restoreAfter={null} onDismissed={onClose}>
      <aside className="promo">…</aside>
    </VortexDismiss>
  );
}
```

## States

- **At rest**: The content with its ×, before any press. `autoplay={false}` keeps the demo pointer away.
- **Shock ring · 0.3 s**: A lime ring rolls out from the ×, the core lights and the content is torn open at the eye.
- **Twist · 1.0 s**: The teal whirl fills the stage; the card swirls, turns, pales and folds edge on into the eye.
- **Bloom · 1.65 s**: The card is gone; the eye swells white and the × glints lime before it collapses.
- **Burst · 2.4 s**: After the flash, a pinwheel of light spreads out and fades by 3.35 s.
- **Gone**: `open={false}`, or `restoreAfter={null}` after a press: an empty stage, the content inert and hidden.
- **No ×**: `closeButton={false}`: the content starts the vortex itself, through the `dismiss` its children function gets.
- **Light**: Every colour is a light-dark() token: `colorScheme="light"`, or a light page.
- **Dark**: `colorScheme="dark"`, or a dark page: a near-black stage and card; the × stays a pale disc.
- **Dark · 1.0 s**: The vortex keeps its colours on a dark stage; each layer is laid over the last, never added.

## How it works

- One clock drives everything: each frame works out every part from the seconds since the press, so nothing drifts and a tab that was hidden picks up where it should be. The whole effect lasts 3.35 s.
- The content is never copied, so any markup can be swallowed. It twists through an SVG filter: three feDisplacementMap passes read one picture of a swirl field, drawn once per press, and each pass turns it a little further, like steps of a flow. A radial mask eats it from the eye outwards.
- Over the swirl, a CSS transform about the eye turns it anticlockwise, folds it edge on about the line from the eye through its middle (rotate3d under a perspective) and shrinks it, while it pales and blurs, so it leaves as a bright sliver curling into the eye.
- The light is one fragment shader on two canvases: under the content, the teal whirl with its grey shade and dark throat; over it, the shock ring, the lime core turning at 4 rad/s, the torn edge, the sparks, a lens flare (a hairline across the stage, a fan of rays and long beams), the white bloom, the flash and the burst. Each layer is laid over the last with its own alpha, never added, so the colours read on a light page as on a dark one.
- The beats: shock ring 0–0.4 s, core from 0.1 s, the card folds away from 0.5 s and is gone by 1.4 s, the bloom swells from 1.3 s and collapses at 1.8 s, the flash at 1.86 s, then the burst opens to its full size and fades by 3.35 s.
- The effect scales with the stage: it was drawn for an eye 541 px from the stage’s farthest corner and grows or shrinks from there. Without WebGL, a CSS whirl of conic and radial gradients stands in.
- Uncontrolled, the content comes back restoreAfter ms after the burst, with a 160 ms fade out of a blur; controlled, it leaves and returns as `open` says. While on screen and untouched, a demo pointer comes in, presses the ×, and lets it play.

## API reference

- **children** (`ReactNode | ((dismiss: (from?: HTMLElement | { x: number; y: number }) => void) => ReactNode)`): What the vortex swallows; without it, a sample promo card. A function gets dismiss, to start the vortex from an element of its own or a point in the content’s px.
- **open** (`boolean`): Controlled: the content shows while true; turning false plays the vortex.
- **onDismiss** (`() => void`): Called on the press, as the vortex starts.
- **onDismissed** (`() => void`): Called once the burst has faded.
- **restoreAfter** (`number | null`, default `800`): Uncontrolled: ms after the burst before the content comes back; null keeps it gone.
- **closeButton** (`boolean`, default `true`): Draws the round × over the content’s top right corner.
- **closeLabel** (`string`, default `'Dismiss'`): The close button’s accessible name.
- **closeInset** (`number`, default `14.5`): The ×’s distance from the content’s top and right edges, in px.
- **palette** (`{ glow?: string; shade?: string; spark?: string; burst?: string }`): The effect’s colours, as hex: the whirl, its shade, the lime of the core and sparks, the burst’s rim.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer presses the × now and then (uncontrolled, with restoreAfter set).
- **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.
- **frozenAt** (`number`): Holds the vortex still this many seconds after a press (0 to 3.35), as if paused there: a still for docs and previews. Nothing finishes and the demo stays off.
- **className** (`string`): Extra class for the root.

## Accessibility

- The × is a real button named by closeLabel (“Dismiss”), with a visible focus ring; Escape inside the content dismisses too.
- A polite live region says “Dismissed”. While the content is leaving and once it is gone, it is inert and hidden from screen readers, and focus moves to the stage instead of being lost on the page.
- The canvases, the art and the demo pointer are decorative (aria-hidden). Any real input hands control to the reader and stops the demo.
- Under reduced motion there is no vortex: the content fades out in 0.22 s, and the demo never plays.

## Source

### vortex-dismiss/vortex-dismiss.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from 'react';
import { DemoPointer, runDemo } from './demo-pointer';
import { PromoCard } from './promo-card';
import { useAutoplay } from './use-autoplay';
import {
  createField,
  DEFAULT_PALETTE,
  parseHex,
  swirlMap,
  type Field,
  type Palette,
} from './vortex-field';
import styles from './vortex-dismiss.module.css';

/*
 * Vortex dismiss: a close button that doesn't just hide what it closes, it swallows it. Press the
 * × and a vortex opens on it: a lime shock ring, a teal whirl that fills the stage, the content
 * torn open at the eye, twisted clockwise and drawn in until it is gone. The eye swells white,
 * collapses to a point, flashes lime, and a pinwheel burst spreads out and fades. 3.35 s in all.
 *
 * - The content is any markup: it is never copied. It twists through an SVG filter, three
 *   feDisplacementMap passes reading one picture of a swirl field (each pass turns it a little
 *   further, like steps of a flow), whose strength grows frame by frame; a CSS transform turns
 *   and shrinks it around the eye, and a radial mask eats it from the eye outwards.
 * - The light is a fragment shader on two canvases, one under the content and one over it
 *   (vortex-field.ts). Without WebGL, a CSS whirl of gradients stands in.
 * - One clock drives it all: each frame works out every part from the seconds since the press,
 *   so nothing drifts, and a stopped tab picks up where it should be.
 * - Uncontrolled, the content comes back restoreAfter ms after the burst, with a short fade from
 *   a blur; controlled (open), it leaves and comes back as `open` says.
 * - While on screen and untouched, a demo pointer comes in, presses the × and lets it play, again
 *   and again. Under reduced motion there is no vortex: the content fades out.
 * - frozenAt holds one moment of the vortex still (drawn once, again on a resize), for docs.
 * - 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.
 */

/** How long the whole effect lasts, from the press to the burst's last light, in s. */
const DURATION = 3.35;
/** The effect was drawn for an eye this far from the stage's farthest corner, in px. */
const DESIGN_REACH = 541;

const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
const smooth = (a: number, b: number, x: number) => {
  const t = clamp01((x - a) / (b - a));
  return t * t * (3 - 2 * t);
};

/** Where the swallowed content is, `t` s after the press. */
function contentAt(t: number) {
  return {
    /** Strength of each swirl pass, in px of offset: a ripple at the press, then the twist. */
    swirl: 50 * smooth(0.45, 0.9, t) + 60 * smooth(0.9, 1.3, t),
    /** How far the eye has eaten into it, in px from the eye. */
    edge: 22 + 14 * smooth(0, 0.3, t) + 14 * smooth(0.3, 0.95, t) + 60 * smooth(0.95, 1.4, t),
    /** Its turn about the eye, anticlockwise, in degrees. */
    turn: -(12 * smooth(0.5, 0.8, t) + 14 * smooth(0.8, 1.2, t)),
    /** How far it folds away, edge on, about the line from the eye through its middle. */
    fold: 6 * smooth(0.55, 0.75, t) + 50 * smooth(0.75, 1.0, t) + 26 * smooth(1.0, 1.12, t),
    scale:
      1 -
      0.03 * smooth(0.5, 0.75, t) -
      0.07 * smooth(0.75, 0.95, t) -
      0.15 * smooth(0.95, 1.2, t) -
      0.35 * smooth(1.2, 1.42, t),
    opacity: 1 - smooth(1.25, 1.42, t),
    blur: 0.8 * smooth(0.8, 1.2, t),
    /** It pales as it goes, as if lit by the eye. */
    glare: smooth(0.6, 1.1, t),
    tear: smooth(0, 0.04, t) * (1 - smooth(0.6, 0.9, t)),
  };
}

type Phase = 'shown' | 'leaving' | 'gone';

type Origin = HTMLElement | { x: number; y: number };

export function VortexDismiss({
  children,
  open,
  onDismiss,
  onDismissed,
  restoreAfter = 800,
  closeButton = true,
  closeLabel = 'Dismiss',
  closeInset = 14.5,
  palette,
  autoplay = true,
  colorScheme,
  frozenAt,
  className = '',
}: {
  /**
   * What the vortex swallows; without it, a sample promo card. A function gets `dismiss`, to
   * start the vortex from an element of its own (or a point in the content's px).
   */
  children?: ReactNode | ((dismiss: (from?: Origin) => void) => ReactNode);
  /** Controlled: the content shows while true; turning false plays the vortex. */
  open?: boolean;
  /** Called on the press, as the vortex starts. */
  onDismiss?: () => void;
  /** Called once the burst has faded. */
  onDismissed?: () => void;
  /** Uncontrolled: ms after the burst before the content comes back; null keeps it gone. */
  restoreAfter?: number | null;
  /** Draws the round × over the content's top right corner. */
  closeButton?: boolean;
  /** The close button's accessible name. */
  closeLabel?: string;
  /** The × button's distance from the content's top and right edges, in px. */
  closeInset?: number;
  /** The effect's colours, as hex: the whirl, its shade, the lime of the core and sparks, the burst. */
  palette?: Partial<Record<keyof Palette, string>>;
  /** While on screen and untouched, a demo pointer presses the × now and then. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /**
   * Holds the vortex still this many s after a press (0 to 3.35), as if paused there: a still for
   * docs and previews. Nothing finishes, and the demo stays off.
   */
  frozenAt?: number;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(5000);
  const controlled = open !== undefined;
  const [phase, setPhase] = useState<Phase>(open === false ? 'gone' : 'shown');
  const [returning, setReturning] = useState(false);
  const [message, setMessage] = useState('');
  const [noGl, setNoGl] = useState(false);
  const filterId = `vortex${useId().replace(/[^a-zA-Z0-9]/g, '')}`;

  const content = useRef<HTMLDivElement>(null);
  const close = useRef<HTMLButtonElement>(null);
  const back = useRef<HTMLCanvasElement>(null);
  const front = useRef<HTMLCanvasElement>(null);
  const fallback = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const feImage = useRef<SVGFEImageElement>(null);
  const filter = useRef<SVGFilterElement>(null);
  const passes = useRef<(SVGFEDisplacementMapElement | null)[]>([]);
  const fields = useRef<{ back: Field | null; front: Field | null } | null>(null);
  /** Where the vortex starts: an element's middle, or a point in the content's px. */
  const origin = useRef<Origin | null>(null);
  const restoreTimer = useRef(0);

  const colors = useMemo<Palette>(
    () => ({
      glow: parseHex(palette?.glow, DEFAULT_PALETTE.glow),
      shade: parseHex(palette?.shade, DEFAULT_PALETTE.shade),
      spark: parseHex(palette?.spark, DEFAULT_PALETTE.spark),
      burst: parseHex(palette?.burst, DEFAULT_PALETTE.burst),
    }),
    [palette?.glow, palette?.shade, palette?.spark, palette?.burst],
  );

  /** Starts the vortex, from the × unless told otherwise. */
  const dismiss = useCallback(
    (from?: Origin) => {
      if (phase !== 'shown') return;
      origin.current = from ?? close.current;
      setReturning(false);
      setPhase('leaving');
      setMessage('Dismissed');
      onDismiss?.();
    },
    [phase, onDismiss],
  );

  // Controlled: follow `open`.
  useEffect(() => {
    if (!controlled) return;
    if (open === false && phase === 'shown') {
      origin.current = close.current;
      setPhase('leaving');
    } else if (open && phase === 'gone') {
      setReturning(true);
      setPhase('shown');
    }
  }, [controlled, open, phase]);

  // A still starts leaving once mounted, not from the first render: the WebGL contexts are made
  // by the vortex effect, and a remount (Strict Mode's, in development) would find them lost.
  useEffect(() => {
    if (frozenAt !== undefined) setPhase('leaving');
  }, [frozenAt]);

  const finish = useEffectEvent(() => {
    setPhase('gone');
    onDismissed?.();
    // Focus was on the × (or inside the content): keep it in the stage, not lost on the body.
    const active = document.activeElement;
    if (active === document.body || (active && root.current?.contains(active)))
      root.current?.focus({ preventScroll: true });
    if (!controlled && restoreAfter !== null) {
      window.clearTimeout(restoreTimer.current);
      restoreTimer.current = window.setTimeout(() => {
        setReturning(true);
        setPhase('shown');
        setMessage('');
      }, restoreAfter);
    }
  });
  useEffect(() => () => window.clearTimeout(restoreTimer.current), []);

  // The vortex itself: one clock, every part worked out from it each frame.
  useEffect(() => {
    if (phase !== 'leaving') return;
    const stage = root.current;
    const box = content.current;
    if (!stage || !box) return;
    // A still: one moment of the vortex, drawn again only when the stage changes size.
    const still = frozenAt === undefined ? null : Math.min(DURATION - 0.001, Math.max(0, frozenAt));
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (reduced && still === null) {
      box.dataset.fading = 'true';
      const timer = window.setTimeout(finish, 240);
      return () => {
        window.clearTimeout(timer);
        delete box.dataset.fading;
      };
    }

    // Where everything is, in the stage's own px (it may sit in a scaled container).
    const stageRect = stage.getBoundingClientRect();
    const zoom = stageRect.width / stage.offsetWidth || 1;
    const boxRect = box.getBoundingClientRect();
    const boxAt = {
      x: (boxRect.left - stageRect.left) / zoom,
      y: (boxRect.top - stageRect.top) / zoom,
    };
    const width = box.offsetWidth;
    const height = box.offsetHeight;
    const from = origin.current;
    let eye: { x: number; y: number };
    if (from instanceof HTMLElement) {
      const rect = from.getBoundingClientRect();
      eye = {
        x: (rect.left + rect.width / 2 - stageRect.left) / zoom,
        y: (rect.top + rect.height / 2 - stageRect.top) / zoom,
      };
    } else if (from) eye = { x: boxAt.x + from.x, y: boxAt.y + from.y };
    else eye = { x: boxAt.x + width, y: boxAt.y };
    const local = { x: eye.x - boxAt.x, y: eye.y - boxAt.y };
    // The fold's axis: from the eye through the content's middle.
    const axis = { x: width / 2 - local.x, y: height / 2 - local.y };
    const axisLength = Math.hypot(axis.x, axis.y) || 1;
    const fold = `${(axis.x / axisLength).toFixed(3)}, ${(axis.y / axisLength).toFixed(3)}, 0`;
    const stageW = stage.offsetWidth;
    const stageH = stage.offsetHeight;
    const reach = Math.max(
      Math.hypot(eye.x, eye.y),
      Math.hypot(stageW - eye.x, eye.y),
      Math.hypot(eye.x, stageH - eye.y),
      Math.hypot(stageW - eye.x, stageH - eye.y),
    );
    const k = Math.min(1.8, Math.max(0.55, reach / DESIGN_REACH));
    const dark = isDark(stage);

    // The swirl filter: its region reaches well past the content, where the twist carries it.
    const region = {
      x: -Math.max(width, height) * 0.7,
      y: -Math.max(width, height) * 0.7,
      w: width + Math.max(width, height) * 1.4,
      h: height + Math.max(width, height) * 1.4,
    };
    const svgFilter = filter.current;
    const image = feImage.current;
    let filtered = false;
    if (svgFilter && image) {
      for (const [name, value] of Object.entries(region))
        svgFilter.setAttribute(name === 'w' ? 'width' : name === 'h' ? 'height' : name, `${value}`);
      const map = swirlMap(region.w, region.h, local.x - region.x, local.y - region.y, 80, 120);
      if (map) {
        image.setAttribute('href', map);
        for (const [name, value] of Object.entries(region))
          image.setAttribute(name === 'w' ? 'width' : name === 'h' ? 'height' : name, `${value}`);
        filtered = true;
      }
    }

    if (!fields.current && !noGl) {
      const b = back.current ? createField(back.current, false) : null;
      const f = front.current ? createField(front.current, true) : null;
      if (b && f) fields.current = { back: b, front: f };
      else {
        b?.dispose();
        f?.dispose();
        setNoGl(true);
      }
    }
    const light = fields.current;
    stage.dataset.vortex = light ? 'gl' : 'css';
    stage.style.setProperty('--eye-x', `${eye.x}px`);
    stage.style.setProperty('--eye-y', `${eye.y}px`);
    stage.style.setProperty('--eye-k', `${k}`);
    box.style.transformOrigin = `${local.x}px ${local.y}px`;
    box.style.willChange = 'transform, filter, opacity';
    const button = close.current;

    const start = performance.now();
    let frame = 0;
    const tick = () => {
      const t = still ?? (performance.now() - start) / 1000;
      if (t >= DURATION) {
        light?.back?.clear();
        light?.front?.clear();
        finish();
        return;
      }
      const at = contentAt(t);
      const passScale = `${at.swirl.toFixed(1)}`;
      for (const pass of passes.current) pass?.setAttribute('scale', passScale);
      const filters = [
        filtered && at.swirl > 0.5 ? `url(#${filterId})` : '',
        at.blur > 0.05 ? `blur(${at.blur.toFixed(2)}px)` : '',
        at.glare > 0.01
          ? // Less contrast, then just enough light that white stays white: the ink pales.
            `contrast(${(1 - 0.45 * at.glare).toFixed(3)}) brightness(${(1 / (1 - 0.225 * at.glare)).toFixed(3)})`
          : '',
      ]
        .filter(Boolean)
        .join(' ');
      box.style.filter = filters || 'none';
      box.style.transform = `perspective(900px) rotate3d(${fold}, ${at.fold.toFixed(2)}deg) rotate(${at.turn.toFixed(2)}deg) scale(${at.scale.toFixed(4)})`;
      box.style.opacity = `${at.opacity}`;
      const eaten = at.edge / at.scale;
      const mask = `radial-gradient(circle at ${local.x}px ${local.y}px, transparent ${eaten.toFixed(1)}px, #000 ${(eaten + 1.5).toFixed(1)}px)`;
      box.style.maskImage = mask;
      box.style.setProperty('-webkit-mask-image', mask);
      if (button) {
        button.style.transform = `scale(${1 - 0.65 * smooth(1.74, 1.87, t)})`;
        button.style.opacity = `${1 - smooth(1.8, 1.9, t)}`;
        button.style.setProperty('--glint', `${smooth(1.5, 1.75, t)}`);
      }
      if (light) {
        const shared = {
          t,
          x: eye.x,
          y: eye.y,
          k,
          edge: at.edge / k,
          tear: at.tear,
          dark,
          palette: colors,
        };
        light.back?.draw(shared);
        light.front?.draw(shared);
      } else fallback.current?.style.setProperty('--t', `${t}`);
      if (still === null) frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    const resized =
      still === null
        ? null
        : new ResizeObserver(() => {
            cancelAnimationFrame(frame);
            frame = requestAnimationFrame(tick);
          });
    resized?.observe(stage);
    return () => {
      cancelAnimationFrame(frame);
      resized?.disconnect();
      delete stage.dataset.vortex;
      for (const name of [
        'filter',
        'transform',
        'opacity',
        'mask-image',
        '-webkit-mask-image',
        'transform-origin',
        'will-change',
      ])
        box.style.removeProperty(name);
      if (button) {
        button.style.removeProperty('transform');
        button.style.removeProperty('opacity');
        button.style.removeProperty('--glint');
      }
    };
  }, [phase, root, filterId, colors, noGl, frozenAt]);

  // The WebGL contexts go with the component.
  useEffect(
    () => () => {
      fields.current?.back?.dispose();
      fields.current?.front?.dispose();
      fields.current = null;
    },
    [],
  );

  // The demo: the pointer comes in, rests on the ×, presses it, and drifts off while the vortex
  // plays; the content comes back and it starts over.
  const demoPress = useEffectEvent(() => dismiss());
  const demo = autoplay && playing && !controlled && restoreAfter !== null && phase === 'shown';
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    const button = close.current;
    if (!demo || !stage || !arrow || !button) return;
    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      // Out of sight until it sets off, from the lower left of the stage.
      arrow.style.opacity = '0';
      await wait(returning ? 1500 : 900);
      const stageRect = stage.getBoundingClientRect();
      const zoom = stageRect.width / stage.offsetWidth || 1;
      const rect = button.getBoundingClientRect();
      const target = {
        x: (rect.left + rect.width * 0.55 - stageRect.left) / zoom,
        y: (rect.top + rect.height * 0.55 - stageRect.top) / zoom,
      };
      await glide({ x: stage.offsetWidth * 0.12, y: stage.offsetHeight * 0.62 }, 0);
      arrow.style.opacity = '1';
      await glide({ x: target.x - 120, y: target.y + 40 }, 900);
      await glide(target, 650);
      glyph('hand');
      await wait(520);
      arrow.dataset.pressed = 'true';
      await wait(90);
      delete arrow.dataset.pressed;
      demoPress();
      await wait(380);
      glyph('arrow');
      await glide({ x: target.x + 90, y: target.y + 10 }, 700);
      arrow.style.opacity = '0';
    });
    return () => {
      stop();
      delete arrow.dataset.pressed;
    };
  }, [demo, root, returning]);

  const onKeyDown = (event: ReactKeyboardEvent) => {
    if (event.key === 'Escape' && phase === 'shown' && closeButton) {
      event.stopPropagation();
      dismiss();
    }
  };

  const shown = phase !== 'gone';
  const body = typeof children === 'function' ? children(dismiss) : (children ?? <PromoCard />);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-phase={phase}
      data-color-scheme={colorScheme}
      tabIndex={-1}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
    >
      <canvas ref={back} className={styles.back} data-part="whirl" aria-hidden="true" />
      {noGl && (
        <div
          ref={fallback}
          className={styles.fallback}
          data-part="whirl-fallback"
          aria-hidden="true"
        />
      )}
      <div
        className={styles.frame}
        data-part="frame"
        data-returning={returning || undefined}
        aria-hidden={!shown || undefined}
        inert={!shown || phase === 'leaving'}
        onKeyDown={onKeyDown}
      >
        <div ref={content} className={styles.content} data-part="content">
          {body}
        </div>
        {closeButton && (
          <button
            ref={close}
            type="button"
            className={styles.close}
            data-part="close"
            aria-label={closeLabel}
            style={{ '--inset': `${closeInset}px` } as CSSProperties}
            onClick={() => dismiss()}
          >
            <svg className={styles.cross} viewBox="0 0 14 14" aria-hidden="true" focusable="false">
              <path d="M1 1L13 13M13 1L1 13" />
            </svg>
          </button>
        )}
      </div>
      <canvas ref={front} className={styles.front} data-part="light" aria-hidden="true" />
      <svg className={styles.defs} aria-hidden="true" focusable="false">
        <filter
          ref={filter}
          id={filterId}
          filterUnits="userSpaceOnUse"
          colorInterpolationFilters="sRGB"
        >
          <feImage ref={feImage} preserveAspectRatio="none" result="field" />
          {[0, 1, 2].map((index) => (
            <feDisplacementMap
              key={index}
              ref={(element) => {
                passes.current[index] = element;
              }}
              in={index ? `pass${index - 1}` : 'SourceGraphic'}
              in2="field"
              scale="0"
              xChannelSelector="R"
              yChannelSelector="G"
              result={`pass${index}`}
            />
          ))}
        </filter>
      </svg>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** Whether the stage is drawn dark, from its own background. */
function isDark(element: HTMLElement) {
  const [r = 255, g = 255, b = 255] = (
    getComputedStyle(element).backgroundColor.match(/[\d.]+/g) ?? []
  ).map(Number);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b < 110;
}
```

### vortex-dismiss/vortex-dismiss.module.css

```css
/* Vortex dismiss (vortex-dismiss.tsx): a pale stage with the content in its middle, the × over
   the content's top right corner, and the vortex's two canvases, one under the content and one
   over it. At night the stage and the card go near-black; the vortex keeps its colours.

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

   Stacking: the stage isolates its layers. Under the content, the whirl; then the content; then
   the light; then the ×, which stays crisp over the vortex until it collapses; then the demo
   pointer. */

.root {
  /* The stage. */
  --page: #f3f5f2;
  /* The card, its hairline edge and its lift. */
  --card: #fff;
  --card-edge: rgb(0 0 0 / 0.085);
  --card-shadow: rgb(0 0 0 / 0.05);
  /* Title and copy. */
  --ink: #212323;
  --ink-2: #646464;
  /* The call to action. */
  --button: #fff;
  --button-edge: rgb(0 0 0 / 0.11);
  --button-shadow: rgb(0 0 0 / 0.07);
  /* The round ×: a white disc in both schemes, over the art. */
  --close: #fff;
  --close-ink: #646464;
  --close-shadow: rgb(0 0 0 / 0.08);
  /* The lime the × takes as the eye swells. */
  --glint-ink: #b6e324;
  /* The same on both schemes: the focus ring, and the demo's pointer, which looks like the
     system's. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  isolation: isolate;
  container-type: inline-size;
  display: grid;
  place-items: center;
  /* Its width comes from outside; its height from the width, but never under 440px. */
  width: 100%;
  aspect-ratio: 4 / 3;
  min-height: 440px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--page);
  outline: none;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --page: light-dark(#f3f5f2, #111311);
    --card: light-dark(#fff, #1b1d1b);
    --card-edge: light-dark(rgb(0 0 0 / 0.085), rgb(255 255 255 / 0.08));
    --card-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.45));
    --ink: light-dark(#212323, #eef0ee);
    --ink-2: light-dark(#646464, #a2a6a2);
    --button: light-dark(#fff, #242624);
    --button-edge: light-dark(rgb(0 0 0 / 0.11), rgb(255 255 255 / 0.1));
    --button-shadow: light-dark(rgb(0 0 0 / 0.07), rgb(0 0 0 / 0.4));
    --close: light-dark(#fff, #f4f6f3);
    --close-ink: light-dark(#646464, #4a4d4a);
    --close-shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.35));
  }
}
/* 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 two canvases fill the stage; they show only while the vortex plays. */
.back,
.front {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  visibility: hidden;
}
.back {
  z-index: 0;
}
.front {
  z-index: 2;
}
.root[data-vortex='gl'] .back,
.root[data-vortex='gl'] .front {
  visibility: visible;
}

/* The content and its ×. The frame is the content's own size, centred. */
.frame {
  position: relative;
  grid-area: 1 / 1;
  max-width: calc(100% - 32px);
}
.content {
  position: relative;
  z-index: 1;
}
.frame[aria-hidden='true'] {
  visibility: hidden;
}
/* Coming back: a short fade out of a blur. */
.frame[data-returning] .content,
.frame[data-returning] .close {
  animation: return 0.16s ease-out both;
}
@keyframes return {
  from {
    opacity: 0;
    filter: blur(6px);
  }
}
/* Under reduced motion, the content only fades. */
.content[data-fading] {
  opacity: 0;
  transition: opacity 0.22s ease;
}

.close {
  --glint: 0;
  position: absolute;
  top: var(--inset);
  right: var(--inset);
  z-index: 3;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--close);
  box-shadow: 0 1px 2px var(--close-shadow);
  color: color-mix(in srgb, var(--glint-ink) calc(var(--glint) * 100%), var(--close-ink));
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.close:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.cross {
  width: 13.6px;
  height: 13.6px;
  overflow: visible;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
}

/* The sample card. */
.card {
  width: 257px;
  max-width: 100%;
  overflow: hidden;
  border-radius: 16px;
  background: var(--card);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 2px 5px var(--card-shadow);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
}
.art {
  height: 113px;
}
.creature {
  display: block;
  width: 100%;
  height: 100%;
}
.cardBody {
  padding: 15.4px 19px 19.4px;
}
.title {
  margin: 0;
  color: var(--ink);
  font-size: 16px;
  font-weight: 600;
  line-height: 20px;
  letter-spacing: -0.012em;
}
.text {
  margin: 6.2px 0 0;
  color: var(--ink-2);
  font-size: 16px;
  font-weight: 400;
  line-height: 22.8px;
}
.action {
  display: block;
  width: 100%;
  height: 35px;
  margin-top: 15.2px;
  padding: 0 12px;
  border: 0;
  border-radius: 999px;
  background: var(--button);
  box-shadow:
    0 0 0 1px var(--button-edge),
    0 1.5px 2.5px var(--button-shadow);
  color: var(--ink);
  font: inherit;
  font-size: 16px;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
}
.action:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The swirl filter's definitions: in the page, but taking no room. */
.defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

/* Without WebGL: a whirl of gradients at the eye, turning and opening, then a flash and a ring. */
.fallback {
  --t: 0;
  position: absolute;
  top: var(--eye-y);
  left: var(--eye-x);
  z-index: 0;
  width: calc(1400px * var(--eye-k, 1));
  height: calc(1400px * var(--eye-k, 1));
  border-radius: 50%;
  background:
    radial-gradient(closest-side, #f4ffd0 0 3%, #d6f840 7%, transparent 16%),
    conic-gradient(
      from calc(var(--t) * 200deg),
      #5cce98cc,
      #8096844d,
      #5cce98cc,
      #e9f5ee40,
      #5cce98cc
    );
  mask-image: radial-gradient(closest-side, #000 20%, transparent 70%);
  opacity: calc(min(1, var(--t) * 3) * (1 - clamp(0, (var(--t) - 1.85) * 4, 1)));
  transform: translate(-50%, -50%) scale(calc(0.1 + min(1, var(--t) * 1.2) * 0.9));
  pointer-events: none;
}
.root:not([data-vortex='css']) .fallback {
  display: none;
}

/* The demo pointer, over everything; one of its glyphs shows at a time. */
.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.pointer[data-pressed] {
  scale: 0.88;
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

.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) {
  .frame[data-returning] .content,
  .frame[data-returning] .close {
    animation-duration: 0.01s;
  }
}
```

### vortex-dismiss/vortex-field.ts

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

/*
 * The light of the vortex, drawn by one fragment shader on two canvases: the back one, under the
 * content, holds the wide teal whirl and its grey shade; the front one, over the content, holds
 * everything that glows (the shock ring, the lime core, the torn edge, the sparks, the lens
 * flare, the white bloom, the flash and the burst). Each pixel works out where it sits from the
 * vortex's eye in "design px" (the size the effect was drawn at, scaled to the stage), so the
 * whole effect grows with the stage around it. Every layer is laid over the last with its own
 * alpha, never added, so the lime and the teal read as colours on a light page as on a dark one.
 *
 * Also here: the displacement map the content is twisted with, a picture of a swirl field that
 * an SVG feDisplacementMap reads (the content itself is never copied into a texture, so any
 * markup can be swallowed).
 */

/** The effect's colours, as 0–255 RGB. */
export type Palette = {
  /** The wide whirl. */
  glow: [number, number, number];
  /** The darker shade that winds through it. */
  shade: [number, number, number];
  /** The core, the sparks, the shock ring and the flash. */
  spark: [number, number, number];
  /** The burst's ring once the flash has passed. */
  burst: [number, number, number];
};

export const DEFAULT_PALETTE: Palette = {
  glow: [92, 206, 152],
  shade: [128, 150, 132],
  spark: [214, 248, 64],
  burst: [104, 214, 160],
};

/** Parses '#rgb' or '#rrggbb'; anything else gives `fallback`. */
export function parseHex(
  value: string | undefined,
  fallback: [number, number, number],
): [number, number, number] {
  const hex = value?.trim().replace(/^#/, '') ?? '';
  const full = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex;
  if (!/^[0-9a-f]{6}$/i.test(full)) return fallback;
  return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16)) as [number, number, number];
}

const VERTEX = `
attribute vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
`;

const FRAGMENT = `
precision highp float;
uniform vec2 uO;      // the eye, in device px from the top left
uniform float uH;     // the canvas's height in device px
uniform float uK;     // device px per design px
uniform float uT;     // seconds since the press
uniform float uFront; // 0: the layer under the content, 1: the one over it
uniform float uDark;  // 1 on a dark page
uniform float uE;     // the torn edge's radius, in design px
uniform float uTear;  // how much the torn edge shows
uniform vec3 uGlow;
uniform vec3 uShade;
uniform vec3 uSpark;
uniform vec3 uBurst;

const float PI = 3.14159265;

float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float hash1(float n) { return fract(sin(n * 91.345) * 47453.5453); }
float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
             mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
}
float fbm(vec2 p) {
  float v = 0.0;
  float a = 0.5;
  for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
  return v;
}
// A layer laid over what is below it, premultiplied.
void lay(inout vec4 c, vec3 rgb, float a) {
  a = clamp(a, 0.0, 1.0);
  c = vec4(rgb * a, a) + c * (1.0 - a);
}
// A ray: bright along angle 'at', 'width' radians wide, fading out with distance.
float ray(float a, float at, float width) {
  float d = abs(mod(a - at + PI, 2.0 * PI) - PI);
  return exp(-d * d / (width * width));
}

void main() {
  vec2 frag = vec2(gl_FragCoord.x, uH - gl_FragCoord.y);
  vec2 p = (frag - uO) / uK;
  float r = length(p);
  float a = atan(p.y, p.x); // grows clockwise on screen
  vec2 dir = p / max(r, 0.001);
  float t = uT;
  vec4 c = vec4(0.0);
  vec3 white = mix(vec3(1.0), vec3(0.93, 1.0, 0.86), uDark * 0.4);
  vec3 shade = mix(uShade, uShade * 0.28, uDark);

  // The whirl's arms wind in towards the eye and turn clockwise.
  float wind = a + 2.1 * log(1.0 + r / 55.0) - 1.9 * t;
  float warp = fbm(p / 170.0 + vec2(t * 0.15, -t * 0.1));

  if (uFront < 0.5) {
    // The wide whirl, under the content: it opens from the eye, holds, and gives way to the flash.
    float on = smoothstep(0.0, 0.06, t) * (1.0 - smoothstep(1.83, 1.9, t));
    float grow = 1.0 - pow(1.0 - clamp(t / 0.9, 0.0, 1.0), 2.0);
    float reach = mix(220.0, 700.0, grow);
    float body = 1.0 - smoothstep(0.05 * reach, reach, r);
    float arm = 0.5 + 0.5 * cos(2.0 * wind + 2.2 * warp);
    // Stronger close in, and on the eye's right and above, as if lit from there.
    float lean = 0.68 + 0.32 * (0.5 + 0.5 * cos(a + 0.6));
    float near = 0.7 + 0.3 * (1.0 - smoothstep(60.0, 380.0, r));
    float teal = on * body * lean * near * smoothstep(6.0, 70.0, r) * (0.35 + 0.65 * arm);
    lay(c, uGlow, min(teal * 1.12, 0.92));
    float band = pow(0.5 + 0.5 * cos(2.0 * wind + 2.6 + 1.6 * warp), 4.0);
    float grey = on * smoothstep(0.25, 0.7, t) * smoothstep(30.0, 90.0, r) * (1.0 - smoothstep(90.0, 210.0, r)) * band;
    lay(c, shade, grey * 0.65);
    // The eye's dark throat, seen where the content has been eaten away.
    float throat = smoothstep(0.05, 0.3, t) * (1.0 - smoothstep(1.6, 1.8, t));
    lay(c, shade * 0.55, throat * (1.0 - smoothstep(12.0, 70.0, r)) * 0.75);
  } else {
    // The shock ring, thrown out by the press.
    float ringR = 118.0 * (1.0 - exp(-t / 0.09));
    float ringW = 3.0 + 9.0 * t;
    float ringD = r - ringR;
    float ringOn = smoothstep(0.0, 0.02, t) * (1.0 - smoothstep(0.1, 0.42, t));
    lay(c, uSpark, ringOn * (0.7 * exp(-ringD * ringD / (ringW * ringW)) + 0.16 * exp(-abs(ringD) / 20.0)));

    // The core: lime arms streaked like spokes, turning fast, olive dark between them.
    float core = smoothstep(0.08, 0.45, t) * (1.0 - smoothstep(1.66, 1.8, t));
    float coreR = 88.0;
    float twist = a + 2.6 * log(1.0 + r / 18.0) - 4.0 * t;
    float coreArm = 0.15 + 0.85 * pow(0.5 + 0.5 * cos(3.0 * twist + 3.0 * warp), 1.2);
    float spokes = 0.6 + 0.4 * noise(vec2(twist * 7.0, r / 30.0 - t * 2.0));
    float inCore = 1.0 - smoothstep(0.2 * coreR, coreR, r);
    lay(c, shade * 0.55, core * smoothstep(8.0, 26.0, r) * inCore * (1.0 - coreArm) * 0.8);
    lay(c, uSpark, core * inCore * coreArm * spokes * (0.25 + 0.6 * smoothstep(0.3, 0.8, t)));
    lay(c, mix(uSpark, white, 0.65), core * exp(-r / (12.0 + 12.0 * smoothstep(0.8, 1.4, t))) * (0.25 + 0.6 * smoothstep(0.55, 1.0, t)));

    // The torn edge of the content, where the eye first eats into it.
    float jag = uE + 12.0 * (fbm(dir * 5.0 + t * 3.0) - 0.5);
    float edgeD = r - jag;
    float tear = uTear * (1.0 - smoothstep(40.0, 60.0, uE));
    lay(c, uSpark, tear * (0.8 * exp(-edgeD * edgeD / 4.0) + 0.3 * exp(-abs(edgeD) / 7.0) * step(edgeD, 0.0)));
    lay(c, white, tear * 0.8 * exp(-edgeD * edgeD / 0.6));

    // Sparks, born around the eye (mostly above and to the right) and drawn into it.
    for (int i = 0; i < 40; i++) {
      float s = float(i);
      float born = hash1(s * 1.7) * 1.25;
      float life = 0.35 + 0.35 * hash1(s * 2.3);
      float age = (t - born) / life;
      if (age < 0.0 || age > 1.0) continue;
      float from = 18.0 + 60.0 * hash1(s * 3.1);
      float ang = -2.0 + 2.2 * hash1(s * 5.9) + age * 2.0;
      float rr = from * (1.0 - age * 0.85);
      vec2 at = vec2(cos(ang), sin(ang)) * rr;
      float size = 0.6 + 0.8 * hash1(s * 7.7);
      float spot = exp(-dot(p - at, p - at) / (size * size));
      lay(c, mix(uSpark, white, hash1(s * 4.4)), spot * sin(age * PI) * 0.95);
    }

    // The lens flare: a hairline across the whole stage, a soft fan of rays to the left, and a
    // few long beams above and below.
    float flare = smoothstep(0.45, 1.0, t) * (1.0 - smoothstep(1.95, 2.6, t));
    flare *= 0.7 + 0.4 * smoothstep(1.45, 1.8, t);
    float lineFade = 1.0 / (1.0 + abs(p.x) / 1400.0);
    float line = exp(-abs(p.y) / 0.9) * 0.85 + exp(-abs(p.y) / 5.0) * 0.16;
    float fan = (ray(a, PI - 0.05, 0.05) * 0.4 + ray(a, PI - 0.13, 0.07) * 0.32 + ray(a, PI - 0.22, 0.05) * 0.2) *
                smoothstep(20.0, 120.0, r) * (1.0 - smoothstep(450.0, 1000.0, r));
    float up = (ray(a, -1.08, 0.06) * 0.45 + ray(a, -1.22, 0.02) * 0.45 + ray(a, -1.56, 0.008) * 0.5) *
               smoothstep(15.0, 80.0, r) * (1.0 - smoothstep(250.0, 650.0, r));
    float down = ray(a, 2.0, 0.03) * 0.1 * smoothstep(40.0, 120.0, r) * (1.0 - smoothstep(250.0, 600.0, r));
    lay(c, mix(uSpark, white, 0.15), flare * (line * lineFade + fan + up + down));

    // The white bloom that swells in the eye, then collapses to a point.
    float bloomOn = smoothstep(1.3, 1.62, t) * (1.0 - smoothstep(1.76, 1.86, t));
    float bloomR = 4.0 + 52.0 * smoothstep(1.3, 1.66, t) * (1.0 - 0.85 * smoothstep(1.7, 1.82, t));
    lay(c, mix(uSpark, white, 0.5), bloomOn * exp(-r / (bloomR * 1.3)) * 0.4);
    lay(c, white, bloomOn * exp(-pow(r / bloomR, 2.0) * 1.4));

    // The flash: the stage goes pale lime for a moment, brightest at the eye, with a small ring.
    float tb = t - 1.86;
    float flash = smoothstep(-0.03, 0.01, tb) * (1.0 - smoothstep(0.04, 0.3, tb));
    float streaks = 0.75 + 0.25 * noise(vec2(a * 9.0, 0.0));
    lay(c, mix(uSpark, white, 0.55), flash * 0.3);
    lay(c, uSpark, flash * 0.7 * exp(-r / 110.0) * streaks);
    float smallR = 9.0 + 60.0 * max(tb, 0.0);
    float smallD = r - smallR;
    lay(c, mix(uSpark, white, 0.3), step(0.0, tb) * (1.0 - smoothstep(0.03, 0.14, tb)) * exp(-smallD * smallD / 5.0));

    // The burst: a blurred pinwheel of light that opens from the eye in uneven lobes, its colour
    // turning from lime to teal, full of specks, fading as it goes.
    if (tb > 0.0) {
      float burstR = 460.0 * (1.0 - exp(-tb / 0.55));
      float lobes = fbm(dir * 1.6 + vec2(3.0, 1.0)) - 0.5;
      float q = r / max(burstR, 1.0) + (0.22 + 0.25 * (1.0 - smoothstep(0.1, 0.4, tb))) * lobes;
      float fade = smoothstep(0.0, 0.07, tb) * (1.0 - smoothstep(0.35, 1.49, tb));
      float spin = a + 0.4 * log(1.0 + r / 30.0) - 0.5 * tb;
      float blades = pow(0.5 + 0.5 * cos(9.0 * spin), 2.0);
      float lime = 1.0 - smoothstep(0.08, 0.4, tb);
      float hue = fbm(dir * 2.4 + vec2(7.0, 2.0));
      vec3 rim = mix(mix(uBurst, vec3(0.45, 0.84, 0.4), smoothstep(0.5, 0.7, hue)), mix(uSpark, vec3(0.36, 0.78, 0.3), smoothstep(0.55, 0.7, hue)), lime * 0.85);
      // Fullest below and to the left of the eye, broken up elsewhere.
      float side = clamp(smoothstep(-0.3, 0.8, cos(a - 2.3)) * 0.8 + 0.5 * (fbm(dir * 1.3 + vec2(1.0, 5.0)) - 0.35), 0.0, 1.0);
      float band = smoothstep(0.32, 0.62, q) * (1.0 - smoothstep(0.82, 1.08, q));
      lay(c, rim, fade * band * (0.1 + 0.8 * side));
      float disc = 1.0 - smoothstep(0.55, 0.95, q);
      lay(c, mix(uBurst, white, 0.72), fade * (1.0 - smoothstep(0.4, 0.9, q)) * 0.4 * (1.0 - lime));
      lay(c, mix(uSpark, white, 0.7 + 0.3 * (1.0 - lime)), fade * disc * (0.08 + 0.28 * blades) * (1.0 - 0.5 * lime));
      lay(c, uSpark, fade * lime * (1.0 - smoothstep(0.3, 0.95, q)) * 0.95 * (0.6 + 0.4 * blades));
      float edge = q - 1.1;
      lay(c, uSpark, fade * 0.1 * exp(-edge * edge / 0.006));
      for (int i = 0; i < 36; i++) {
        float s = float(i) + 50.0;
        float ang = 2.0 * PI * hash1(s * 1.3);
        float dist = (0.12 + 0.95 * hash1(s * 2.9)) * burstR;
        vec2 at = vec2(cos(ang), sin(ang)) * dist;
        float size = 0.7 + 0.7 * hash1(s * 6.1);
        float spot = exp(-dot(p - at, p - at) / (size * size));
        lay(c, mix(uBurst, uSpark, hash1(s * 8.3)), spot * fade * 0.8);
      }
    }
  }
  gl_FragColor = c;
}
`;

export type FieldFrame = {
  /** Seconds since the press. */
  t: number;
  /** The eye, in CSS px from the canvas's top left. */
  x: number;
  y: number;
  /** CSS px per design px. */
  k: number;
  /** The torn edge's radius in design px, and how much it shows. */
  edge: number;
  tear: number;
  dark: boolean;
  palette: Palette;
};

export type Field = {
  draw: (frame: FieldFrame) => void;
  clear: () => void;
  dispose: () => void;
};

/** A WebGL painter for one canvas, or null where WebGL is missing (the CSS fallback then shows). */
export function createField(canvas: HTMLCanvasElement, front: boolean): Field | null {
  const gl = canvas.getContext('webgl', {
    alpha: true,
    premultipliedAlpha: true,
    antialias: false,
    depth: false,
    stencil: false,
    preserveDrawingBuffer: false,
  });
  if (!gl) return null;
  const compile = (type: number, source: string) => {
    const shader = gl.createShader(type)!;
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    return shader;
  };
  const program = gl.createProgram()!;
  gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX));
  gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAGMENT));
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;
  gl.useProgram(program);
  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  const position = gl.getAttribLocation(program, 'aPos');
  gl.enableVertexAttribArray(position);
  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
  const at = (name: string) => gl.getUniformLocation(program, name);
  const u = {
    o: at('uO'),
    h: at('uH'),
    k: at('uK'),
    t: at('uT'),
    front: at('uFront'),
    dark: at('uDark'),
    e: at('uE'),
    tear: at('uTear'),
    glow: at('uGlow'),
    shade: at('uShade'),
    spark: at('uSpark'),
    burst: at('uBurst'),
  };
  const rgb = (c: [number, number, number]) => [c[0] / 255, c[1] / 255, c[2] / 255] as const;

  return {
    draw(frame) {
      // The canvas matches its box at the screen's density, up to 2×: the light is soft, so
      // more would cost without showing.
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      const width = Math.max(1, Math.round(canvas.clientWidth * ratio));
      const height = Math.max(1, Math.round(canvas.clientHeight * ratio));
      if (canvas.width !== width || canvas.height !== height) {
        canvas.width = width;
        canvas.height = height;
      }
      gl.viewport(0, 0, width, height);
      gl.uniform2f(u.o, frame.x * ratio, frame.y * ratio);
      gl.uniform1f(u.h, height);
      gl.uniform1f(u.k, frame.k * ratio);
      gl.uniform1f(u.t, frame.t);
      gl.uniform1f(u.front, front ? 1 : 0);
      gl.uniform1f(u.dark, frame.dark ? 1 : 0);
      gl.uniform1f(u.e, frame.edge);
      gl.uniform1f(u.tear, frame.tear);
      gl.uniform3f(u.glow, ...rgb(frame.palette.glow));
      gl.uniform3f(u.shade, ...rgb(frame.palette.shade));
      gl.uniform3f(u.spark, ...rgb(frame.palette.spark));
      gl.uniform3f(u.burst, ...rgb(frame.palette.burst));
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    },
    clear() {
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
    },
    dispose() {
      gl.deleteBuffer(buffer);
      gl.deleteProgram(program);
      gl.getExtension('WEBGL_lose_context')?.loseContext();
    },
  };
}

/**
 * The swirl field as a picture, for feDisplacementMap: red and green carry where each pixel of
 * the twisted content is read from, as an offset from 128. The offset turns the content
 * clockwise around the eye (most at `turn` px from it, less further in and further out) and
 * draws it inwards (most at `pull` px). `width`×`height` is the filter's region in CSS px, with
 * the eye at `x`, `y` in it.
 */
export function swirlMap(
  width: number,
  height: number,
  x: number,
  y: number,
  turn: number,
  pull: number,
): string {
  const scale = 1;
  const w = Math.max(2, Math.round(width * scale));
  const h = Math.max(2, Math.round(height * scale));
  const canvas = document.createElement('canvas');
  canvas.width = w;
  canvas.height = h;
  const context = canvas.getContext('2d');
  if (!context) return '';
  const image = context.createImageData(w, h);
  const data = image.data;
  for (let j = 0; j < h; j += 1) {
    for (let i = 0; i < w; i += 1) {
      const dx = (i + 0.5) / scale - x;
      const dy = (j + 0.5) / scale - y;
      const r = Math.hypot(dx, dy) || 1;
      // Both peak at their radius and fade fast beyond it, so the far side stays nearly flat.
      const ft = (r / turn) * Math.exp(0.5 * (1 - (r / turn) ** 2));
      const fr = 0.6 * (r / pull) * Math.exp(0.5 * (1 - (r / pull) ** 2));
      // Read from a little anticlockwise and further out: the content moves clockwise and in.
      let fx = (dy / r) * ft + (dx / r) * fr;
      let fy = (-dx / r) * ft + (dy / r) * fr;
      const m = Math.hypot(fx, fy);
      if (m > 1) {
        fx /= m;
        fy /= m;
      }
      const o = (j * w + i) * 4;
      data[o] = Math.round(128 + 127 * fx);
      data[o + 1] = Math.round(128 + 127 * fy);
      data[o + 2] = 128;
      data[o + 3] = 255;
    }
  }
  context.putImageData(image, 0, 0);
  return canvas.toDataURL('image/png');
}
```

### vortex-dismiss/promo-card.tsx

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

'use client';

import { useId } from 'react';
import styles from './vortex-dismiss.module.css';

/*
 * The sample content the vortex swallows when nothing else is given: a promo card with a glossy
 * green creature on top, a title, a line of copy and a call to action. The art is one SVG of
 * gradients only (no filters, no raster), so it stays sharp at any size and costs nothing to
 * redraw while the card twists.
 */

/** The creature: a soft four-armed blob, two arched eyes, a leaf scarf, a chain and a coin. */
function Creature() {
  // Gradient ids are per instance: two cards on a page must not share them.
  const id = useId().replace(/[^a-zA-Z0-9]/g, '');
  const ref = (name: string) => `url(#${id}${name})`;
  return (
    <svg
      className={styles.creature}
      viewBox="0 0 256 112"
      preserveAspectRatio="xMidYMid slice"
      aria-hidden="true"
      focusable="false"
    >
      <defs>
        <linearGradient id={`${id}sky`} x1="0" y1="0" x2="1" y2="0.3">
          <stop offset="0" stopColor="#b4ffb4" />
          <stop offset="0.3" stopColor="#6ff6a3" />
          <stop offset="0.62" stopColor="#5af29c" />
          <stop offset="1" stopColor="#3fd58b" />
        </linearGradient>
        <radialGradient id={`${id}haze`} cx="0.03" cy="0.62" r="0.32">
          <stop offset="0" stopColor="#f6ffd2" />
          <stop offset="0.55" stopColor="#e9ffbd" stopOpacity="0.75" />
          <stop offset="1" stopColor="#e9ffbd" stopOpacity="0" />
        </radialGradient>
        <radialGradient id={`${id}floor`} cx="0.55" cy="1.1" r="0.5">
          <stop offset="0" stopColor="#d4ff6c" />
          <stop offset="0.6" stopColor="#b6ff7e" stopOpacity="0.8" />
          <stop offset="1" stopColor="#b6ff7e" stopOpacity="0" />
        </radialGradient>
        <radialGradient id={`${id}corner`} cx="0" cy="1" r="0.28">
          <stop offset="0" stopColor="#56ee6c" />
          <stop offset="1" stopColor="#56ee6c" stopOpacity="0" />
        </radialGradient>
        <radialGradient id={`${id}east`} cx="1" cy="0.35" r="0.3">
          <stop offset="0" stopColor="#2fcf86" />
          <stop offset="1" stopColor="#2fcf86" stopOpacity="0" />
        </radialGradient>
        <radialGradient id={`${id}body`} cx="0.42" cy="0.32" r="0.75">
          <stop offset="0" stopColor="#5df35c" />
          <stop offset="0.45" stopColor="#2fd347" />
          <stop offset="0.8" stopColor="#17b33a" />
          <stop offset="1" stopColor="#0f9a34" />
        </radialGradient>
        <linearGradient id={`${id}depth`} x1="0.2" y1="0.1" x2="0.75" y2="1">
          <stop offset="0" stopColor="#0a7a2c" stopOpacity="0" />
          <stop offset="0.55" stopColor="#0a7a2c" stopOpacity="0.1" />
          <stop offset="1" stopColor="#06612a" stopOpacity="0.4" />
        </linearGradient>
        <linearGradient id={`${id}rim`} x1="0" y1="1" x2="0.6" y2="0">
          <stop offset="0" stopColor="#d8ff5e" />
          <stop offset="0.3" stopColor="#a6fb5a" stopOpacity="0.7" />
          <stop offset="0.55" stopColor="#8cf75a" stopOpacity="0" />
        </linearGradient>
        <linearGradient id={`${id}eye`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#fbfff0" />
          <stop offset="1" stopColor="#d9f7c4" />
        </linearGradient>
        <linearGradient id={`${id}leaf`} x1="0" y1="0" x2="0.3" y2="1">
          <stop offset="0" stopColor="#36cf5b" />
          <stop offset="0.5" stopColor="#0d9a3c" />
          <stop offset="1" stopColor="#c8f53a" />
        </linearGradient>
        <linearGradient id={`${id}link`} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#4fe36a" />
          <stop offset="1" stopColor="#0b8a36" />
        </linearGradient>
        <radialGradient id={`${id}coin`} cx="0.4" cy="0.35" r="0.7">
          <stop offset="0" stopColor="#ecff6a" />
          <stop offset="0.7" stopColor="#b7f23a" />
          <stop offset="1" stopColor="#5fcf3a" />
        </radialGradient>
        <radialGradient id={`${id}face`} cx="0.45" cy="0.4" r="0.65">
          <stop offset="0" stopColor="#3fdc5a" />
          <stop offset="1" stopColor="#119c3c" />
        </radialGradient>
      </defs>

      {/* The sky: a mint wash, a pale glow on the left, lime rising from below. */}
      <rect width="256" height="112" fill={ref('sky')} />
      <rect width="256" height="112" fill={ref('corner')} />
      <rect width="256" height="112" fill={ref('east')} />
      <rect width="256" height="112" fill={ref('floor')} />
      <rect width="256" height="112" fill={ref('haze')} />

      {/* Splashes under the creature. */}
      <g fill="#c8ff7a" opacity="0.85">
        <ellipse cx="122" cy="106" rx="3.2" ry="1.6" transform="rotate(-30 122 106)" />
        <ellipse cx="141" cy="103" rx="2" ry="1.2" />
        <ellipse cx="148" cy="109" rx="2.6" ry="1.4" transform="rotate(20 148 109)" />
        <circle cx="112" cy="109" r="1.6" />
      </g>

      {/* The leaf scarf, streaming to the right behind the creature. */}
      <path
        d="M130 48C146 43 162 43 176 48C184 51 192 53 199 56C194 61 184 64 172 67C162 69 152 70 140 72C134 66 131 56 130 48Z"
        fill={ref('leaf')}
      />
      <path
        d="M150 69C162 66 176 64 190 60C184 64 174 67 164 69C158 70 154 70 150 69Z"
        fill="#d6f54a"
        opacity="0.85"
      />

      {/* The chain, from the creature down to the coin. */}
      <g fill="none" stroke={ref('link')} strokeWidth="4.6">
        <ellipse cx="147" cy="73" rx="7.5" ry="4.8" transform="rotate(35 147 73)" />
        <ellipse cx="158" cy="81" rx="3.4" ry="6.8" transform="rotate(-40 158 81)" />
        <ellipse cx="170" cy="88" rx="7.5" ry="4.8" transform="rotate(25 170 88)" />
        <ellipse cx="181" cy="94" rx="3.4" ry="6.4" transform="rotate(-50 181 94)" />
      </g>
      <g fill="none" stroke="#c9ff6a" strokeWidth="1" opacity="0.8">
        <path d="M141 70c3-1.2 7-0.4 10 2" />
        <path d="M164 85c3-1.2 7-0.4 10 2" />
      </g>

      {/* The coin, half out of the frame, with a blob on its face. */}
      <circle cx="216.5" cy="91" r="32" fill={ref('coin')} />
      <circle cx="218.5" cy="93" r="25" fill={ref('face')} />
      <circle
        cx="218.5"
        cy="93"
        r="25"
        fill="none"
        stroke="#0b8a36"
        strokeWidth="1.2"
        opacity="0.6"
      />
      <path
        d="M211 88C209 82 216 79 221 83C225 86 228 81 233 82C239 84 237 90 234 93C231 96 236 100 234 104C231 109 224 106 221 103C218 100 213 104 209 101C205 98 209 94 211 91Z"
        fill="#d9ff5a"
        transform="translate(-4 0)"
      />

      {/* The creature: body, shade, a lime rim light from below, highlights, and its eyes. */}
      <clipPath id={`${id}shape`}>
        <path d="M16 36C15 28 22 23 31 24C40 25 48 30 56 31C62 31 64 24 70 18C76 11 88 8 99 9C108 10 114 16 114 25C114 33 116 40 122 45C129 50 140 53 146 59C150 64 147 71 139 72C132 73 128 76 128 82C128 91 136 99 133 106C130 112 122 110 120 103C118 96 120 89 114 83C107 77 97 79 87 75C77 71 69 64 60 59C51 54 42 51 33 50C25 49 17 45 16 36Z" />
      </clipPath>
      <path
        d="M16 36C15 28 22 23 31 24C40 25 48 30 56 31C62 31 64 24 70 18C76 11 88 8 99 9C108 10 114 16 114 25C114 33 116 40 122 45C129 50 140 53 146 59C150 64 147 71 139 72C132 73 128 76 128 82C128 91 136 99 133 106C130 112 122 110 120 103C118 96 120 89 114 83C107 77 97 79 87 75C77 71 69 64 60 59C51 54 42 51 33 50C25 49 17 45 16 36Z"
        fill={ref('body')}
      />
      <path
        d="M16 36C15 28 22 23 31 24C40 25 48 30 56 31C62 31 64 24 70 18C76 11 88 8 99 9C108 10 114 16 114 25C114 33 116 40 122 45C129 50 140 53 146 59C150 64 147 71 139 72C132 73 128 76 128 82C128 91 136 99 133 106C130 112 122 110 120 103C118 96 120 89 114 83C107 77 97 79 87 75C77 71 69 64 60 59C51 54 42 51 33 50C25 49 17 45 16 36Z"
        fill={ref('depth')}
      />
      <path
        d="M16 36C15 28 22 23 31 24C40 25 48 30 56 31C62 31 64 24 70 18C76 11 88 8 99 9C108 10 114 16 114 25C114 33 116 40 122 45C129 50 140 53 146 59C150 64 147 71 139 72C132 73 128 76 128 82C128 91 136 99 133 106C130 112 122 110 120 103C118 96 120 89 114 83C107 77 97 79 87 75C77 71 69 64 60 59C51 54 42 51 33 50C25 49 17 45 16 36Z"
        fill="none"
        stroke={ref('rim')}
        strokeWidth="7"
        clipPath={ref('shape')}
      />
      <g fill="#f6ffea">
        <ellipse cx="27" cy="28" rx="8" ry="2.4" opacity="0.85" transform="rotate(4 27 28)" />
        <ellipse cx="92" cy="13.5" rx="9" ry="2.2" opacity="0.8" transform="rotate(-6 92 13.5)" />
        <ellipse cx="140" cy="60" rx="4" ry="1.4" opacity="0.7" transform="rotate(20 140 60)" />
      </g>
      <path
        d="M64.5 47C64.5 42.5 67.5 40 71 40C74.5 40 77.5 42.5 77.5 47V57H64.5Z"
        fill={ref('eye')}
      />
      <path d="M85 49C85 44.5 88.5 42 92 42C95.5 42 99 44.5 99 49V59H85Z" fill={ref('eye')} />
    </svg>
  );
}

/** The sample card: art, title, copy and a call to action. */
export function PromoCard({
  title = 'Launch your venture',
  text = 'Raise from your community, guided by markets.',
  action = 'Get started',
  onAction,
}: {
  title?: string;
  text?: string;
  action?: string;
  onAction?: () => void;
}) {
  return (
    <article className={styles.card} data-part="card">
      <div className={styles.art} data-part="art">
        <Creature />
      </div>
      <div className={styles.cardBody}>
        <h3 className={styles.title} data-part="title">
          {title}
        </h3>
        <p className={styles.text} data-part="text">
          {text}
        </p>
        <button type="button" className={styles.action} data-part="action" onClick={onAction}>
          {action}
        </button>
      </div>
    </article>
  );
}
```

### vortex-dismiss/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, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it (the eye of the
 * card, should anything want to move along with it). The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### vortex-dismiss/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 };
}
```