# Status picker

> A row of quick statuses with one sliding tooltip: pick one and its icon arcs down into the status button.

Hover or focus along the row and a single tooltip glides from button to button, its width easing to each label. Pick a status and its icon flies down into the button below, which widens to fit the new label and offers a clear button. A sixth button pages through more statuses.

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

Category: Inputs.

## Anatomy

1. **Row**: A toolbar with one tab stop: the arrow keys, Home and End move along it.
2. **Tooltip**: One bubble for the whole row. It slides to the button under the pointer or the focus and eases its width to the label.
3. **Status**: A round toggle button with the status’s emoji. The one that is set gets an inked ring; pressing it again clears it.
4. **Paging button**: Shown when there are more than five statuses: three dots turn to a back arrow on the last page.
5. **Status button**: Shows the status that is set. Its width springs to fit the new label; pressing it points back to the row.
6. **Icon**: The status’s emoji, flown in from the row along an arc, or a dotted ring when nothing is set.
7. **Label**: The status’s words, or Set status. The old label lifts away as the new one rises.
8. **Clear**: Comes with a status and clears it; the focus then moves to the status button.

## When to use

- Setting a presence or availability status from a short, known list: in a meeting, out sick, on vacation.
- A handful of choices that read at a glance as icons, each with a short label shown on hover or focus.
- One status at a time that can be cleared, where the current one should stay visible as a labelled button.
- A profile menu, a chat header or a team sidebar, where the picker lives in a small, fixed spot.

## When not to use

- Free-text statuses or a custom message. Pair a text field with a small emoji menu instead.
- Several values at once (tags, filters). Use checkboxes or toggle chips.
- Long lists of options, or options without a clear icon. Use a select or a menu with words.
- Choices the reader must compare by their words: the labels only show one at a time, in a tooltip.
- Touch-first screens where nothing hovers: the labels never show, so the icons must speak for themselves.

## Installation

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

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

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

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

## Usage

```tsx
'use client';

import { useState } from 'react';
import { StatusPicker } from './status-picker';
import type { Status } from './statuses';

export default function ProfileStatus() {
  const [status, setStatus] = useState<Status | null>(null);
  return (
    <>
      <StatusPicker onChange={setStatus} autoplay={false} />
      <p>{status ? `${status.emoji} ${status.label}` : 'No status'}</p>
    </>
  );
}
```

## States

- **No status**: The button reads Set status beside a dotted ring, with no clear button.
- **Hovered**: The status under the pointer darkens and grows its emoji; the tooltip names it.
- **Status set**: An inked ring marks it in the row; the button shows its icon, its label and a clear button.
- **Second page**: The next five statuses; the paging button turns to a back arrow, labelled Back.
- **Long label**: The button grows to fit the words, however long; the row keeps its size.
- **Dark scheme**: `colorScheme="dark"` sets it whatever the page; the round buttons sit a step lighter than the row.

## How it works

- One tooltip serves the whole row. It slides to the hovered or focused button, eases its width to the label (measured as it comes up), cross-fades the labels with a touch of blur, and stays within the row’s ends.
- The tooltip moves inside a fixed-size, clipped track that is its own small layer. The bubble only repaints that layer as it slides, so a phone never rebuilds the layer on every frame.
- Picking a status runs FLIP on the status button: it is measured before and after, and its width springs between the two with the Web Animations API and a linear() spring. Meanwhile a copy of the emoji flies along an arc from the row, swelling and tilting, and the real icon takes over with a bounce.
- The previous icon and label stay in the button, invisible by default. Their exit animation starts from visible, so nothing has to clean them up.
- The sixth button pages five at a time: old icons blur out, new ones pop in from left to right.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop; at night the round buttons sit a step lighter than the row, and the emoji stay as they are.

## API reference

- **statuses** (`readonly Status[]`, default `STATUSES`): The statuses to pick from, five per page; the sixth button pages through them. Each has an id, an emoji and a label.
- **defaultStatus** (`string | null`, default `null`): The id of the status set at first.
- **defaultPage** (`number`): The page of statuses shown at first, 0 being the first five. Setting it turns the demo off.
- **defaultTip** (`number`): The button whose tooltip shows at first, as if hovered: 0 to 4 are the statuses, 5 the paging button. Setting it turns the demo off.
- **onChange** (`(status: Status | null) => void`): Called with the new status, or null when it is cleared.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer hovers along the row, picks a status, pages and clears it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- The row is a toolbar with one tab stop: the arrow keys, Home and End move along it, and Enter or Space picks. Statuses are toggle buttons (aria-pressed), and pressing the one that is set clears it.
- The tooltip appears on hover or keyboard focus, never after a tap. It is hidden from screen readers, since each button carries its own label, and Escape dismisses it.
- Changes are announced (“Status set: In a meeting.”, “Status cleared.”). When the clear button goes away, focus moves to the status button.
- Under reduced motion nothing flies or slides, and the demo does not run.

## Source

### status-picker/status-picker.tsx

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

'use client';

import {
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import { GhostPointer, runDemo } from './ghost-pointer';
import { STATUSES, type Status } from './statuses';
import { useAutoplay } from './use-autoplay';
import styles from './status-picker.module.css';

/*
 * Status picker: a row of quick statuses over the button that shows yours.
 *
 * - Hover or focus a status and its label shows in a tooltip above it. The row has one tooltip:
 *   moving to the next status slides it over, its width eases to the new label and the two
 *   labels cross-fade with a touch of blur.
 * - Pick one and its icon flies into the status button below along a small arc, while the
 *   button's label changes and its width eases to fit (FLIP: the button is measured before and
 *   after the change, and animated from one width to the other). A clear button comes with it.
 * - The sixth button pages through more statuses, five at a time.
 * - While on screen and untouched, a ghost pointer plays with it. Under reduced motion every
 *   change is immediate.
 * - 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.
 */

/** Statuses per page; the button after them pages through the rest. */
const PAGE = 5;

/**
 * A spring that overshoots a little and settles, as a CSS linear() easing. The stylesheet's
 * transitions use it too, through the --spring custom property.
 */
const SPRING =
  'linear(0, 0.054, 0.18, 0.336, 0.496, 0.641, 0.764, 0.861, 0.932, 0.982, 1.014, 1.031, 1.038, 1.039, 1.036, 1.03, 1.024, 1.017, 1.012, 1.008, 1.004, 1.002, 1, 0.999, 0.999, 0.998, 1)';
/** A livelier one, for the icon landing. */
const BOUNCE =
  'linear(0, 0.097, 0.318, 0.578, 0.814, 0.993, 1.106, 1.156, 1.16, 1.133, 1.093, 1.051, 1.016, 0.992, 0.978, 0.973, 0.975, 0.981, 0.988, 0.994, 0.999, 1.002, 1.004, 1.004, 1.004, 1.003, 1)';

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Browsers without linear() easings get a close cubic curve. */
const easing = (curve: string) =>
  CSS.supports('transition-timing-function', curve) ? curve : 'cubic-bezier(0.3, 1.3, 0.5, 1)';

/** What the status button shows: a status, or the empty state (no emoji). */
type Face = { key: string; emoji: string | null; label: string };

/** The tooltip: the button it is over, and whether it shows. Each `round` mounts a new bubble. */
type Tip = { index: number; shown: boolean; round: number };

/** A change waiting for the DOM: where things were, to animate from there. */
type Flip = { width: number; from: DOMRect | null; refocus: boolean };

export function StatusPicker({
  statuses = STATUSES,
  defaultStatus = null,
  defaultPage,
  defaultTip,
  onChange,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The statuses to pick from, five per page; the sixth button pages through them. */
  statuses?: readonly Status[];
  /** The id of the status set at first, if any. */
  defaultStatus?: string | null;
  /** The page of statuses shown at first (0 is the first five). Setting it turns the demo off. */
  defaultPage?: number;
  /**
   * The row's button whose tooltip shows at first, as if hovered (0 to 4 are the statuses, 5 the
   * paging button). Setting it turns the demo off.
   */
  defaultTip?: number;
  /** Called with the new status, or null when it is cleared. */
  onChange?: (status: Status | null) => void;
  /** While on screen and untouched, a demo pointer plays with it on its own. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [current, setCurrent] = useState<Status | null>(
    () => statuses.find((status) => status.id === defaultStatus) ?? null,
  );
  /** Counts the changes, to key the button's faces. */
  const [version, setVersion] = useState(0);
  /**
   * The face before the last change. It stays in the button, invisible: its exit animation plays
   * as it mounts, and starts from visible, so nothing has to clear it (or wait for it).
   */
  const [previous, setPrevious] = useState<(Face & { hidden: boolean }) | null>(null);
  /** The face whose icon flies in from the row, and whether it has landed. */
  const [arrival, setArrival] = useState<{ key: string; landed: boolean } | null>(null);
  const [page, setPage] = useState(() =>
    Math.max(0, Math.min(defaultPage ?? 0, Math.ceil(statuses.length / PAGE) - 1)),
  );
  /** The page before the last turn, kept invisible the same way. */
  const [previousPage, setPreviousPage] = useState<number | null>(null);
  const [tip, setTip] = useState<Tip | null>(() =>
    defaultTip === undefined ? null : { index: defaultTip, shown: true, round: 1 },
  );
  /** Opened in a given state: the demo stays off, so the state holds. */
  const demo = autoplay && defaultPage === undefined && defaultTip === undefined;
  /** The row's one tab stop (arrow keys move it). */
  const [focusIndex, setFocusIndex] = useState(0);
  const [message, setMessage] = useState('');

  const buttons = useRef<(HTMLButtonElement | null)[]>([]);
  const tipBox = useRef<HTMLSpanElement>(null);
  const tipLabels = useRef<(HTMLSpanElement | null)[]>([]);
  const chip = useRef<HTMLDivElement>(null);
  const main = useRef<HTMLButtonElement>(null);
  const clearButton = useRef<HTMLButtonElement>(null);
  const icon = useRef<HTMLSpanElement>(null);
  const flyers = useRef<HTMLSpanElement>(null);
  const pointer = useRef<SVGSVGElement>(null);
  const flip = useRef<Flip | null>(null);
  const flight = useRef<Animation | null>(null);
  const stopDemo = useRef<(() => void) | null>(null);
  /** The tooltip is the demo's: it goes when the demo stops. */
  const demoTip = useRef(false);

  const pages = Math.ceil(statuses.length / PAGE);
  const shown = statuses.slice(page * PAGE, page * PAGE + PAGE);
  const lastPage = page === pages - 1;
  /** The paging button's place in the row, after the statuses. */
  const more = shown.length;
  /** The row's tab stop, kept on a button that exists after a page turn. */
  const tabStop = Math.min(focusIndex, pages > 1 ? more : more - 1);
  const face: Face = current
    ? { key: `${current.id}-${version}`, emoji: current.emoji, label: current.label }
    : { key: `none-${version}`, emoji: null, label: 'Set status' };
  /** The tooltip's text over each button of the row. */
  const tipTexts = [
    ...shown.map((status) => status.label),
    ...(pages > 1 ? [lastPage ? 'Back' : 'More'] : []),
  ];

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

  const showTip = (index: number, byDemo = false) => {
    demoTip.current = byDemo;
    setTip((now) =>
      now?.shown
        ? now.index === index
          ? now
          : { ...now, index }
        : { index, shown: true, round: (now?.round ?? 0) + 1 },
    );
  };
  const hideTip = () => setTip((now) => (now?.shown ? { ...now, shown: false } : now));

  /** Sets the status (or clears it, with null); the icon flies in from `source`, if given. */
  const change = (next: Status | null, source: HTMLElement | null) => {
    const animate = !reducedMotion();
    const nextKey = `${next?.id ?? 'none'}-${version + 1}`;
    flip.current = {
      width: animate ? (chip.current?.offsetWidth ?? 0) : 0,
      from: animate
        ? (source?.querySelector('[data-emoji]')?.getBoundingClientRect() ?? null)
        : null,
      // The clear button leaves with the status: the focus goes to the status button.
      refocus: !next && document.activeElement === clearButton.current,
    };
    // An icon still in flight never showed: it leaves unseen.
    setPrevious({ ...face, hidden: arrival?.key === face.key && !arrival.landed });
    setArrival(animate && next && source ? { key: nextKey, landed: false } : null);
    setCurrent(next);
    setVersion(version + 1);
    onChange?.(next);
    announce(next ? `Status set: ${next.label}.` : 'Status cleared.');
  };

  const choose = (index: number) => {
    const status = shown[index];
    if (!status) return;
    // Pressing the status that is set clears it.
    change(current?.id === status.id ? null : status, buttons.current[index]);
  };

  const clear = () => {
    if (current) change(null, null);
  };

  const turnPage = () => {
    const next = (page + 1) % pages;
    setPreviousPage(page);
    setPage(next);
    announce(next === 0 ? 'First statuses.' : 'More statuses.');
  };

  /** The status button points to the row: the focus goes there, and the row bounces. */
  const pointToRow = () => {
    const index = current ? shown.findIndex((status) => status.id === current.id) : -1;
    buttons.current[Math.max(0, index)]?.focus();
    if (reducedMotion()) return;
    buttons.current.forEach((button, i) =>
      button?.animate(
        [{ transform: 'none' }, { transform: 'translateY(-0.22em)' }, { transform: 'none' }],
        { duration: 380, delay: i * 45, easing: 'ease-in-out' },
      ),
    );
  };

  /** Flies a copy of the new icon from `from` to `to` (boxes on screen), then calls `land`. */
  const fly = (emoji: string, from: DOMRect, to: DOMRect, land: () => void) => {
    const stage = root.current;
    const layer = flyers.current;
    if (!stage || !layer) return land();
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const centre = (box: DOMRect) => ({
      x: (box.left + box.width / 2 - frame.left) / scale,
      y: (box.top + box.height / 2 - frame.top) / scale,
      size: box.height / scale,
    });
    const a = centre(from);
    const b = centre(to);
    const flyer = document.createElement('span');
    flyer.className = styles.flyer;
    flyer.textContent = emoji;
    flyer.style.width = flyer.style.height = flyer.style.fontSize = `${a.size}px`;
    layer.append(flyer);
    // Up a little first, then down into the button: a quadratic curve peaking above the start.
    const peak = { x: a.x + (b.x - a.x) * 0.3, y: Math.min(a.y, b.y) - a.size * 1.2 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 16; i += 1) {
      const t = i / 16;
      const u = 1 - t;
      const x = u * u * a.x + 2 * u * t * peak.x + t * t * b.x;
      const y = u * u * a.y + 2 * u * t * peak.y + t * t * b.y;
      // It swells mid-air, tilts, and lands at the size of the button's icon.
      const size = (1 + 0.28 * Math.sin(Math.PI * t)) * (1 + (b.size / a.size - 1) * t);
      const tilt = -12 * Math.sin(Math.PI * t);
      keyframes.push({
        transform: `translate(${x - a.size / 2}px, ${y - a.size / 2}px) rotate(${tilt}deg) scale(${size})`,
      });
    }
    flight.current?.cancel();
    const animation = flyer.animate(keyframes, {
      duration: 560,
      easing: 'cubic-bezier(0.35, 0, 0.2, 1)',
    });
    flight.current = animation;
    animation.finished.then(
      () => {
        flyer.remove();
        land();
      },
      () => flyer.remove(),
    );
  };

  // After a change: the button eases from its old width to the new one, and the icon flies in.
  useLayoutEffect(() => {
    const pending = flip.current;
    flip.current = null;
    const box = chip.current;
    if (!pending || !box) return;
    if (pending.refocus) main.current?.focus();
    if (!pending.width) return;
    const width = box.offsetWidth;
    // Where the icon lands, read before the width animation changes the layout.
    const target = icon.current;
    const to = target?.getBoundingClientRect();
    if (Math.abs(width - pending.width) > 0.5) {
      box.animate([{ width: `${pending.width}px` }, { width: `${width}px` }], {
        duration: 560,
        easing: easing(SPRING),
      });
    }
    if (!pending.from || !target || !to || !current) return;
    const key = `${current.id}-${version}`;
    fly(current.emoji, pending.from, to, () => {
      setArrival((now) => (now?.key === key ? { key, landed: true } : now));
      target.animate([{ transform: 'scale(1.4)' }, { transform: 'none' }], {
        duration: 560,
        easing: easing(BOUNCE),
      });
      box.animate([{ scale: 1 }, { scale: 1.035 }, { scale: 1 }], {
        duration: 320,
        easing: 'ease-out',
      });
    });
    // `fly` reads refs only; the change is all this depends on.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [version]);

  // The tooltip's width follows its label (labels are measured as they come up).
  useLayoutEffect(() => {
    if (!tip) return;
    const label = tipLabels.current[tip.index];
    tipBox.current?.style.setProperty('--w', `${label?.offsetWidth ?? 0}px`);
  }, [tip, page]);

  // Nothing keeps flying once the picker is gone.
  useEffect(() => () => flight.current?.cancel(), []);

  // The demo calls the handlers of the latest render.
  const actions = useRef({ showTip, hideTip, choose, turnPage, clear, page, more });
  useLayoutEffect(() => {
    actions.current = { showTip, hideTip, choose, turnPage, clear, page, more };
  });

  // The demo: a ghost pointer runs along the row, picks a status, picks another, pages through
  // the statuses, and clears what it set; again while on screen and untouched.
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    if (!demo || !playing || !stage || !ghost) return;
    const stop = runDemo(stage, ghost, async ({ wait, moveTo, press }) => {
      const act = () => actions.current;
      /** Hovers the row's button `i` for `ms`. */
      const visit = async (i: number, ms: number) => {
        await moveTo(buttons.current[i]);
        act().showTip(i, true);
        await wait(ms);
      };
      const pick = (i: number) => press(() => act().choose(i));
      await wait(500);
      for (let round = 0; ; round += 1) {
        // The first page first, should the reader have left the second one up.
        if (act().page !== 0) {
          await visit(act().more, 300);
          await press(() => act().turnPage());
          await wait(500);
        }
        if (round % 2 === 0) {
          await visit(0, 420);
          await visit(1, 340);
          await visit(2, 620);
          await pick(2);
          await wait(1500);
          await visit(3, 380);
          await visit(4, 620);
          await pick(4);
        } else {
          await visit(act().more, 560);
          await press(() => act().turnPage());
          await wait(700);
          await visit(0, 420);
          await visit(1, 620);
          await pick(1);
          await wait(1400);
          await visit(act().more, 480);
          await press(() => act().turnPage());
        }
        await wait(1000);
        act().hideTip();
        await moveTo(clearButton.current);
        await wait(350);
        await press(() => act().clear());
        await wait(1500);
      }
    });
    stopDemo.current = () => {
      stop();
      if (demoTip.current) actions.current.hideTip();
    };
    return stop;
  }, [demo, playing, root]);

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

  const onRowKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === 'Escape') {
      hideTip();
      return;
    }
    const count = tipTexts.length;
    const moves: Record<string, number> = {
      ArrowRight: tabStop + 1,
      ArrowLeft: tabStop - 1,
      Home: 0,
      End: count - 1,
    };
    if (!(event.key in moves)) return;
    event.preventDefault();
    buttons.current[(moves[event.key] + count) % count]?.focus();
  };

  const onRowBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget)) hideTip();
  };

  /** Mouse and pen only: a tooltip under a finger would only flash. */
  const hoverable = (event: PointerEvent) => event.pointerType !== 'touch';
  /** Focus from the keyboard shows the tooltip; a click or a tap does not leave it behind. */
  const keyboardFocus = (event: FocusEvent<HTMLElement>) =>
    event.currentTarget.matches(':focus-visible');

  const hovered = tip?.shown ? tip.index : -1;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      style={{ '--spring': SPRING } as CSSProperties}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
    >
      <div className={styles.scene}>
        <div className={styles.picker} style={{ '--count': tipTexts.length } as CSSProperties}>
          {tip && (
            <span className={styles.tips} data-shown={tip.shown || undefined} aria-hidden="true">
              <span
                key={tip.round}
                ref={tipBox}
                className={styles.tip}
                data-part="tip"
                style={{ '--i': tip.index } as CSSProperties}
              >
                {tipTexts.map((text, i) => (
                  <span
                    key={i}
                    ref={(element) => {
                      tipLabels.current[i] = element;
                    }}
                    className={styles.tipLabel}
                    data-active={i === tip.index || undefined}
                  >
                    {text}
                  </span>
                ))}
              </span>
            </span>
          )}

          <div
            role="toolbar"
            aria-label="Quick statuses"
            data-part="row"
            className={styles.row}
            onKeyDown={onRowKeyDown}
            onBlur={onRowBlur}
            onPointerMove={(event) => {
              if (hoverable(event)) readerActs();
            }}
            onPointerLeave={(event) => {
              if (hoverable(event)) hideTip();
            }}
          >
            {shown.map((status, i) => {
              const out = previousPage === null ? undefined : statuses[previousPage * PAGE + i];
              return (
                <button
                  key={i}
                  ref={(element) => {
                    buttons.current[i] = element;
                  }}
                  type="button"
                  className={styles.status}
                  data-part="status"
                  tabIndex={tabStop === i ? 0 : -1}
                  aria-label={status.label}
                  aria-pressed={current?.id === status.id}
                  data-hover={hovered === i || undefined}
                  onPointerEnter={(event) => {
                    if (hoverable(event)) showTip(i);
                  }}
                  onFocus={(event) => {
                    setFocusIndex(i);
                    if (keyboardFocus(event)) showTip(i);
                  }}
                  onClick={() => choose(i)}
                >
                  {out && (
                    <span
                      key={`out-${out.id}`}
                      className={styles.emojiOut}
                      style={{ '--i': i } as CSSProperties}
                      aria-hidden="true"
                    >
                      {out.emoji}
                    </span>
                  )}
                  <span
                    key={status.id}
                    className={styles.emoji}
                    style={{ '--i': i } as CSSProperties}
                    data-emoji=""
                    data-entering={out ? '' : undefined}
                    aria-hidden="true"
                  >
                    {status.emoji}
                  </span>
                </button>
              );
            })}
            {pages > 1 && (
              <button
                ref={(element) => {
                  buttons.current[more] = element;
                }}
                type="button"
                className={styles.more}
                data-part="more"
                tabIndex={tabStop === more ? 0 : -1}
                aria-label={lastPage ? 'Show the first statuses' : 'Show more statuses'}
                data-hover={hovered === more || undefined}
                data-last={lastPage || undefined}
                onPointerEnter={(event) => {
                  if (hoverable(event)) showTip(more);
                }}
                onFocus={(event) => {
                  setFocusIndex(more);
                  if (keyboardFocus(event)) showTip(more);
                }}
                onClick={turnPage}
              >
                <svg viewBox="0 0 20 20" data-icon="more" aria-hidden="true">
                  <circle cx="4.5" cy="10" r="1.6" />
                  <circle cx="10" cy="10" r="1.6" />
                  <circle cx="15.5" cy="10" r="1.6" />
                </svg>
                <svg viewBox="0 0 20 20" data-icon="back" aria-hidden="true">
                  <path d="M11.75 5.5 7.25 10l4.5 4.5" />
                </svg>
              </button>
            )}
          </div>
        </div>

        <div
          ref={chip}
          className={styles.chip}
          data-part="chip"
          data-set={current ? '' : undefined}
        >
          <button
            ref={main}
            type="button"
            className={styles.main}
            aria-label={current ? `Status: ${current.label}` : 'Set status'}
            onClick={pointToRow}
          >
            <span className={styles.slot} data-part="icon">
              {previous && (
                <span
                  key={`out-${previous.key}`}
                  className={styles.iconOut}
                  data-hidden={previous.hidden || undefined}
                  aria-hidden="true"
                >
                  {previous.emoji ?? <EmptyIcon />}
                </span>
              )}
              <span
                key={face.key}
                ref={icon}
                className={styles.icon}
                data-entering={previous && arrival?.key !== face.key ? '' : undefined}
                data-flying={(arrival?.key === face.key && !arrival.landed) || undefined}
                aria-hidden="true"
              >
                {face.emoji ?? <EmptyIcon />}
              </span>
            </span>
            <span className={styles.text}>
              {previous && (
                <span key={`out-${previous.key}`} className={styles.labelOut} aria-hidden="true">
                  {previous.label}
                </span>
              )}
              <span
                key={face.key}
                className={styles.label}
                data-part="label"
                data-entering={previous ? '' : undefined}
              >
                {face.label}
              </span>
            </span>
          </button>
          {current && (
            <button
              ref={clearButton}
              type="button"
              className={styles.clear}
              data-part="clear"
              data-entering={previous?.emoji === null ? '' : undefined}
              aria-label="Clear status"
              onClick={clear}
            >
              <svg viewBox="0 0 20 20" aria-hidden="true">
                <path d="m6.5 6.5 7 7m0-7-7 7" />
              </svg>
            </button>
          )}
        </div>
      </div>

      <span ref={flyers} className={styles.flyers} aria-hidden="true" />
      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** The empty status: a dotted ring. */
function EmptyIcon() {
  return (
    <svg className={styles.empty} viewBox="0 0 20 20" aria-hidden="true">
      <circle cx="10" cy="10" r="7.25" />
      <circle cx="10" cy="10" r="2.1" />
    </svg>
  );
}
```

### status-picker/status-picker.module.css

```css
/* Status picker (status-picker.tsx). Sizes are in em: the stage sets the font-size from its own
   width (a container query), so the picker grows with the room it has. --spring, the easing of
   the springy transitions, comes from the component, which uses it for its own animations too.

   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). The statuses' emoji are the same on both. */

.root {
  /* The stage, and what is raised on it: the row, the tooltip and the status button. */
  --surface: #efeff3;
  --surface-2: #fff;
  /* The words and the ring round the status that is set; the paging button's dots, the empty
     status and the clear button's cross. */
  --ink: #1c1c1e;
  --ink-muted: #8e8e93;
  /* The round buttons, then under the pointer; the status button under the pointer. */
  --wash: #f1f1f5;
  --wash-strong: #e6e6ec;
  --hover: #fafafc;
  /* The stage's edge, and the hairline round what is raised. */
  --line: rgb(0 0 0 / 0.05);
  --edge: rgb(0 0 0 / 0.06);
  /* What is raised sits on its hairline, a shade right under it and a soft drop. */
  --shadow-near: rgb(0 0 0 / 0.05);
  --shadow: rgb(0 0 0 / 0.16);
  --lift: 0 0 0 0.5px var(--edge), 0 1px 2px var(--shadow-near), 0 8px 22px -10px var(--shadow);
  /* The same on both schemes: the accent, the system's blue of the focus rings, and the demo's
     pointer, which looks like the system's. */
  --accent: #0a84ff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#efeff3, #121214);
    --surface-2: light-dark(#fff, #242427);
    --ink: light-dark(#1c1c1e, #ececef);
    --ink-muted: light-dark(#8e8e93, #8e8e95);
    --wash: light-dark(#f1f1f5, #2f2f33);
    --wash-strong: light-dark(#e6e6ec, #3a3a3f);
    --hover: light-dark(#fafafc, #2a2a2d);
    --line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
    --shadow-near: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --shadow: light-dark(rgb(0 0 0 / 0.16), rgb(0 0 0 / 0.6));
  }
}
/* 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 buttons start from the stage's text; :where() keeps this below every class. */
:where(.root) button {
  margin: 0;
  font: inherit;
  color: inherit;
}

.scene {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1.25em;
  min-height: max(380px, 56cqi);
  padding: 3em 16px 2em;
  font-size: clamp(14px, 9.5px + 1.1cqi, 20px);
}

/* The row of statuses, and its tooltip. */
.picker {
  --pad: 0.375em;
  --size: 2.5em;
  --gap: 0.375em;
  position: relative;
}
.row {
  display: flex;
  gap: var(--gap);
  padding: var(--pad);
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow: var(--lift);
}
.status,
.more {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--size);
  height: var(--size);
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--wash);
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    box-shadow 0.2s ease,
    scale 0.3s var(--spring);
}
.status[data-hover],
.more[data-hover] {
  background: var(--wash-strong);
}
.status:active,
.more:active {
  scale: 0.9;
}
.status[aria-pressed='true'] {
  background: var(--surface-2);
  box-shadow:
    inset 0 0 0 1.5px var(--ink),
    inset 0 0 0 3.5px var(--surface-2);
}
.status:focus-visible,
.more:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.emoji,
.emojiOut {
  grid-area: 1 / 1;
  font-size: 1.3em;
  line-height: 1;
  transition: scale 0.35s var(--spring);
}
.status[data-hover] .emoji {
  scale: 1.14;
}
/* A page turn: the old icons blur away and the new ones pop in, left to right. The old ones
   stay, invisible, until the next turn: they leave from visible, so nothing waits for them. */
.emoji[data-entering] {
  animation: pop 0.44s var(--spring) backwards;
  animation-delay: calc(var(--i) * 35ms + 60ms);
}
.emojiOut {
  opacity: 0;
  animation: sink 0.2s ease-in backwards;
  animation-delay: calc(var(--i) * 25ms);
}

.more {
  color: var(--ink-muted);
}
.more svg {
  grid-area: 1 / 1;
  width: 1.15em;
  height: 1.15em;
  fill: currentColor;
  transition:
    opacity 0.2s ease,
    scale 0.4s var(--spring),
    rotate 0.4s var(--spring);
}
.more [data-icon='back'] {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0;
  scale: 0.4;
  rotate: -90deg;
}
.more[data-last] [data-icon='more'] {
  opacity: 0;
  scale: 0.4;
  rotate: 90deg;
}
.more[data-last] [data-icon='back'] {
  opacity: 1;
  scale: 1;
  rotate: 0deg;
}

/* One tooltip for the row: it slides to the button under the pointer (or the focus), and its
   width eases to the label (--w, measured by the component), kept within the row's ends.
   The track it moves in spans the row, with room round the bubble for its shadow, and clips
   to its own box: a small layer of a fixed size, which fades in and out. The bubble moves inside
   it by repainting it (left and width), so the phone never has to make the layer again as it
   moves. */
.tips {
  --row: calc(2 * var(--pad) + var(--count) * var(--size) + (var(--count) - 1) * var(--gap));
  /* How far the bubble may go past the row's ends, and the room for its shadow. */
  --reach: 0.75em;
  --side: 0.8em;
  position: absolute;
  z-index: 1;
  bottom: calc(100% + 0.55em - 1.3em);
  left: calc(-1 * (var(--reach) + var(--side)));
  width: calc(var(--row) + 2 * (var(--reach) + var(--side)));
  height: calc(0.35em + 1.95em + 1.3em);
  overflow: hidden;
  overflow: clip;
  pointer-events: none;
  will-change: transform;
  transition:
    opacity 0.18s ease,
    translate 0.22s var(--ease-out);
}
.tips:not([data-shown]) {
  opacity: 0;
  translate: 0 0.3em;
}
.tip {
  --center: calc(
    var(--reach) + var(--side) + var(--pad) + var(--i) * (var(--size) + var(--gap)) + var(--size) /
      2
  );
  --width: calc(var(--w, 0px) + 1.6em);
  position: absolute;
  top: 0.35em;
  left: clamp(
    var(--side),
    calc(var(--center) - var(--width) / 2),
    calc(100% - var(--side) - var(--width))
  );
  width: var(--width);
  height: 1.95em;
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow: var(--lift);
  transition:
    left 0.46s var(--spring),
    width 0.46s var(--spring);
}
.tipLabel {
  position: absolute;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  font-size: 0.8em;
  font-weight: 600;
  letter-spacing: -0.005em;
  white-space: nowrap;
  opacity: 0;
  filter: blur(3px);
  transition:
    opacity 0.22s ease,
    filter 0.22s ease;
}
.tipLabel[data-active] {
  opacity: 1;
  filter: none;
}

/* The status button: the status's icon and label, and the clear button once one is set. */
.chip {
  position: relative;
  display: inline-flex;
  height: 2.75em;
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow: var(--lift);
  overflow: hidden;
  overflow: clip;
}
.chip:has(.main:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.main {
  display: flex;
  align-items: center;
  gap: 0.5em;
  height: 100%;
  padding: 0 1.15em 0 0.8em;
  border: 0;
  border-radius: inherit;
  background: transparent;
  white-space: nowrap;
  cursor: pointer;
  transition: background-color 0.2s ease;
}
.main:hover {
  background: var(--hover);
}
.main:focus-visible {
  outline: none;
}
.chip[data-set] .main {
  padding-right: 2.7em;
}
.slot {
  display: grid;
  place-items: center;
  width: 1.4em;
  height: 1.4em;
}
.icon,
.iconOut {
  grid-area: 1 / 1;
  display: grid;
  place-items: center;
  font-size: 1.15em;
  line-height: 1;
}
.icon[data-entering] {
  animation: pop 0.44s var(--spring) 0.08s backwards;
}
/* The icon in flight: its copy flies in, and it shows when the copy lands. */
.icon[data-flying] {
  visibility: hidden;
  animation: none;
}
.iconOut {
  opacity: 0;
  animation: drop 0.26s ease-in backwards;
}
.iconOut[data-hidden] {
  visibility: hidden;
}
.empty {
  width: 1em;
  height: 1em;
  fill: none;
  stroke: var(--ink-muted);
  stroke-width: 1.6;
}
.empty circle:first-child {
  stroke-dasharray: 1.6 3.09;
  stroke-linecap: round;
}
.empty circle:last-child {
  fill: var(--ink-muted);
  stroke: none;
}
.text {
  position: relative;
  display: grid;
  font-size: 0.95em;
  font-weight: 600;
  letter-spacing: -0.01em;
}
.label[data-entering] {
  animation: rise 0.36s var(--ease-out) 0.1s backwards;
}
.labelOut {
  position: absolute;
  top: 0;
  left: 0;
  opacity: 0;
  animation: lift 0.22s ease-in backwards;
}
.clear {
  position: absolute;
  top: 50%;
  right: 0.5em;
  display: grid;
  place-items: center;
  width: 1.75em;
  height: 1.75em;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--wash);
  color: var(--ink-muted);
  cursor: pointer;
  translate: 0 -50%;
  transition:
    background-color 0.2s ease,
    color 0.2s ease;
}
.clear[data-entering] {
  animation: pop 0.44s var(--spring) 0.16s backwards;
}
.clear:hover {
  background: var(--wash-strong);
  color: var(--ink);
}
.clear:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px var(--accent);
}
.clear svg {
  width: 1em;
  height: 1em;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
}

/* The flying icons (positioned by the component). */
.flyers {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
}
.flyer {
  position: absolute;
  top: 0;
  left: 0;
  display: grid;
  place-items: center;
  line-height: 1;
}

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

@keyframes pop {
  from {
    opacity: 0;
    scale: 0.4;
    filter: blur(3px);
  }
}
@keyframes sink {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
    scale: 0.4;
    filter: blur(3px);
  }
}
@keyframes rise {
  from {
    opacity: 0;
    translate: 0 0.35em;
    filter: blur(4px);
  }
}
@keyframes lift {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
    translate: 0 -0.35em;
    filter: blur(4px);
  }
}
@keyframes drop {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
    scale: 0.5;
    translate: 0 0.5em;
    filter: blur(3px);
  }
}

/* Final states at once. */
@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
    transition: none !important;
  }
}
```

### status-picker/statuses.ts

```tsx
/*
 * The statuses to pick from. The picker shows them five at a time; its sixth button pages
 * through the rest.
 */

export type Status = {
  id: string;
  /** Its icon: on its button, then in the status once it is set. */
  emoji: string;
  /** The status itself: the tooltip over its button, then the status's text once it is set. */
  label: string;
};

export const STATUSES: readonly Status[] = [
  { id: 'vacation', emoji: '🌴', label: 'On vacation' },
  { id: 'sick', emoji: '🤒', label: 'Out sick' },
  { id: 'meeting', emoji: '📅', label: 'In a meeting' },
  { id: 'focus', emoji: '🎧', label: 'Focusing' },
  { id: 'commuting', emoji: '🚌', label: 'Commuting' },
  { id: 'lunch', emoji: '🥪', label: 'Out for lunch' },
  { id: 'remote', emoji: '🏡', label: 'Working from home' },
  { id: 'walk', emoji: '🚶', label: 'Out for a walk' },
  { id: 'travel', emoji: '✈️', label: 'Travelling' },
  { id: 'away', emoji: '🌙', label: 'Off for the day' },
];
```

### status-picker/ghost-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps:
 * wait, glide the pointer onto a control, press it and call the component's own handler, as a
 * click would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in
 * progress at once: its promise rejects, which ends the script right there, and the pointer
 * fades out.
 */

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
};

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

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

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

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: SVGSVGElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  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 moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    const arrow = pointer.firstElementChild;
    const tap = arrow?.animate(
      [{ transform: 'none' }, { transform: 'scale(0.8)', offset: 0.35 }, { transform: 'none' }],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press }).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';
  };
}

/** Its colours are the stage's tokens (status-picker.module.css). */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  width: 22,
  height: 22,
  overflow: 'visible',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  fill: 'var(--pointer)',
  stroke: 'var(--pointer-edge)',
  strokeWidth: 1.3,
  strokeLinejoin: 'round',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
};

/** The arrow the demo moves around. It is decorative: the demo's effects are announced. */
export function GhostPointer({ ref }: { ref: Ref<SVGSVGElement> }) {
  return (
    <svg ref={ref} viewBox="0 0 20 20" style={POINTER} aria-hidden="true">
      <path
        d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
        style={{ transformBox: 'fill-box', transformOrigin: '0 0' }}
      />
    </svg>
  );
}
```

### status-picker/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 };
}
```