# Swipe deck

> A phone with a deck of profile cards: drag left to pass, right to like, and the next card grows into place.

A dating app’s discovery screen on a phone. The top card follows the finger, turning a little as it goes; its cross or heart swells out of the card into a big badge and the card washes dark or purple. Let go far enough, or flick, and it flies off on its momentum while the card behind grows into place and its name fades in; otherwise a spring brings it back. The buttons and the arrow keys do the same. Photographic portraits of generated adults, with invented names. Light and dark.

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

Category: Mobile · Contents.

## Anatomy

1. **Status bar**: The time and the phone’s signal, Wi-Fi and battery, drawn and hidden from screen readers.
2. **Header tools**: Filters on the left and notifications on the right, 44-point buttons; the bell carries a purple dot.
3. **Top card**: A focusable group with the portrait, a purple gradient and the profile. Drag it, or use the arrow keys.
4. **Card behind**: The next profile, drawn at 84% wide and peeking 11 px below; it grows to 92% with the drag. Inert.
5. **Profile**: The name on two lines in tall condensed capitals, up to three interest icons and a detail line.
6. **Pass button**: A real button named after the person; it plays the same move as a drag to the left.
7. **Like button**: The brand’s purple, with the heart; it slides the card out to the right and sends it off.
8. **Tab bar**: Four tabs; the current one is purple and marked aria-current.

## When to use

- A quick yes-or-no on one item at a time, on a phone: people to meet, homes to visit, outfits, recipes.
- A deck where the picture carries the choice, and a name and a line or two are enough to decide.
- Triage that should feel light: drag it away, or press the cross or the heart, and the next one is already there.
- A screen held in one hand, where a thumb’s sideways drag is the fastest gesture there is.

## When not to use

- Comparing items side by side, or going back to one. Use a list or a grid with a save button.
- Choices with more than two answers (yes, no, later, share). Use a card with labelled buttons, or an action sheet.
- Decisions that cost something or cannot be undone, such as a payment or a deletion. Use a confirm dialog.
- Items that need reading: long text, specs or prices. Use a detail page with the actions at its foot.
- A desktop page. It is drawn as a 390-point phone screen; on a wide layout use a grid of cards.

## Installation

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

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

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

- https://brunodelorence.com/experiments/swipe-deck/dusk-photo-v1.webp
- https://brunodelorence.com/experiments/swipe-deck/window-photo-v1.webp
- https://brunodelorence.com/experiments/swipe-deck/garden-photo-v1.webp
- https://brunodelorence.com/experiments/swipe-deck/shore-photo-v1.webp

## Usage

```tsx
import { SwipeDeck, type Profile } from './swipe-deck';

const people: Profile[] = [
  { first: 'Elias', last: 'Morrow', detail: 'Male, 21', interests: ['bike', 'swim'], scene: 'dusk' },
  { first: 'Juno', last: 'Hale', detail: 'Female, 22', interests: ['camera'], scene: 'garden' },
];

export function Discover() {
  return (
    <SwipeDeck
      profiles={people}
      autoplay={false}
      onDecide={(profile, choice) => console.log(choice, profile.first)}
    />
  );
}
```

## States

- **At rest**: The top card square to the screen, both buttons on it, the next card peeking underneath.
- **Dragging to pass**: Held 120 points left: turned −2.9°, a dark wash at 24%, the cross rising as a badge and the heart faded.
- **Dragging to like**: Held 120 points right: the purple wash comes in and the heart swells sooner than the cross does.
- **Pass, full progress**: At 250 points the badge is 2.3 times its size and the card behind is at 92%. Let go here and it flies.
- **Like, full progress**: The heart at 2.7 times over a purple wash at 70%; past this point the badge rides along with the card.
- **Dark**: The stage, the screen and the pass button go dark; the cards and the purple stay as they are.

## How it works

- The phone’s screen is laid out at 390 × 844 points and scaled as a whole to fit the stage (never above its own size), so every measure inside stays in points; pointer positions are divided by the same scale.
- A drag writes one transform on the top card each frame, outside React: it follows the finger (a third as much up and down), turns 0.024° per px and shrinks by up to 4%. React only renders which profile is on top, next and third.
- The progress is the distance over 250 px. With it the button of that side is swapped for a badge above the deck that rises towards the free space and grows to 2.3 times (pass) or 2.7 times (like), the other button fades to a quarter, and a wash comes over the card, black for pass and purple for like. Past 250 px the badge rides along with the card.
- The card behind waits narrower (84% wide) and peeks 11 px below the top one, showing its art alone; it grows with the progress, to 92%.
- Letting go past 130 px, or flicking faster than 0.55 px/ms, sends the card off in a 300 ms Hermite curve that starts at the finger’s speed, while the card behind grows into place (300 ms) and its gradient, name and buttons fade in. Otherwise a critically damped spring (stiffness 500, damping 44) brings it back from the finger’s speed, stepped at 240 Hz so a slow frame cannot throw it.
- The pass and like buttons and the arrow keys play the same moves: the card slides out to full progress in 260 ms, then flies. Once React has swapped the roles, a layout effect resets the motion before the browser paints, and moves the focus to the new top card if the old one had it.
- While on screen and untouched, a demo finger teases the card each way, passes, taps the heart and flicks a like; any real input hands control to the reader.
- Every colour is a light-dark() token on the root: at night the stage and the app go dark, the pass button turns charcoal, and the cards and the brand’s purple stay the same.

## API reference

- **profiles** (`Profile[]`, default `four invented profiles`): The deck, in order; it starts over after the last. A profile has first and last names, a detail line, up to three interests (bike, swim, hike, music, camera, book) a scene for its photograph (dusk, window, garden, shore), and an optional imageSrc for your own photo.
- **onDecide** (`(profile: Profile, choice: 'pass' | 'like') => void`): Called once a card has left the screen.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo finger swipes the deck. Any real input hands control to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **defaultOffset** (`number`): Opens with the top card held this many points to the side, as if mid-drag: negative towards pass, positive towards like, 250 at full progress. It turns the demo off.
- **className** (`string`): Added to the stage.

## Accessibility

- The top card is a focusable group labelled with the name and detail; Left and Right arrow keys pass or like it. Its pass and like buttons are real buttons named after the person.
- Each decision is announced in a polite live region (“Passed on Elias Morrow. Now showing Tomas Veira.”), and focus on a leaving card moves to the one that replaces it.
- The cards behind are hidden from assistive technology and inert; the portraits, badges, status bar and demo finger are decorative and hidden.
- Dragging uses pointer events with capture and leaves vertical panning to the page on touch. Reduced motion skips the flights and the spring and stops the demo.

## Source

### swipe-deck/swipe-deck.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { Icon, InterestIcon, Portrait, type Interest, type Scene } from './art';
import { useAutoplay } from './use-autoplay';
import styles from './swipe-deck.module.css';

/*
 * Swipe deck: a phone with a deck of profile cards. Drag the top card left to pass, right to like;
 * or press its buttons, or use the arrow keys. The next card waits behind, and grows into place
 * as the top one leaves.
 *
 * - The card follows the finger: it moves with it (a third as much up and down), turns a little
 *   with the distance (0.024° per px, about 6° at 250 px), and shrinks by up to 4%. All of it is
 *   one transform, written straight on the card each frame; React only renders who is on top.
 * - Its progress is the distance over 250 px. With it, the button of that side grows out of the
 *   card into a badge (to 2.3 times its size for pass, 2.7 for like) and rises towards the middle
 *   of the free space; past 250 px it rides along with the card. The other button fades to 25%,
 *   and a wash comes over the card: dark for pass, the brand's purple for like.
 * - The card behind starts narrower (84%) and peeks 11 px under the top one; it grows with the
 *   progress, to 92%.
 * - Let go past 130 px, or flick faster than 0.55 px/ms, and the card flies off on its momentum
 *   (a 300 ms curve that starts at the finger's speed) while the next one grows into place and its
 *   name, gradient and buttons fade in. Otherwise a critically damped spring (stiffness 500,
 *   damping 44) brings it back from the finger's speed, without overshoot.
 * - The buttons and the arrow keys play the same moves: the card slides to 250 px, then flies.
 * - While on screen and untouched, a demo finger swipes the deck: a tease each way, a pass, a tap
 *   on the heart and a like. Any real input hands control to the reader.
 * - The phone is drawn at 390 × 844 points and scaled down to fit narrower or shorter stages.
 * - 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. Reduced motion skips the flights.
 */

export type Profile = {
  /** First and last name, drawn on two lines. */
  first: string;
  last: string;
  /** The small line under the interests. */
  detail: string;
  /** Up to three small icons: bike, swim, hike, music, camera, book. */
  interests: Interest[];
  /** The photographic portrait: dusk, window, garden or shore. */
  scene: Scene;
  /** Optional URL for your own portrait photograph. */
  imageSrc?: string;
};

export type Choice = 'pass' | 'like';

const PROFILES: Profile[] = [
  {
    first: 'Elias',
    last: 'Morrow',
    detail: 'Male, 21',
    interests: ['bike', 'swim'],
    scene: 'dusk',
  },
  {
    first: 'Tomas',
    last: 'Veira',
    detail: 'Male, 23',
    interests: ['hike', 'music'],
    scene: 'window',
  },
  {
    first: 'Juno',
    last: 'Hale',
    detail: 'Female, 22',
    interests: ['camera', 'swim'],
    scene: 'garden',
  },
  {
    first: 'Ines',
    last: 'Rocha',
    detail: 'Female, 24',
    interests: ['bike', 'book'],
    scene: 'shore',
  },
];

/** The phone's screen, in points. */
const SCREEN = { width: 390, height: 844 };
/** The distance at which a drag's progress is whole. */
const REACH = 250;
/** Degrees of turn per px of drag. */
const TURN = 0.024;
/** How far a drag must go to count once let go, and how fast a flick must be (px/ms). */
const COMMIT = 130;
const FLICK = 0.55;
/** The flight off the screen. */
const FLIGHT = 300;
const AWAY = 600;
/** The next card's growth into place. */
const PROMOTE = 300;
/** The spring that brings a card back. */
const STIFFNESS = 500;
const DAMPING = 44;
/**
 * The badges: where each button sits on the screen (its middle), where it rises to at full
 * progress, and how much larger it grows.
 */
const BADGE = {
  pass: { x: 69.5, y: 681.5, toX: 130, toY: 505, scale: 2.3 },
  like: { x: 323.5, y: 681.5, toX: 258, toY: 515, scale: 2.7 },
} as const;

type Motion = { dx: number; dy: number };
type Drag = {
  owner: 'reader' | 'demo';
  fromX: number;
  fromY: number;
  dx0: number;
  dy0: number;
  samples: { x: number; t: number }[];
};

const nameOf = (profile: Profile) => `${profile.first} ${profile.last}`;
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);

export function SwipeDeck({
  profiles = PROFILES,
  onDecide,
  autoplay = true,
  colorScheme,
  defaultOffset,
  className = '',
}: {
  /** The deck, in order; it starts over after the last one. */
  profiles?: Profile[];
  /** Called once a card has left: with the profile, and 'pass' or 'like'. */
  onDecide?: (profile: Profile, choice: Choice) => void;
  /** While on screen and untouched, a demo finger swipes the deck. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /**
   * Opens with the top card held this many points to the side, as if mid-drag (negative towards
   * pass, positive towards like; 250 is full progress). It turns the demo off.
   */
  defaultOffset?: number;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(5000);
  const [index, setIndex] = useState(0);
  const [tab, setTab] = useState(0);
  const [message, setMessage] = useState('');
  const screen = useRef<HTMLDivElement>(null);
  const top = useRef<HTMLDivElement>(null);
  const next = useRef<HTMLDivElement>(null);
  const passBadge = useRef<HTMLSpanElement>(null);
  const likeBadge = useRef<HTMLSpanElement>(null);
  const finger = useRef<HTMLSpanElement>(null);
  const motion = useRef<Motion>({ dx: defaultOffset ?? 0, dy: 0 });
  const drag = useRef<Drag | null>(null);
  /** The card is on its way out: input waits until the next one is in place. */
  const busy = useRef(false);
  const frame = useRef(0);
  const fit = useRef(1);
  const reduced = useRef(false);

  const count = profiles.length;
  const at = (offset: number) => profiles[(index + offset) % count];

  // The reader's motion setting, read live.
  useEffect(() => {
    const query = window.matchMedia('(prefers-reduced-motion: reduce)');
    const read = () => (reduced.current = query.matches);
    read();
    query.addEventListener('change', read);
    return () => query.removeEventListener('change', read);
  }, []);

  // The phone keeps its 390-point design and is scaled down to fit the stage.
  useLayoutEffect(() => {
    const stage = root.current;
    if (!stage) return;
    const measure = () => {
      const scale = Math.min(
        1,
        (stage.clientWidth - 32) / SCREEN.width,
        (stage.clientHeight - 40) / SCREEN.height,
      );
      fit.current = Math.max(0.3, scale);
      stage.style.setProperty('--fit', fit.current.toFixed(4));
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(stage);
    return () => observer.disconnect();
  }, [root]);

  /** Writes the motion on the cards and the badges: one transform each. */
  const paint = useCallback(() => {
    const card = top.current;
    if (!card) return;
    const { dx, dy } = motion.current;
    const progress = Math.min(1, Math.abs(dx) / REACH);
    const side: Choice | null = dx < -0.5 ? 'pass' : dx > 0.5 ? 'like' : null;
    card.style.transform = `translate(${dx.toFixed(2)}px, ${dy.toFixed(2)}px) rotate(${(dx * TURN).toFixed(3)}deg) scale(${(1 - 0.04 * progress).toFixed(4)})`;
    if (side) card.dataset.side = side;
    else delete card.dataset.side;
    const wash = card.querySelector<HTMLElement>('[data-part="wash"]');
    if (wash) wash.style.opacity = String((side === 'like' ? 0.7 : 0.5) * progress);
    const passButton = card.querySelector<HTMLElement>('[data-part="pass"]');
    const likeButton = card.querySelector<HTMLElement>('[data-part="like"]');
    // The other button fades quickly, to a quarter.
    const faded = String(Math.max(0.25, 1 - 1.1 * progress));
    if (passButton) passButton.style.opacity = side === 'pass' ? '0' : faded;
    if (likeButton) likeButton.style.opacity = side === 'like' ? '0' : faded;
    for (const [name, badge] of [
      ['pass', passBadge.current],
      ['like', likeBadge.current],
    ] as const) {
      if (!badge) continue;
      if (side !== name) {
        badge.style.opacity = '0';
        continue;
      }
      const spec = BADGE[name];
      // The heart swells a little sooner than the cross; both rise with the progress.
      const t = progress;
      const grow = name === 'like' ? 1 - (1 - progress) ** 1.6 : progress;
      const beyond = Math.abs(dx) > REACH ? dx - Math.sign(dx) * REACH : 0;
      const x = (spec.toX - spec.x) * t + beyond;
      const y = (spec.toY - spec.y) * t;
      badge.style.opacity = '1';
      badge.style.transform = `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${(1 + (spec.scale - 1) * grow).toFixed(4)})`;
    }
    const behind = next.current;
    if (behind && !behind.dataset.promote) {
      behind.style.transform = `translateY(${(11 + progress).toFixed(2)}px) scale(${(0.84 + 0.08 * progress).toFixed(4)}, ${(0.94 + 0.042 * progress).toFixed(4)})`;
    }
  }, []);

  useEffect(() => () => cancelAnimationFrame(frame.current), []);

  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  /** A decision waiting for React to swap the roles. */
  const decided = useRef<{ choice: Choice; gone: Profile; coming: Profile; focused: boolean }>(
    null,
  );

  /**
   * The next card takes the top: React swaps the roles. Until it has, what shows is already the
   * end state (the old card off screen, the new one in place), so nothing flickers in between.
   */
  const settle = useEffectEvent((choice: Choice) => {
    decided.current = {
      choice,
      gone: at(0),
      coming: at(1),
      // Focus on the leaving card (or its buttons) moves to the card that takes its place.
      focused: !!top.current?.contains(document.activeElement),
    };
    setIndex((now) => now + 1);
  });

  // Once the roles are swapped, before the browser paints: the motion starts over.
  const swapped = useEffectEvent(() => {
    const done = decided.current;
    decided.current = null;
    const card = top.current;
    if (card) {
      delete card.dataset.promote;
      card.style.transition = '';
    }
    // On mounting, the card stays where it opens (at rest, or held at defaultOffset).
    if (done) motion.current = { dx: 0, dy: 0 };
    paint();
    busy.current = false;
    if (!done) return;
    if (done.focused) card?.focus({ preventScroll: true });
    announce(
      `${done.choice === 'like' ? 'Liked' : 'Passed on'} ${nameOf(done.gone)}. Now showing ${nameOf(done.coming)}.`,
    );
    onDecide?.(done.gone, done.choice);
  });
  useLayoutEffect(() => swapped(), [index]);

  /** Brings the card back to rest, from its speed (px/ms). */
  const springBack = (vx: number, vy: number) => {
    cancelAnimationFrame(frame.current);
    if (reduced.current) {
      motion.current = { dx: 0, dy: 0 };
      paint();
      return;
    }
    let vX = vx * 1000;
    let vY = vy * 1000;
    let last = performance.now();
    const step = (now: number) => {
      // Small steps keep the spring steady on a slow frame.
      let dt = Math.min(0.064, (now - last) / 1000);
      last = now;
      const m = motion.current;
      while (dt > 0) {
        const h = Math.min(dt, 1 / 240);
        vX += (-STIFFNESS * m.dx - DAMPING * vX) * h;
        vY += (-STIFFNESS * m.dy - DAMPING * vY) * h;
        m.dx += vX * h;
        m.dy += vY * h;
        dt -= h;
      }
      if (Math.abs(m.dx) < 0.3 && Math.abs(m.dy) < 0.3 && Math.abs(vX) < 6 && Math.abs(vY) < 6) {
        motion.current = { dx: 0, dy: 0 };
        paint();
        return;
      }
      paint();
      frame.current = requestAnimationFrame(step);
    };
    frame.current = requestAnimationFrame(step);
  };

  /** Sends the card off the screen, from its speed (px/ms), and brings the next one in. */
  const flyOff = (choice: Choice, vx: number) => {
    cancelAnimationFrame(frame.current);
    busy.current = true;
    if (reduced.current) return settle(choice);
    const sign = choice === 'like' ? 1 : -1;
    const from = motion.current.dx;
    const to = sign * AWAY;
    // A curve that leaves at the finger's speed (at least 1 px/ms outwards) and keeps going.
    const start = Math.max(1, vx * sign) * sign * FLIGHT;
    const end = (to - from) * 1.2;
    const behind = next.current;
    if (behind) {
      behind.dataset.promote = 'true';
      behind.style.transition = `transform ${PROMOTE}ms cubic-bezier(0.2, 0.8, 0.2, 1)`;
      behind.style.transform = 'none';
    }
    const began = performance.now();
    const step = (now: number) => {
      const t = Math.min(1, (now - began) / FLIGHT);
      const t2 = t * t;
      const t3 = t2 * t;
      motion.current.dx =
        (2 * t3 - 3 * t2 + 1) * from +
        (t3 - 2 * t2 + t) * start +
        (-2 * t3 + 3 * t2) * to +
        (t3 - t2) * end;
      paint();
      if (t < 1) frame.current = requestAnimationFrame(step);
      else settle(choice);
    };
    frame.current = requestAnimationFrame(step);
  };

  /** The buttons and the keys: the card slides out to full progress, then flies. */
  const decide = (choice: Choice) => {
    if (busy.current || drag.current) return;
    cancelAnimationFrame(frame.current);
    busy.current = true;
    if (reduced.current) return settle(choice);
    const from = { ...motion.current };
    const to = (choice === 'like' ? 1 : -1) * REACH;
    const began = performance.now();
    const step = (now: number) => {
      const t = Math.min(1, (now - began) / 260);
      const e = easeInOut(t);
      motion.current = { dx: from.dx + (to - from.dx) * e, dy: from.dy * (1 - e) };
      paint();
      if (t < 1) frame.current = requestAnimationFrame(step);
      else flyOff(choice, 1.2 * Math.sign(to));
    };
    frame.current = requestAnimationFrame(step);
  };

  // Dragging, in the screen's points: the reader's finger or mouse, or the demo's finger.
  const grab = (x: number, y: number, owner: Drag['owner']) => {
    if (busy.current) return false;
    cancelAnimationFrame(frame.current);
    const { dx, dy } = motion.current;
    drag.current = {
      owner,
      fromX: x,
      fromY: y,
      dx0: dx,
      dy0: dy,
      samples: [{ x, t: performance.now() }],
    };
    return true;
  };
  const move = (x: number, y: number) => {
    const d = drag.current;
    if (!d) return;
    motion.current = { dx: d.dx0 + x - d.fromX, dy: d.dy0 + (y - d.fromY) / 3 };
    const now = performance.now();
    d.samples.push({ x, t: now });
    while (d.samples.length > 2 && now - d.samples[0].t > 90) d.samples.shift();
    paint();
  };
  const release = () => {
    const d = drag.current;
    if (!d) return;
    drag.current = null;
    const first = d.samples[0];
    const last = d.samples[d.samples.length - 1];
    const span = last.t - first.t;
    const vx = span > 8 ? (last.x - first.x) / span : 0;
    const { dx } = motion.current;
    const sign = Math.sign(dx);
    const far = Math.abs(dx) > COMMIT && vx * sign > -0.2;
    const flicked = Math.abs(dx) > 24 && vx * sign > FLICK;
    if (sign && (far || flicked)) flyOff(sign > 0 ? 'like' : 'pass', vx);
    else springBack(vx, 0);
  };

  /** Screen points from a pointer event. */
  const pointOf = (event: { clientX: number; clientY: number }) => {
    const rect = screen.current?.getBoundingClientRect();
    if (!rect) return { x: 0, y: 0 };
    // The drawn scale, read from the screen itself: its own fit, and any scale a page draws the
    // whole component at.
    const scale = rect.width / SCREEN.width || fit.current;
    return {
      x: (event.clientX - rect.left) / scale,
      y: (event.clientY - rect.top) / scale,
    };
  };

  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.button !== 0 || (event.target as HTMLElement).closest('button')) return;
    const { x, y } = pointOf(event);
    if (!grab(x, y, 'reader')) return;
    event.currentTarget.setPointerCapture(event.pointerId);
  };
  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (drag.current?.owner !== 'reader') return;
    const { x, y } = pointOf(event);
    move(x, y);
  };
  const onPointerUp = () => {
    if (drag.current?.owner === 'reader') release();
  };
  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    if (event.key === 'ArrowLeft') {
      event.preventDefault();
      decide('pass');
    } else if (event.key === 'ArrowRight') {
      event.preventDefault();
      decide('like');
    }
  };

  // The demo: a finger teases the card each way, passes, taps the heart, and likes.
  const demoGrab = useEffectEvent((x: number, y: number) => grab(x, y, 'demo'));
  const demoMove = useEffectEvent((x: number, y: number) => {
    if (drag.current?.owner === 'demo') move(x, y);
  });
  const demoRelease = useEffectEvent(() => {
    if (drag.current?.owner === 'demo') release();
  });
  const demoTap = useEffectEvent(() => decide('like'));
  const demoQuit = useEffectEvent(() => {
    if (drag.current?.owner === 'demo') {
      drag.current = null;
      springBack(0, 0);
    }
  });
  useEffect(() => {
    const dot = finger.current;
    if (!autoplay || defaultOffset !== undefined || !playing || !dot) return;
    let alive = true;
    let timer = 0;
    let raf = 0;
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        timer = window.setTimeout(() => (alive ? resolve() : reject()), ms);
      });
    let spot = { x: 250, y: 420 };
    const place = () => {
      dot.style.transform = `translate(${spot.x.toFixed(1)}px, ${spot.y.toFixed(1)}px)`;
    };
    const glide = (x: number, y: number, ms: number, pressed: boolean) =>
      new Promise<void>((resolve, reject) => {
        const from = spot;
        const began = performance.now();
        const step = (now: number) => {
          if (!alive) return reject();
          const t = Math.min(1, (now - began) / ms);
          const e = easeInOut(t);
          spot = { x: from.x + (x - from.x) * e, y: from.y + (y - from.y) * e };
          place();
          if (pressed) demoMove(spot.x, spot.y);
          if (t < 1) raf = requestAnimationFrame(step);
          else resolve();
        };
        raf = requestAnimationFrame(step);
      });
    const press = async (x: number, y: number, hold = true) => {
      spot = { x, y };
      place();
      dot.dataset.state = 'down';
      await wait(140);
      if (hold) demoGrab(x, y);
    };
    const lift = async () => {
      demoRelease();
      dot.dataset.state = 'up';
      await wait(160);
      dot.dataset.state = 'off';
    };
    const swipe = async (from: [number, number], to: [number, number], ms: number) => {
      await press(...from);
      await glide(to[0], to[1], ms, true);
    };
    (async () => {
      for (;;) {
        await wait(1100);
        // A tease to the left, and back.
        await swipe([250, 420], [104, 430], 700);
        await wait(320);
        await glide(232, 424, 480, true);
        await lift();
        await wait(700);
        // A tease to the right, and back.
        await swipe([150, 420], [296, 414], 650);
        await wait(320);
        await glide(166, 420, 460, true);
        await lift();
        await wait(800);
        // A pass: far enough to go.
        await swipe([250, 420], [24, 440], 560);
        await lift();
        await wait(1500);
        // A tap on the heart.
        await press(BADGE.like.x, BADGE.like.y, false);
        demoTap();
        await lift();
        await wait(1600);
        // A quick flick to the right.
        await swipe([140, 420], [300, 412], 300);
        await lift();
        await wait(1500);
      }
    })().catch(() => {});
    return () => {
      alive = false;
      window.clearTimeout(timer);
      cancelAnimationFrame(raf);
      dot.dataset.state = 'off';
      demoQuit();
    };
  }, [autoplay, defaultOffset, playing]);

  const roles = [2, 1, 0] as const;
  const tabs = [
    { glyph: 'cards', label: 'Discover' },
    { glyph: 'chat', label: 'Messages' },
    { glyph: 'bag', label: 'Shop' },
    { glyph: 'person', label: 'Profile' },
  ] as const;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.frame}>
        <div ref={screen} className={styles.screen} data-part="phone">
          <div className={styles.status} data-part="status-bar" aria-hidden="true">
            <span className={styles.time}>9:41</span>
            <span className={styles.signals}>
              <Icon glyph="signal" className={styles.signal} />
              <Icon glyph="wifi" className={styles.wifi} />
              <Icon glyph="battery" className={styles.battery} />
            </span>
          </div>
          <header className={styles.header} data-part="header">
            <button type="button" className={styles.tool} aria-label="Filters">
              <Icon glyph="filter" className={styles.toolIcon} />
            </button>
            <button type="button" className={styles.tool} aria-label="Notifications, 1 new">
              <Icon glyph="bell" className={styles.toolIcon} />
              <span className={styles.dot} />
            </button>
          </header>
          <div className={styles.deck} data-part="deck">
            {roles.map((offset) => {
              const profile = at(offset);
              const role = offset === 0 ? 'top' : offset === 1 ? 'next' : 'third';
              const name = nameOf(profile);
              return (
                <div
                  key={index + offset}
                  ref={offset === 0 ? top : offset === 1 ? next : undefined}
                  className={styles.card}
                  data-part="card"
                  data-role={role}
                  role={offset === 0 ? 'group' : undefined}
                  aria-roledescription={offset === 0 ? 'profile card' : undefined}
                  aria-label={
                    offset === 0
                      ? `${name}, ${profile.detail}. Left and right arrow keys pass or like.`
                      : undefined
                  }
                  aria-hidden={offset === 0 ? undefined : true}
                  inert={offset !== 0}
                  tabIndex={offset === 0 ? 0 : undefined}
                  onPointerDown={offset === 0 ? onPointerDown : undefined}
                  onPointerMove={offset === 0 ? onPointerMove : undefined}
                  onPointerUp={offset === 0 ? onPointerUp : undefined}
                  onPointerCancel={offset === 0 ? onPointerUp : undefined}
                  onKeyDown={offset === 0 ? onKeyDown : undefined}
                >
                  <Portrait scene={profile.scene} src={profile.imageSrc} className={styles.art} />
                  <div className={styles.content} data-part="content">
                    <div className={styles.tint} />
                    <div className={styles.wash} data-part="wash" />
                    <div className={styles.info} data-part="info">
                      <h3 className={styles.name}>
                        <span>{profile.first}</span> <span>{profile.last}</span>
                      </h3>
                      <ul className={styles.interests} aria-label="Interests">
                        {profile.interests.slice(0, 3).map((interest) => (
                          <li key={interest} aria-label={interest}>
                            <InterestIcon kind={interest} className={styles.interest} />
                          </li>
                        ))}
                      </ul>
                      <p className={styles.detail}>{profile.detail}</p>
                    </div>
                    <button
                      type="button"
                      className={styles.action}
                      data-part="pass"
                      aria-label={`Pass on ${name}`}
                      onClick={() => decide('pass')}
                    >
                      <Icon glyph="close" className={styles.actionIcon} />
                    </button>
                    <button
                      type="button"
                      className={styles.action}
                      data-part="like"
                      aria-label={`Like ${name}`}
                      onClick={() => decide('like')}
                    >
                      <Icon glyph="heart" className={styles.actionIcon} />
                    </button>
                  </div>
                </div>
              );
            })}
            <span
              ref={passBadge}
              className={styles.badge}
              data-part="pass-badge"
              aria-hidden="true"
            >
              <Icon glyph="close" className={styles.actionIcon} />
            </span>
            <span
              ref={likeBadge}
              className={styles.badge}
              data-part="like-badge"
              aria-hidden="true"
            >
              <Icon glyph="heart" className={styles.actionIcon} />
            </span>
          </div>
          <nav className={styles.tabs} aria-label="Main" data-part="tab-bar">
            {tabs.map((item, i) => (
              <button
                key={item.label}
                type="button"
                className={styles.tab}
                aria-label={item.label}
                aria-current={tab === i ? 'page' : undefined}
                onClick={() => setTab(i)}
              >
                <Icon glyph={item.glyph} className={styles.tabIcon} />
              </button>
            ))}
          </nav>
          <span className={styles.home} data-part="home-indicator" aria-hidden="true" />
          <span ref={finger} className={styles.touch} data-state="off" aria-hidden="true" />
        </div>
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### swipe-deck/swipe-deck.module.css

```css
/* Swipe deck (swipe-deck.tsx): a quiet stage with a phone in its middle. The phone's screen is
   drawn at 390 × 844 points and scaled down as a whole (--fit, set from the stage's size) when
   the stage is narrower or shorter. Every measure inside it is in the screen's points.

   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). At night the stage and the app go dark; the
   cards, their art and the brand's purple stay as they are.

   Memory and layout. The stage is one layout boundary. What moves is a transform written by the
   script on its own layer: the top card, the card behind it, the two badges and the demo's
   finger. Nothing inside an SVG ever changes. */

.root {
  /* The stage and its edge. */
  --stage: #f0f0f5;
  --line: rgb(0 0 0 / 0.04);
  /* The screen, its words and icons, the idle tabs, and its faint edge. */
  --screen: #fcfcfd;
  --ink: #111113;
  --idle: #babbbc;
  --screen-edge: rgb(0 0 0 / 0.03);
  /* The pass button and its cross. */
  --pass: #ece9ef;
  --pass-ink: #141416;
  --pass-badge: #f4f2f6;
  /* The brand's purple: the like button, the wash, the gradient, the active tab, the bell's dot. */
  --brand: #4d09ff;
  --brand-ink: #fff;
  /* The card's colour while its art loads, and the shade between the cards. */
  --card: #2a2530;
  --card-shadow: rgb(20 10 40 / 0.1);
  --focus: #0a84ff;
  /* The demo's finger. */
  --touch: rgb(20 18 30 / 0.2);
  --touch-ring: rgb(255 255 255 / 0.85);
  --fit: 0.75;
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  aspect-ratio: 6 / 5;
  min-height: 660px;
  max-height: 920px;
  overflow: hidden;
  overflow: clip;
  contain: layout;
  border-radius: 18px;
  background: var(--stage);
  box-shadow: inset 0 0 0 1px var(--line);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#f0f0f5, #0e0e11);
    --line: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.06));
    --screen: light-dark(#fcfcfd, #0a0a0c);
    --ink: light-dark(#111113, #f3f3f5);
    --idle: light-dark(#babbbc, #56565c);
    --screen-edge: light-dark(rgb(0 0 0 / 0.03), rgb(255 255 255 / 0.09));
    --pass: light-dark(#ece9ef, #2b2832);
    --pass-ink: light-dark(#141416, #f2f0f6);
    --pass-badge: light-dark(#f4f2f6, #34303c);
    --card-shadow: light-dark(rgb(20 10 40 / 0.1), rgb(0 0 0 / 0.5));
  }
}
.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's box at its drawn size, and the screen inside it at its own. */
.frame {
  position: relative;
  width: calc(390px * var(--fit));
  height: calc(844px * var(--fit));
}
.screen {
  position: absolute;
  top: 0;
  left: 0;
  width: 390px;
  height: 844px;
  overflow: hidden;
  overflow: clip;
  border-radius: 42px;
  background: var(--screen);
  box-shadow: 0 0 0 1px var(--screen-edge);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  transform: scale(var(--fit));
  transform-origin: 0 0;
  user-select: none;
  -webkit-user-select: none;
}

/* The status bar. */
.status {
  position: absolute;
  inset: 0 0 auto;
  height: 48px;
}
.time {
  position: absolute;
  top: 14px;
  left: 43px;
  font-size: 17px;
  font-weight: 600;
  line-height: 20px;
  letter-spacing: -0.3px;
}
.signals {
  position: absolute;
  inset: 0;
}
.signal,
.wifi,
.battery {
  position: absolute;
  display: block;
  color: var(--ink);
}
.signal {
  top: 18px;
  left: 287px;
  width: 18px;
  height: 12px;
}
.wifi {
  top: 18px;
  left: 314px;
  width: 16px;
  height: 12px;
}
.battery {
  top: 17.5px;
  left: 340px;
  width: 27px;
  height: 13px;
}

/* The header: filters on the left, notifications on the right. */
.header {
  position: absolute;
  inset: 56px 0 auto;
  height: 44px;
}
.tool {
  position: absolute;
  top: 0;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 12px;
  background: none;
  color: var(--ink);
  cursor: pointer;
}
.tool:first-child {
  left: 5px;
}
.tool:last-child {
  left: 343px;
}
.tool:active {
  opacity: 0.6;
}
.toolIcon {
  display: block;
  width: 24px;
  height: 24px;
}
.tool:last-child .toolIcon {
  width: 26px;
  height: 26px;
}
.dot {
  position: absolute;
  top: 11px;
  left: 26px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--brand);
  box-shadow: 0 0 0 1.5px var(--screen);
}

/* The deck covers the screen, so the badges share its points. */
.deck {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.card {
  position: absolute;
  top: 114px;
  left: 12px;
  width: 366px;
  height: 622px;
  overflow: hidden;
  overflow: clip;
  border-radius: 24px;
  background: var(--card);
  transform-origin: 50% 50%;
  will-change: transform;
  pointer-events: auto;
  touch-action: pan-y;
  cursor: grab;
}
.card:active {
  cursor: grabbing;
}
.card[data-role='next'],
.card[data-role='third'] {
  transform: translateY(11px) scale(0.84, 0.94);
  transform-origin: 50% 100%;
  pointer-events: none;
}
.card[data-role='top'] {
  box-shadow: 0 1px 2px var(--card-shadow);
}
.card:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
}
.art {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

/* What only the top card shows: the gradient, the wash, the name and the buttons. The card
   behind shows its art alone; they fade in as it comes to the top. */
.content {
  position: absolute;
  inset: 0;
  opacity: 0;
}
.card[data-role='top'] .content {
  opacity: 1;
}
.card[data-promote] .content {
  opacity: 1;
  transition: opacity 260ms ease;
}
.tint {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(
      80% 44% at 4% 100%,
      rgb(77 9 255 / 0.95) 0%,
      rgb(77 9 255 / 0.7) 45%,
      rgb(77 9 255 / 0) 100%
    ),
    linear-gradient(
      to right,
      rgb(77 9 255 / 0.9) 0%,
      rgb(77 9 255 / 0.62) 14%,
      rgb(77 9 255 / 0.22) 30%,
      rgb(77 9 255 / 0) 46%
    );
}
.wash {
  position: absolute;
  inset: 0;
  background: #000;
  opacity: 0;
}
.card[data-side='like'] .wash {
  background: var(--brand);
}
.info {
  position: absolute;
  top: 340px;
  left: 28px;
  right: 120px;
  color: #fff;
}
.name {
  margin: 0;
  /* A tall, tight condensed grotesque, from the faces most systems carry. */
  font-family: Impact, Haettenschweiler, 'Avenir Next Condensed', 'Arial Narrow', sans-serif;
  font-size: 42px;
  font-weight: 400;
  font-stretch: condensed;
  line-height: 42px;
  letter-spacing: 0;
  text-transform: uppercase;
}
.name > span {
  display: block;
}
.interests {
  display: flex;
  gap: 18px;
  margin: 18px 0 0;
  padding: 0;
  list-style: none;
}
.interests > li {
  display: block;
}
.interest {
  display: block;
  width: 24px;
  height: 24px;
  color: #fff;
}
.detail {
  margin: 12px 0 0;
  font-size: 14px;
  font-weight: 500;
  line-height: 16px;
  color: rgb(255 255 255 / 0.72);
}

/* The two buttons on the card, and the badges they grow into. */
.action,
.badge {
  position: absolute;
  display: grid;
  place-items: center;
  width: 59px;
  height: 59px;
  padding: 0;
  border: 0;
  border-radius: 22px;
}
.action {
  bottom: 25px;
  cursor: pointer;
  transition: scale 120ms ease;
}
.action:active {
  scale: 0.92;
}
.action:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 2px;
}
.action[data-part='pass'] {
  left: 28px;
  background: var(--pass);
  color: var(--pass-ink);
}
.action[data-part='like'] {
  right: 25px;
  background: var(--brand);
  color: var(--brand-ink);
}
.badge {
  top: 652px;
  opacity: 0;
  pointer-events: none;
  will-change: transform;
}
.badge[data-part='pass-badge'] {
  left: 40px;
  background: var(--pass-badge);
  color: var(--pass-ink);
}
.badge[data-part='like-badge'] {
  left: 294px;
  background: var(--brand);
  color: var(--brand-ink);
}
.actionIcon {
  display: block;
  width: 34px;
  height: 34px;
}
.actionIcon path[stroke] {
  stroke-width: 1.65px;
}
[data-part='like'] > .actionIcon,
[data-part='like-badge'] > .actionIcon {
  width: 29px;
  height: 29px;
}

/* The tab bar and the home indicator. */
.tabs {
  position: absolute;
  inset: 768px 0 auto;
  height: 48px;
}
.tab {
  position: absolute;
  top: 0;
  display: grid;
  place-items: center;
  width: 56px;
  height: 48px;
  padding: 0;
  border: 0;
  border-radius: 12px;
  background: none;
  color: var(--idle);
  cursor: pointer;
  transition: color 160ms ease;
}
.tab:nth-child(1) {
  left: 35px;
}
.tab:nth-child(2) {
  left: 123px;
}
.tab:nth-child(3) {
  left: 213px;
}
.tab:nth-child(4) {
  left: 301px;
}
.tab[aria-current='page'] {
  color: var(--brand);
}
.tab:focus-visible,
.tool:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: -2px;
}
.tabIcon {
  display: block;
  width: 31px;
  height: 31px;
}
.home {
  position: absolute;
  top: 834px;
  left: 125px;
  width: 140px;
  height: 5px;
  border-radius: 3px;
  background: var(--ink);
}

/* The demo's finger: a soft disc where it presses. */
.touch {
  position: absolute;
  top: -22px;
  left: -22px;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--touch);
  box-shadow: inset 0 0 0 2px var(--touch-ring);
  opacity: 0;
  pointer-events: none;
  will-change: transform;
  transition:
    opacity 150ms ease,
    scale 150ms ease;
}
.touch[data-state='down'] {
  opacity: 1;
  scale: 0.9;
}
.touch[data-state='up'] {
  opacity: 0;
  scale: 1.15;
}

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

@media (prefers-reduced-motion: reduce) {
  .card[data-promote] .content,
  .action,
  .tab,
  .touch {
    transition: none;
  }
}
```

### swipe-deck/art.tsx

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

'use client';

/* Full-bleed photographic portraits; the profile text, gradients and controls are
 * composed separately in React. Every default portrait depicts an invented adult. */

export type Scene = 'dusk' | 'window' | 'garden' | 'shore';
export type Interest = 'bike' | 'swim' | 'hike' | 'music' | 'camera' | 'book';

/** A photo fills the card without being stretched; callers can supply their own image. */
export function Portrait({
  scene,
  src,
  className,
}: {
  scene: Scene;
  src?: string;
  className?: string;
}) {
  return (
    <img
      className={className}
      src={src ?? `/experiments/swipe-deck/${scene}-photo-v1.webp`}
      width={600}
      height={1000}
      alt=""
      draggable={false}
      decoding="async"
    />
  );
}

/** The small icons of a profile's interests, white on the card. */
export function InterestIcon({ kind, className }: { kind: Interest; className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
      {kind === 'bike' && (
        <g fill="currentColor">
          <circle cx="5.5" cy="17.5" r="3.8" />
          <circle cx="18.5" cy="17.5" r="3.8" />
          <circle cx="15.5" cy="4.2" r="2.2" />
          <path d="M8.2 10.4l4.6-3.4 2.2 3.2h3.2v2h-4.3l-1.4-2-2 1.6 2.2 2.1V19h-2v-4.2l-3-2.8z" />
        </g>
      )}
      {kind === 'swim' && (
        <g fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round">
          <path d="M2.5 15.5c1.6 0 1.6 1.4 3.2 1.4s1.6-1.4 3.2-1.4 1.6 1.4 3.2 1.4 1.6-1.4 3.2-1.4 1.6 1.4 3.2 1.4 1.6-1.4 3-1.4" />
          <path d="M2.5 20c1.6 0 1.6 1.4 3.2 1.4S7.3 20 8.9 20s1.6 1.4 3.2 1.4S13.7 20 15.3 20s1.6 1.4 3.2 1.4S20.1 20 21.5 20" />
          <path d="M7 12.5l5-5.5 4 3.8" />
          <circle cx="17.6" cy="6" r="1.6" fill="currentColor" stroke="none" />
        </g>
      )}
      {kind === 'hike' && (
        <path
          fill="currentColor"
          d="M1.5 20.5L9 8l3.6 6 2.4-3.6 7.5 10.1zM9 8l1.6 2.7-1.6 1-1.6-1z"
        />
      )}
      {kind === 'music' && (
        <path
          fill="currentColor"
          d="M9 4.5l11-2.3v13.3a3.2 3.2 0 1 1-2-3V6.5l-7 1.5v9.8a3.2 3.2 0 1 1-2-3z"
        />
      )}
      {kind === 'camera' && (
        <path
          fill="currentColor"
          fillRule="evenodd"
          d="M3 7.5h4l1.8-2.5h6.4L17 7.5h4a1.5 1.5 0 0 1 1.5 1.5v9.5A1.5 1.5 0 0 1 21 20H3a1.5 1.5 0 0 1-1.5-1.5V9A1.5 1.5 0 0 1 3 7.5zm9 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"
        />
      )}
      {kind === 'book' && (
        <path
          fill="currentColor"
          d="M2 5c3.4-1 6.6-.6 9 1.2V20c-2.4-1.6-5.6-2-9-1.2zm11 1.2C15.4 4.4 18.6 4 22 5v13.8c-3.4-.8-6.6-.4-9 1.2z"
        />
      )}
    </svg>
  );
}

export type Glyph =
  | 'signal'
  | 'wifi'
  | 'battery'
  | 'filter'
  | 'bell'
  | 'close'
  | 'heart'
  | 'cards'
  | 'chat'
  | 'bag'
  | 'person';

/** The app's own icons: the status bar's, the header's, the card's buttons and the tab bar's. */
export function Icon({ glyph, className }: { glyph: Glyph; className?: string }) {
  const box: Partial<Record<Glyph, string>> = {
    signal: '0 0 18 12',
    wifi: '0 0 16 12',
    battery: '0 0 27 13',
  };
  return (
    <svg
      className={className}
      viewBox={box[glyph] ?? '0 0 24 24'}
      aria-hidden="true"
      focusable="false"
    >
      {glyph === 'signal' && (
        <g fill="currentColor">
          <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" />
        </g>
      )}
      {glyph === 'wifi' && (
        <path
          fill="currentColor"
          d="M8 2.4c2.4 0 4.6.9 6.3 2.5l1.2-1.3A10.6 10.6 0 0 0 8 .6 10.6 10.6 0 0 0 .5 3.6l1.2 1.3A9 9 0 0 1 8 2.4zm0 3.4c1.5 0 2.9.6 4 1.6l1.2-1.3A7.4 7.4 0 0 0 8 4 7.4 7.4 0 0 0 2.8 6.1L4 7.4c1.1-1 2.5-1.6 4-1.6zM8 9.2c.7 0 1.3.3 1.8.7L8 11.8 6.2 9.9c.5-.4 1.1-.7 1.8-.7z"
        />
      )}
      {glyph === 'battery' && (
        <g>
          <rect
            x="0.5"
            y="0.5"
            width="23"
            height="12"
            rx="3.8"
            fill="none"
            stroke="currentColor"
            opacity="0.36"
          />
          <rect x="2" y="2" width="20" height="9" rx="2.5" fill="currentColor" />
          <path
            d="M25 4.5v4c.8-.3 1.4-1.1 1.4-2s-.6-1.7-1.4-2z"
            fill="currentColor"
            opacity="0.4"
          />
        </g>
      )}
      {glyph === 'filter' && (
        <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
          <path d="M11 7.5h10M3 16.5h10" />
          <circle cx="7.5" cy="7.5" r="3" />
          <circle cx="16.5" cy="16.5" r="3" />
          <path d="M3 7.5h1.5M19.5 16.5H21" />
        </g>
      )}
      {glyph === 'bell' && (
        <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
          <path d="M6 17V11a6 6 0 0 1 9.4-4.9M18 11v6l1.6 1.6H4.4L6 17" />
          <path d="M9.8 21a2.4 2.4 0 0 0 4.4 0" strokeLinecap="round" />
        </g>
      )}
      {glyph === 'close' && (
        <path
          d="M6 6l12 12M18 6L6 18"
          fill="none"
          stroke="currentColor"
          strokeWidth="2.2"
          strokeLinecap="round"
        />
      )}
      {glyph === 'heart' && (
        <path
          fill="currentColor"
          d="M12 20.6c-.3 0-.6-.1-.9-.3C6.6 17 2.5 13.6 2.5 9.1 2.5 6.1 4.8 3.8 7.6 3.8c1.8 0 3.4.9 4.4 2.4 1-1.5 2.6-2.4 4.4-2.4 2.8 0 5.1 2.3 5.1 5.3 0 4.5-4.1 7.9-8.6 11.2-.3.2-.6.3-.9.3z"
        />
      )}
      {glyph === 'cards' && (
        <g fill="currentColor">
          <rect x="2.6" y="4.6" width="9" height="15.4" rx="1.6" transform="rotate(-9 7 12)" />
          <rect
            x="8.4"
            y="2.6"
            width="12.2"
            height="17"
            rx="2"
            transform="rotate(5 14.5 11)"
            style={{ stroke: 'var(--screen)', strokeWidth: 1.6 }}
          />
        </g>
      )}
      {glyph === 'chat' && (
        <path
          fill="currentColor"
          d="M12 3.2c5.2 0 9.5 3.4 9.5 7.7s-4.3 7.6-9.5 7.6c-1 0-2-.1-2.9-.4L4.3 20.8l1.3-4C3.7 15.4 2.5 13.3 2.5 10.9c0-4.3 4.3-7.7 9.5-7.7z"
        />
      )}
      {glyph === 'bag' && (
        <path
          fill="currentColor"
          fillRule="evenodd"
          d="M8 7.5V7a4 4 0 0 1 8 0v.5h2.6c.6 0 1 .4 1.1 1l.9 11.2c0 .7-.5 1.3-1.2 1.3H4.6c-.7 0-1.2-.6-1.2-1.3l.9-11.2c.1-.6.5-1 1.1-1zm1.8 0h4.4V7a2.2 2.2 0 0 0-4.4 0z"
        />
      )}
      {glyph === 'person' && (
        <path
          fill="currentColor"
          fillRule="evenodd"
          d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20zm0 4.2a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7zm0 8.8c-2.6 0-4.9 1.2-6 3a8.2 8.2 0 0 0 12 0c-1.1-1.8-3.4-3-6-3z"
        />
      )}
    </svg>
  );
}
```

### swipe-deck/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 };
}
```