# Status avatar

> A round avatar whose status dot grows into a tray of three statuses, and shrinks back into the one you pick.

The status dot on a profile picture. Rest the pointer on it, click it or press Enter, and the dot swells into a tray of three round options, Online, Focus and Do Not Disturb, while the avatar grows a little with it. A label over the tray names the option under the pointer, its words changing letter by letter. Pick one and the tray shrinks back into a dot of that colour, with a glint across it.

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

Category: Inputs.

## Anatomy

1. **Stage**: The grey panel it sits on, 4 : 3. Every length is a number of design units times one unit in px, set by `size` or by the stage’s width.
2. **Avatar**: The round picture, 241 units across. It grows by about a fifth while the tray is open, on the same spring.
3. **Tray**: The dot’s ring, grown from 88 units to a 368 × 128 pill. Closed, it is the ring around the status dot.
4. **Chosen option**: A `role="radio"` with `aria-checked`. Closed, the options stack inside the dot with the chosen one on top.
5. **Option**: Each status is a circle in its own colour with a white icon. It blurs as it travels and lands sharp.
6. **Label**: A pill as wide as its words, over the option under the pointer or the focus, kept inside the tray’s ends. Visual only: each option has its own name.

## When to use

- Setting your own presence where your avatar already sits: a sidebar footer, a profile header, a call window.
- A small fixed set of statuses, two to four, each with its own colour and icon: `Online`, `Focus`, `Do Not Disturb`.
- A choice made often enough that it should take one hover and one click, without a menu or a settings page.
- A hero moment in a product tour or a landing page, where the morph from dot to tray is worth showing.

## When not to use

- Showing other people’s status in a list or a chat. Draw a plain avatar with a static dot; only you can change yours.
- Statuses with free text, an expiry or a schedule (“Back at 3”). Open a menu or a dialog with a field and a duration.
- More than four statuses: the tray grows past the avatar. Use a dropdown menu with icons.
- Tiny avatars, under about 60 px, as in a dense table. The dot is too small to aim at; put the setting in the account menu.
- Pages that must stay still. The springs, blur and glint are the point; with reduced motion they are dropped, but a plain select is lighter.

## Installation

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

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

Or add the registry to components.json once, as a namespace that coding agents and the shadcn MCP server search by name:

```json
{
  "registries": {
    "@brunodelorence": "https://brunodelorence.com/r/{name}.json"
  }
}
```

```sh
npx shadcn@latest add @brunodelorence/status-avatar
```

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

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

## Usage

```tsx
import { useState } from 'react';
import { StatusAvatar, type Status } from './status-avatar';

export function ProfileStatus() {
  const [status, setStatus] = useState<Status>('online');
  return <StatusAvatar status={status} onStatusChange={setStatus} autoplay={false} />;
}
```

## States

- **Closed, Online**: The default: a green dot in a light ring at the avatar’s top right.
- **Closed, Focus**: The dot takes the chosen status’s colour; the dot button says “Your status: Focus”.
- **Closed, Do Not Disturb**: Orange, the last option. Closed, the icons are hidden; the colour carries it.
- **Open**: The tray is out, the avatar a fifth larger, and the label names the current status.
- **Open, label at the end**: Over the last option the long label is pushed back inside the tray instead of overhanging it.
- **Dark**: With `colorScheme="dark"`: a near-black stage and a graphite tray; the status colours stay.
- **Small**: With `size={96}`: the dot, the ring and the tray follow the avatar’s width.

## How it works

- One morph, not two widgets: the tray is the dot’s white ring grown from 88 to 368 × 128 design units, and the three options are the dot’s coloured middle. Closed, all three sit in the dot at 60% of their size, the chosen one on top; open, each slides out to its place in the row.
- Every length is a number of design units times --k, one unit in px: from the size prop, or else a fifth of the stage’s width for the avatar, and shrunk with a container query unit on a narrow stage, so the open tray always fits beside the avatar.
- The tray, the options, the label and the avatar (which grows by 19%) move on springs written as CSS linear() easings: a softer one that opens (damping 0.65, a 6% overshoot, settled in about half a second) and a firmer one that closes (damping 0.78, 2%).
- On the way the options blur and land sharp, the ones not chosen fading in on the way out and out on the way back; the dot’s softer colour turns to the tray’s brighter one and the white icons fade in. The blur, and the glint that sweeps the dot after a choice, are played with the Web Animations API.
- The label is a pill as wide as its words, measured from hidden copies, centred over the named option but kept inside the tray’s ends. It slides and resizes on the opening spring, and its new words blur in letter by letter, 16 ms apart, over the old ones blurring out.
- A short rest on the dot opens it; a click within 280 ms of that hover is taken as aimed at the dot, not at the option now under the pointer. A tray the hover opened closes a moment after the pointer leaves the avatar; any tray closes on a choice, Escape or a press elsewhere.
- While on screen and untouched, a demo pointer opens the tray, looks over the options and picks one, then the next, in a loop; any input from the reader stops it.
- Every colour is a token on the root, light-dark() where the two schemes differ: at night the stage turns near-black and the tray and label a dark grey, the statuses keeping their colours.

## API reference

- **status** (`'online' | 'focus' | 'busy'`): The status shown, when the parent keeps it; pair it with onStatusChange.
- **defaultStatus** (`'online' | 'focus' | 'busy'`, default `'online'`): The status it starts with, when it keeps its own.
- **onStatusChange** (`(status: Status) => void`): Called with the status the reader picks.
- **size** (`number`): The avatar’s width in px; the dot, the tray and the label follow. Without it, a fifth of the stage’s width, from 120 to 193 px. Either way it shrinks on a stage too narrow for the open tray.
- **label** (`string`, default `'Your status'`): What the dot’s button is called for screen readers, before the status itself.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer opens the tray and picks a status; any input from the reader stops it.
- **defaultOpen** (`boolean`, default `false`): Starts with the tray open and the label on the current status, as a still for docs and screenshots; it turns the autoplay demo off. A click elsewhere closes it as usual.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): A class for the stage.

## Accessibility

- The dot is a button named with the current status (“Your status: Focus”), with aria-expanded and aria-controls pointing at the options.
- The options are a radio group: Enter, Space or an arrow key on the dot opens it with the focus on the current status; arrows, Home and End move between the options, Enter or Space picks one, Escape closes. The focus then goes back to the dot.
- Closed, the options are inert, out of the tab order and out of the accessibility tree.
- A polite live region says the new status (“Status set to Do Not Disturb”); the label over the tray is visual only, since each option carries its own name.
- Every control shows a focus ring on keyboard focus. The picture, the icons and the demo pointer are hidden from screen readers.
- With reduced motion, the tray, the avatar and the label change at once, without springs, blur, letter animation or glint, and the demo does not play.

## Source

### status-avatar/status-avatar.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type MouseEvent as ReactMouseEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { AvatarArt } from './avatar-art';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './status-avatar.module.css';

/*
 * Status avatar: a round avatar with its status dot at the top right. Hovering the dot (or
 * clicking it, or pressing Enter on it) grows the dot into a tray of three options, Online, Focus
 * and Do Not Disturb; picking one shrinks the tray back into a dot of that colour, and a glint
 * sweeps across it.
 *
 * - One morph, not two widgets: the tray is the dot's white ring, sized up from 88 to 368 × 128
 *   design units, and the three options are the dot's coloured middle. Closed, they all sit in
 *   the dot, scaled to its size, the chosen one on top; opened, each slides to its place in the
 *   row. The avatar grows by a fifth with them. All of it moves on the same spring (CSS linear()
 *   easings in the stylesheet), softer opening than closing.
 * - The options blur as they travel and come into focus as they land; the ones not chosen fade
 *   in on the way out and fade out on the way back. The blur and the glint are played with the
 *   Web Animations API, so nothing runs frame by frame.
 * - The label over the tray names the option under the pointer or the focus. It is a pill as wide
 *   as its words, centred over that option but kept within the tray; it slides and resizes on the
 *   spring, and its words change letter by letter, the new ones blurring in over the old.
 * - It is a button (aria-expanded) that opens a radio group: arrows move between the options,
 *   Enter or Space picks one, Escape closes. A live region says the new status.
 * - While on screen and untouched, a demo pointer opens the tray, looks over the options and
 *   picks one, then another.
 */

export type Status = 'online' | 'focus' | 'busy';

const OPTIONS: { id: Status; label: string; x: number }[] = [
  // x: the option's middle, in design units from the tray's left edge.
  { id: 'online', label: 'Online', x: 64 },
  { id: 'focus', label: 'Focus', x: 184 },
  { id: 'busy', label: 'Do Not Disturb', x: 304 },
];

/** The avatar's width in design units; the size prop sets how many px that is. */
const AVATAR = 241;
/** Where the tray's left edge sits, in units from the avatar's middle. */
const TRAY_LEFT = 40;
/** The label stays this far inside the tray's two ends, in units. */
const LABEL_MIN = 61.5;
const LABEL_MAX = 386.5;
/** The label's padding on each side of its words, in units. */
const LABEL_PAD = 33;
/** The middle of the closed dot, and the row of open options, in units from the avatar's middle. */
const DOT: Point = { x: 84, y: -91.5 };
const ROW_Y = -119.5;
/** A click this soon after a hover opened the tray was aimed at the dot, not at an option. */
const SETTLE = 280;

type Words = { id: number; text: string; phase: 'rest' | 'in' | 'out' };

/** The three icons, drawn white on a 106-unit option. */
function Icon({ status, className }: { status: Status; className: string }) {
  const slash = `${useId()}-slash`;
  return (
    <svg className={className} viewBox="0 0 106 106" aria-hidden="true" focusable="false">
      {status === 'online' && (
        <path d="M53 30c16 0 29 9.4 29 21s-13 21-29 21c-3.4 0-6.7-.4-9.7-1.2-3.4 2.4-8.2 3.8-14.4 3.6 2.9-2.1 4.8-4.8 5.4-7.9C28.6 62.6 24 57.2 24 51c0-11.6 13-21 29-21z" />
      )}
      {status === 'focus' && (
        <>
          <circle cx="53" cy="53" r="25.8" fill="none" stroke="currentColor" strokeWidth="8.4" />
          <circle cx="53" cy="53" r="14.6" fill="none" stroke="currentColor" strokeWidth="6.6" />
          <circle cx="53" cy="53" r="6.2" />
        </>
      )}
      {status === 'busy' && (
        <>
          <mask id={slash} maskUnits="userSpaceOnUse">
            <rect width="106" height="106" fill="#fff" />
            <path d="M30 30L79 79" stroke="#000" strokeWidth="9.5" strokeLinecap="round" />
          </mask>
          <g mask={`url(#${slash})`}>
            <path d="M53 31c-1.7 0-3 1.3-3 3v1.2C42.3 36.7 37 43.4 37 51.6v8.6l-5 6.2c-1.3 1.6-.2 3.9 1.9 3.9h38.2c2.1 0 3.2-2.3 1.9-3.9l-5-6.2v-8.6C69 43.4 63.7 36.7 56 35.2V34c0-1.7-1.3-3-3-3z" />
            <path d="M46.6 73.6h12.8a6.4 6.4 0 0 1-12.8 0z" />
          </g>
          <path
            d="M31.5 31.5L77.5 77.5"
            stroke="currentColor"
            strokeWidth="3.6"
            strokeLinecap="round"
          />
        </>
      )}
    </svg>
  );
}

export function StatusAvatar({
  status: controlled,
  defaultStatus = 'online',
  onStatusChange,
  size,
  label = 'Your status',
  autoplay = true,
  defaultOpen = false,
  colorScheme,
  className = '',
}: {
  /** The status shown, when the parent keeps it; pair it with onStatusChange. */
  status?: Status;
  /** The status it starts with, when it keeps its own. */
  defaultStatus?: Status;
  /** Called with the status the reader picks. */
  onStatusChange?: (status: Status) => void;
  /**
   * The avatar's width in px (the rest follows); without it, a fifth of the stage's width, from
   * 120 to 193 px. Either way it shrinks on a stage too narrow for the open tray.
   */
  size?: number;
  /** What the control is called for screen readers, before its status. */
  label?: string;
  /** While on screen and untouched, a demo pointer opens the tray and picks a status. */
  autoplay?: boolean;
  /**
   * Starts with the tray open and the label on the current status, as a still for docs and
   * screenshots; it also turns the autoplay demo off. A click elsewhere closes it as usual.
   */
  defaultOpen?: 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>(5000);
  const [own, setOwn] = useState<Status>(defaultStatus);
  const status = controlled ?? own;
  const [open, setOpen] = useState(defaultOpen);
  /** With defaultOpen, the label starts on the status it opens with. */
  const [opensOn] = useState(() =>
    defaultOpen
      ? Math.max(
          0,
          OPTIONS.findIndex((option) => option.id === (controlled ?? defaultStatus)),
        )
      : null,
  );
  /** The option the label names: under the pointer or the focus. */
  const [active, setActive] = useState<number | null>(opensOn);
  const [words, setWords] = useState<Words[]>(() =>
    opensOn === null ? [] : [{ id: 0, text: OPTIONS[opensOn].label, phase: 'rest' }],
  );
  /** The label jumps to its place, instead of sliding, when it first shows. */
  const [instant, setInstant] = useState(true);
  const [message, setMessage] = useState('');
  /** px per design unit, and each label's width in px: read from the page. */
  const [metrics, setMetrics] = useState<{ k: number; widths: number[] } | null>(null);

  const groupId = useId();
  const avatar = useRef<HTMLDivElement>(null);
  const toggle = useRef<HTMLButtonElement>(null);
  const options = useRef<(HTMLButtonElement | null)[]>([]);
  const shines = useRef<(HTMLSpanElement | null)[]>([]);
  const measures = useRef<(HTMLSpanElement | null)[]>([]);
  const pointer = useRef<HTMLSpanElement>(null);
  const activeRef = useRef<number | null>(opensOn);
  const openRef = useRef(defaultOpen);
  const openedBy = useRef<'hover' | 'click' | 'key' | 'demo'>('click');
  const openedAt = useRef(0);
  const wordId = useRef(0);
  const glintNext = useRef(false);
  const focusNext = useRef(false);
  const dwell = useRef(0);
  const away = useRef(0);
  const prune = useRef(0);
  const first = useRef(true);

  const current = Math.max(
    0,
    OPTIONS.findIndex((option) => option.id === status),
  );

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

  /** Names option `next` in the label, or hides the label (null). */
  const showLabel = (next: number | null) => {
    const previous = activeRef.current;
    if (previous === next) return;
    activeRef.current = next;
    setActive(next);
    if (next === null) return;
    const text = OPTIONS[next].label;
    wordId.current += 1;
    const id = wordId.current;
    if (previous === null) {
      setInstant(true);
      setWords([{ id, text, phase: 'rest' }]);
      return;
    }
    setInstant(false);
    setWords((now) => [
      ...now
        .filter((word) => word.phase !== 'out')
        .map((word) => ({ ...word, phase: 'out' as const })),
      { id, text, phase: 'in' },
    ]);
    window.clearTimeout(prune.current);
    prune.current = window.setTimeout(
      () => setWords((now) => now.filter((word) => word.phase !== 'out')),
      600,
    );
  };

  const openTray = (by: typeof openedBy.current) => {
    window.clearTimeout(dwell.current);
    if (openRef.current) return;
    openRef.current = true;
    openedBy.current = by;
    openedAt.current = performance.now();
    focusNext.current = by === 'key';
    setOpen(true);
  };

  const closeTray = () => {
    window.clearTimeout(away.current);
    if (!openRef.current) return;
    // Focus inside the group goes back to the dot, before the group goes inert.
    const group = options.current[0]?.parentElement;
    if (group?.contains(document.activeElement)) toggle.current?.focus();
    openRef.current = false;
    showLabel(null);
    setOpen(false);
  };

  const choose = (index: number, by: 'reader' | 'demo') => {
    const next = OPTIONS[index];
    glintNext.current = true;
    if (controlled === undefined) setOwn(next.id);
    if (by === 'reader') {
      onStatusChange?.(next.id);
      announce(`Status set to ${next.label}`);
    }
    closeTray();
  };

  // The geometry the label needs, read again whenever the stage changes size.
  useLayoutEffect(() => {
    const stage = root.current;
    if (!stage) return;
    const read = () => {
      const width = avatar.current?.offsetWidth ?? 0;
      if (!width) return;
      setMetrics({
        k: width / AVATAR,
        widths: OPTIONS.map((_, index) => measures.current[index]?.offsetWidth ?? 0),
      });
    };
    read();
    const observer = new ResizeObserver(read);
    observer.observe(stage);
    // Webfonts can change the words' widths once they arrive.
    document.fonts?.ready.then(read);
    return () => observer.disconnect();
  }, [root]);

  // Each opening and closing: the options blur on the way and land sharp; a choice ends with
  // a glint across the dot. Focus moves into the group when the keyboard opened it.
  useEffect(() => {
    if (first.current) {
      first.current = false;
      return;
    }
    if (open && focusNext.current) {
      focusNext.current = false;
      options.current[current]?.focus();
    }
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      glintNext.current = false;
      return;
    }
    const k = (avatar.current?.offsetWidth ?? AVATAR * 0.5) / AVATAR;
    const blur = (units: number) => `blur(${(units * k).toFixed(2)}px)`;
    const played: Animation[] = [];
    options.current.forEach((element, index) => {
      if (!element) return;
      const chosen = index === current;
      const frames = open
        ? chosen
          ? [{ filter: blur(0) }, { filter: blur(5), offset: 0.3 }, { filter: blur(0) }]
          : [{ filter: blur(10) }, { filter: blur(0) }]
        : chosen
          ? [{ filter: blur(0) }, { filter: blur(4), offset: 0.35 }, { filter: blur(0) }]
          : [{ filter: blur(0) }, { filter: blur(10) }];
      played.push(
        element.animate(frames, {
          duration: open ? 380 : 240,
          easing: open ? 'cubic-bezier(0.2, 0.6, 0.3, 1)' : 'ease-in',
        }),
      );
    });
    const shine = shines.current[current];
    if (!open && glintNext.current && shine) {
      played.push(
        shine.animate(
          [
            { opacity: 0, transform: 'translateX(-70%)' },
            { opacity: 1, offset: 0.2 },
            { opacity: 1, offset: 0.75 },
            { opacity: 0, transform: 'translateX(70%)' },
          ],
          { duration: 440, delay: 190, easing: 'cubic-bezier(0.4, 0, 0.3, 1)' },
        ),
      );
    }
    glintNext.current = false;
    return () => played.forEach((animation) => animation.finish());
    // Only on opening and closing: `current` is read as it is then.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open]);

  // A press anywhere else closes the tray.
  useEffect(() => {
    if (!open) return;
    const onDown = (event: PointerEvent) => {
      const scene = toggle.current?.parentElement;
      if (scene && !scene.contains(event.target as Node)) closeTray();
    };
    document.addEventListener('pointerdown', onDown);
    return () => document.removeEventListener('pointerdown', onDown);
    // closeTray only reads refs and setters.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open]);

  useEffect(
    () => () => {
      window.clearTimeout(dwell.current);
      window.clearTimeout(away.current);
      window.clearTimeout(prune.current);
    },
    [],
  );

  // The demo: the pointer opens the tray, looks over the options, picks one; then the others.
  const demoAct = useEffectEvent(
    (act: { open: true } | { hover: number | null } | { pick: number } | { close: true }) => {
      if ('open' in act) openTray('demo');
      else if ('hover' in act) showLabel(act.hover);
      else if ('pick' in act) choose(act.pick, 'demo');
      // The demo only closes what it opened: the reader may be opening it as the demo stops.
      else if (openedBy.current === 'demo') closeTray();
    },
  );
  const demo = autoplay && !defaultOpen && playing;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    const face = avatar.current;
    if (!demo || !stage || !arrow || !face) return;
    const stop = runDemo(stage, arrow, async ({ wait, glide }) => {
      /** A point given in design units from the avatar's middle, in the stage's px. */
      const at = (x: number, y: number): Point => {
        const frame = stage.getBoundingClientRect();
        const scale = frame.width / stage.offsetWidth || 1;
        const rect = face.getBoundingClientRect();
        const k = face.offsetWidth / AVATAR;
        return {
          x: (rect.left + rect.width / 2 - frame.left) / scale + x * k,
          y: (rect.top + rect.height / 2 - frame.top) / scale + y * k,
        };
      };
      const option = (index: number) => at(TRAY_LEFT + OPTIONS[index].x + 4, ROW_Y + 10);
      const tour: { look: number[]; pick: number }[] = [
        { look: [1, 2, 1], pick: 1 },
        { look: [2], pick: 2 },
        { look: [1, 0], pick: 0 },
      ];
      await glide(at(230, 70), 700);
      for (;;) {
        for (const round of tour) {
          await wait(900);
          await glide(at(DOT.x + 6, DOT.y + 8), 620);
          await wait(160);
          demoAct({ open: true });
          await wait(420);
          await glide(at(240, 80), 520);
          await wait(380);
          for (const index of round.look) {
            await glide(option(index), 460);
            demoAct({ hover: index });
            await wait(820);
          }
          await wait(200);
          demoAct({ pick: round.pick });
          await wait(500);
          await glide(at(230, 70), 640);
          await wait(1100);
        }
      }
    });
    return () => {
      stop();
      demoAct({ close: true });
    };
  }, [demo, root]);

  // The reader's hover: a short rest on the dot opens the tray; leaving the whole avatar for a
  // moment closes a tray the hover opened.
  const onDotEnter = (event: ReactPointerEvent) => {
    if (event.pointerType === 'touch') return;
    window.clearTimeout(dwell.current);
    dwell.current = window.setTimeout(() => openTray('hover'), 140);
  };
  const onDotLeave = () => window.clearTimeout(dwell.current);
  const onSceneEnter = () => window.clearTimeout(away.current);
  const onSceneLeave = (event: ReactPointerEvent) => {
    if (event.pointerType === 'touch' || !openRef.current || openedBy.current !== 'hover') return;
    window.clearTimeout(away.current);
    away.current = window.setTimeout(closeTray, 1200);
  };

  const onDotClick = (event: ReactMouseEvent) => {
    // A keyboard press (no pointer, detail 0) moves the focus into the options.
    if (openRef.current) closeTray();
    else openTray(event.detail === 0 ? 'key' : 'click');
  };
  const onDotKey = (event: ReactKeyboardEvent) => {
    if (['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'].includes(event.key)) {
      event.preventDefault();
      openTray('key');
    }
  };

  const onOptionClick = (index: number) => {
    if (openedBy.current === 'hover' && performance.now() - openedAt.current < SETTLE) return;
    choose(index, 'reader');
  };
  const onGroupKey = (event: ReactKeyboardEvent) => {
    const index = options.current.findIndex((element) => element === document.activeElement);
    const move = (next: number) => {
      event.preventDefault();
      options.current[(next + OPTIONS.length) % OPTIONS.length]?.focus();
    };
    if (event.key === 'ArrowRight' || event.key === 'ArrowDown') move(index + 1);
    else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') move(index - 1);
    else if (event.key === 'Home') move(0);
    else if (event.key === 'End') move(OPTIONS.length - 1);
    else if (event.key === 'Escape') {
      event.preventDefault();
      closeTray();
    }
  };

  // The label's place and width, in px from the avatar's middle.
  let slot: CSSProperties | undefined;
  const shown = open && active !== null && metrics !== null;
  const named = active ?? OPTIONS.findIndex((option) => option.label === words.at(-1)?.text);
  if (metrics && named >= 0) {
    const { k, widths } = metrics;
    const width = widths[named] + LABEL_PAD * 2 * k;
    const middle = (TRAY_LEFT + OPTIONS[named].x) * k;
    const centre = Math.min(Math.max(middle, LABEL_MIN * k + width / 2), LABEL_MAX * k - width / 2);
    slot = { width, transform: `translateX(${(centre - width / 2).toFixed(2)}px)` };
  }

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-open={open}
      data-color-scheme={colorScheme}
      style={size ? ({ '--k-max': `${size / AVATAR}px` } as CSSProperties) : undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
    >
      <div
        className={styles.scene}
        data-part="scene"
        onPointerEnter={onSceneEnter}
        onPointerLeave={onSceneLeave}
      >
        <div ref={avatar} className={styles.avatar} data-part="avatar">
          <AvatarArt className={styles.art} />
        </div>

        <div
          className={styles.labelSlot}
          data-part="label"
          data-shown={shown}
          data-instant={instant}
          style={slot}
          aria-hidden="true"
        >
          <div className={styles.label}>
            {words.map((word) => (
              <span key={word.id} className={styles.words} data-phase={word.phase}>
                {[...word.text].map((char, index) => (
                  <span
                    key={index}
                    className={styles.char}
                    style={{ '--i': index } as CSSProperties}
                  >
                    {char}
                  </span>
                ))}
              </span>
            ))}
            {OPTIONS.map((option, index) => (
              <span
                key={option.id}
                ref={(element) => {
                  measures.current[index] = element;
                }}
                className={styles.measure}
              >
                {option.label}
              </span>
            ))}
          </div>
        </div>

        <div className={styles.tray} data-part="tray">
          <div
            id={groupId}
            className={styles.group}
            role="radiogroup"
            tabIndex={-1}
            aria-label="Set status"
            inert={!open}
            onKeyDown={onGroupKey}
            onPointerLeave={() => showLabel(null)}
            onBlur={(event) => {
              if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
                if (openedBy.current === 'key' || openedBy.current === 'click') closeTray();
                else showLabel(null);
              }
            }}
          >
            {OPTIONS.map((option, index) => (
              <button
                key={option.id}
                ref={(element) => {
                  options.current[index] = element;
                }}
                type="button"
                role="radio"
                aria-checked={index === current}
                aria-label={option.label}
                tabIndex={index === current ? 0 : -1}
                className={styles.option}
                data-part="option"
                data-status={option.id}
                style={{ '--x': option.x } as CSSProperties}
                onPointerEnter={(event) => {
                  if (event.pointerType !== 'touch') showLabel(index);
                }}
                onFocus={() => showLabel(index)}
                onClick={() => onOptionClick(index)}
              >
                <Icon status={option.id} className={styles.icon} />
                <span
                  ref={(element) => {
                    shines.current[index] = element;
                  }}
                  className={styles.shine}
                  aria-hidden="true"
                />
              </button>
            ))}
          </div>
        </div>

        <button
          ref={toggle}
          type="button"
          className={styles.toggle}
          data-part="dot"
          aria-label={`${label}: ${OPTIONS[current].label}`}
          aria-expanded={open}
          aria-controls={groupId}
          onPointerEnter={onDotEnter}
          onPointerLeave={onDotLeave}
          onClick={onDotClick}
          onKeyDown={onDotKey}
        />
      </div>

      <p className={styles.hidden} aria-live="polite">
        {message}
      </p>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
    </div>
  );
}
```

### status-avatar/status-avatar.module.css

```css
/* Status avatar (status-avatar.tsx): a round avatar on a quiet grey stage, its status dot at the
   top right. Opened, the dot grows into a tray of three round options, the avatar grows with it,
   and a label names the option under the pointer or the focus.

   Sizes. Every length is a number of design units times --k, the px of one unit: the avatar is
   241 units across, the open tray 368 × 128. --k comes from the size prop (--k-max), or without it
   from the stage's width (an avatar a fifth of it, 120 to 193 px), and shrinks on a narrow stage, so the open tray always fits beside the avatar (a container query unit).

   Motion. The tray, the options and the avatar move on springs written as linear() easings:
   a softer one that opens (a 6% overshoot on the avatar and the label, 4.6% on the tray, settled
   in about half a second) and a firmer one that closes (2%). The blur the options go through, and the glint on the dot after a choice, are
   played from the script with the Web Animations API.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page, or the one its colorScheme prop sets. */

.root {
  /* The stage. */
  --stage: #f6f6f6;
  --line: rgb(0 0 0 / 0.05);
  /* The tray and the dot's ring, their edge and lift. */
  --tray: #f8f8f9;
  --tray-edge: rgb(0 0 0 / 0.11);
  --tray-shine: #fff;
  --tray-shadow: rgb(0 0 0 / 0.06);
  /* The label above the tray. */
  --label: #fff;
  --label-edge: rgb(0 0 0 / 0.1);
  --label-ink: #1f1f21;
  --label-shadow: rgb(0 0 0 / 0.05);
  /* The avatar's lift. */
  --avatar-shadow: rgb(20 30 40 / 0.13);
  --avatar-edge: rgb(0 0 0 / 0.06);
  /* The same on both schemes: each status in the tray, and as the smaller dot. */
  --online: #11dd40;
  --online-dot: #3ad652;
  --focus-status: #f6c605;
  --focus-dot: #f8cd0a;
  --busy: #f27305;
  --busy-dot: #f47b29;
  --icon: #fff;
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  /* The springs: opening (damping ratio 0.65, 15 rad/s), the tray opening (0.7, 17.5 rad/s) and
     closing (0.78, 22 rad/s). */
  --open-spring: linear(
    0,
    0.0374,
    0.1306,
    0.2556,
    0.3934,
    0.5304,
    0.6572,
    0.7681,
    0.8602,
    0.9332,
    0.9878,
    1.026,
    1.0503,
    1.0635,
    1.068,
    1.0663,
    1.0605,
    1.0523,
    1.0429,
    1.0336,
    1.0248,
    1.017,
    1.0105,
    1.0053,
    1.0014,
    0.9986,
    0.9968,
    0.9958,
    0.9954,
    0.9958,
    0.9968,
    0.998,
    1
  );
  --close-spring: linear(
    0,
    0.0524,
    0.1734,
    0.3222,
    0.4727,
    0.6098,
    0.7259,
    0.819,
    0.8898,
    0.941,
    0.9761,
    0.9985,
    1.0116,
    1.018,
    1.0199,
    1.0191,
    1.0168,
    1.0139,
    1.0109,
    1.0081,
    1.0057,
    1.0038,
    1.0024,
    1.0013,
    1.0005,
    1.0001,
    1
  );
  --tray-spring: linear(
    0,
    0.0579,
    0.1927,
    0.3589,
    0.5259,
    0.6757,
    0.7992,
    0.894,
    0.9616,
    1.0059,
    1.0316,
    1.0435,
    1.0459,
    1.0425,
    1.0359,
    1.0281,
    1.0206,
    1.0139,
    1.0084,
    1.0043,
    1.0013,
    0.9995,
    0.9984,
    0.998,
    0.9979,
    0.9981,
    0.9984,
    0.9988,
    0.9991,
    0.9994,
    0.9997,
    0.9998,
    1
  );
  --open-time: 680ms;
  --close-time: 420ms;
  /* One design unit, in px: the size prop's, or less on a narrow stage. */
  --k: min(var(--k-max, clamp(0.5px, 20cqi / 241, 0.8px)), calc((50cqi - 16px) / 409));
  position: relative;
  container-type: inline-size;
  contain: layout;
  width: 100%;
  aspect-ratio: 4 / 3;
  min-height: 340px;
  max-height: 640px;
  overflow: hidden;
  overflow: clip;
  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(#f6f6f6, #141416);
    --line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --tray: light-dark(#f8f8f9, #26262a);
    --tray-edge: light-dark(rgb(0 0 0 / 0.11), rgb(255 255 255 / 0.1));
    --tray-shine: light-dark(#fff, rgb(255 255 255 / 0.08));
    --tray-shadow: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.4));
    --label: light-dark(#fff, #2c2c31);
    --label-edge: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.1));
    --label-ink: light-dark(#1f1f21, #f1f1f3);
    --label-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.35));
    --avatar-shadow: light-dark(rgb(20 30 40 / 0.13), rgb(0 0 0 / 0.55));
    --avatar-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
  }
}
.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 point everything is placed from: the middle of the avatar, in the middle of the stage. */
.scene {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0;
  height: 0;
}

/* The avatar: a circle that crops the drawing, a little larger while the tray is open. */
.avatar {
  position: absolute;
  top: calc(-120.5 * var(--k));
  left: calc(-120.5 * var(--k));
  width: calc(241 * var(--k));
  height: calc(241 * var(--k));
  border-radius: 50%;
  overflow: hidden;
  background: #cfe3dc;
  box-shadow:
    0 calc(16 * var(--k)) calc(44 * var(--k)) var(--avatar-shadow),
    0 0 0 1px var(--avatar-edge);
  transition: transform var(--close-time) var(--close-spring);
  will-change: transform;
}
.root[data-open='true'] .avatar {
  transform: scale(1.188);
  transition: transform var(--open-time) var(--open-spring);
}
.art {
  display: block;
  width: 100%;
  height: 100%;
}

/* The tray: the dot's white ring, grown into a pill that holds the three options. */
.tray {
  position: absolute;
  top: calc(-135.5 * var(--k));
  left: calc(40 * var(--k));
  width: calc(88 * var(--k));
  height: calc(88 * var(--k));
  border-radius: 999px;
  background: var(--tray);
  box-shadow:
    0 0 0 0.5px var(--tray-edge),
    inset 0 calc(2 * var(--k)) 0 var(--tray-shine),
    0 calc(6 * var(--k)) calc(16 * var(--k)) var(--tray-shadow);
  transition:
    top var(--close-time) var(--close-spring),
    width var(--close-time) var(--close-spring),
    height var(--close-time) var(--close-spring);
}
.root[data-open='true'] .tray {
  top: calc(-183.5 * var(--k));
  width: calc(368 * var(--k));
  height: calc(128 * var(--k));
  transition:
    top var(--open-time) var(--tray-spring),
    width var(--open-time) var(--tray-spring),
    height var(--open-time) var(--tray-spring);
}

/* The dot's own button, over the closed dot. */
.toggle {
  position: absolute;
  top: calc(-135.5 * var(--k));
  left: calc(40 * var(--k));
  width: calc(88 * var(--k));
  height: calc(88 * var(--k));
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
}
.toggle:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.root[data-open='true'] .toggle {
  pointer-events: none;
}

/* The options, in a row; closed, they gather into the dot, the chosen one on top. */
.group {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  outline: none;
}
.option {
  --c: var(--online);
  --c-dot: var(--online-dot);
  position: absolute;
  top: 50%;
  left: calc((var(--x) - 53) * var(--k));
  width: calc(106 * var(--k));
  height: calc(106 * var(--k));
  margin: calc(-53 * var(--k)) 0 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  overflow: hidden;
  background: var(--c-dot);
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.08);
  opacity: 0;
  cursor: pointer;
  pointer-events: none;
  transform: translateX(calc((44 - var(--x)) * var(--k))) scale(0.604);
  transition:
    transform var(--close-time) var(--close-spring),
    opacity 180ms ease-in,
    background-color 220ms ease;
}
.option[data-status='focus'] {
  --c: var(--focus-status);
  --c-dot: var(--focus-dot);
}
.option[data-status='busy'] {
  --c: var(--busy);
  --c-dot: var(--busy-dot);
}
.option[aria-checked='true'] {
  z-index: 1;
  opacity: 1;
}
.root[data-open='true'] .option {
  opacity: 1;
  pointer-events: auto;
  background: var(--c);
  transform: none;
  transition:
    transform var(--open-time) var(--tray-spring),
    opacity 260ms cubic-bezier(0.2, 0.7, 0.3, 1),
    background-color 220ms ease;
}
.option:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.icon {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  color: var(--icon);
  fill: var(--icon);
  opacity: 0;
  transition: opacity 120ms ease-in;
}
.root[data-open='true'] .icon {
  opacity: 1;
  transition: opacity 240ms ease-out 60ms;
}
/* The glint that sweeps the dot once a choice is made. */
.shine {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: linear-gradient(
    120deg,
    transparent 30%,
    rgb(255 255 255 / 0.62) 46%,
    rgb(255 255 255 / 0.3) 54%,
    transparent 68%
  );
  opacity: 0;
  pointer-events: none;
}

/* The label: a pill over the tray, as wide as its words, over the option it names. */
.labelSlot {
  position: absolute;
  top: calc(-268.5 * var(--k));
  left: 0;
  height: calc(74 * var(--k));
  pointer-events: none;
  transition:
    transform var(--open-time) var(--open-spring),
    width var(--open-time) var(--open-spring);
}
.labelSlot[data-instant='true'] {
  transition: none;
}
.label {
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: var(--label);
  box-shadow:
    0 0 0 0.5px var(--label-edge),
    0 calc(4 * var(--k)) calc(12 * var(--k)) var(--label-shadow);
  color: var(--label-ink);
  font:
    600 calc(36 * var(--k)) / 1 -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Inter,
    Roboto,
    sans-serif;
  letter-spacing: -0.015em;
  -webkit-font-smoothing: antialiased;
  white-space: nowrap;
  opacity: 0;
  filter: blur(calc(8 * var(--k)));
  transform: translateY(calc(14 * var(--k))) scale(0.8);
  transform-origin: 30% 100%;
  transition:
    opacity 140ms ease-in,
    filter 140ms ease-in,
    transform 160ms ease-in;
}
.labelSlot[data-shown='true'] .label {
  opacity: 1;
  filter: blur(0);
  transform: none;
  transition:
    opacity 160ms ease-out,
    filter 200ms ease-out,
    transform 260ms var(--open-spring);
}
/* The words: the new ones come in letter by letter over the old ones going out. */
.words {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding-top: calc(6 * var(--k));
}
.char {
  display: inline-block;
  white-space: pre;
}
.words[data-phase='in'] .char {
  animation: char-in 240ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
  animation-delay: calc(var(--i) * 16ms);
}
.words[data-phase='out'] .char {
  animation: char-out 200ms ease-in both;
  animation-delay: calc(var(--i) * 16ms);
}
@keyframes char-in {
  from {
    opacity: 0;
    filter: blur(calc(7 * var(--k)));
    transform: translateY(calc(6 * var(--k)));
  }
}
@keyframes char-out {
  to {
    opacity: 0;
    filter: blur(calc(7 * var(--k)));
    transform: translateY(calc(-6 * var(--k)));
  }
}
/* The words at rest, out of sight: what the pill's width is measured from. */
.measure {
  position: absolute;
  visibility: hidden;
  white-space: pre;
  letter-spacing: -0.015em;
  font:
    600 calc(36 * var(--k)) / 1 -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Inter,
    Roboto,
    sans-serif;
}

/* The demo pointer, over everything; one of its glyphs shows at a time. */
.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
}
.pointer {
  width: clamp(20px, calc(48 * var(--k)), 30px);
  height: clamp(20px, calc(48 * var(--k)), 30px);
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

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

@media (prefers-reduced-motion: reduce) {
  .avatar,
  .root[data-open='true'] .avatar,
  .tray,
  .root[data-open='true'] .tray,
  .option,
  .root[data-open='true'] .option,
  .labelSlot {
    transition-duration: 0s;
  }
  .label,
  .labelSlot[data-shown='true'] .label {
    filter: none;
    transform: none;
  }
  .words[data-phase='in'] .char,
  .words[data-phase='out'] .char {
    animation: none;
  }
  .words[data-phase='out'] {
    visibility: hidden;
  }
}
```

### status-avatar/avatar-art.tsx

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

import { useId } from 'react';

/*
 * The avatar's picture: an illustrated, made-up character in a leafy valley, drawn once in SVG
 * on a 240-unit square that the avatar's circle crops. A pale sky, two ranges of hills, a tree
 * leaning in from the left, and bushes with small flowers in front of a smiling, bearded
 * character in a dark tee.
 *
 * The foliage is many small leaves, laid out once at load from a seeded random sequence, so the
 * drawing is the same on the server and in the browser, on every render.
 */

/** A small seeded random sequence (mulberry32), so the leaves land in the same places each time. */
function sequence(seed: number) {
  let state = seed >>> 0;
  return () => {
    state = (state + 0x6d2b79f5) >>> 0;
    let t = state;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

type Leaf = { x: number; y: number; size: number; angle: number; fill: string };

/** One leaf, its stem at the origin, pointing right, `size` long. */
const leafPath = (size: number) => {
  const a = (size * 0.32).toFixed(1);
  const b = (size * 0.74).toFixed(1);
  const w = (size * 0.42).toFixed(1);
  return `M0 0C${a} -${w} ${b} -${w} ${size} 0C${b} ${w} ${a} ${w} 0 0Z`;
};

/** A bush: leaves fanned out from a middle, darker inside and lighter on top. */
function bush(
  random: () => number,
  cx: number,
  cy: number,
  radius: number,
  count: number,
  palette: string[],
): Leaf[] {
  const leaves: Leaf[] = [];
  for (let i = 0; i < count; i += 1) {
    const turn = random() * Math.PI * 2;
    const reach = Math.sqrt(random()) * radius;
    const x = cx + Math.cos(turn) * reach;
    const y = cy + Math.sin(turn) * reach * 0.8;
    // Leaves point outwards, give or take.
    const angle = (turn * 180) / Math.PI + (random() - 0.5) * 70;
    // The lighter greens sit on top of the bush, the darker ones low and inside.
    const light = Math.min(
      palette.length - 1,
      Math.floor((1 - (y - cy + radius) / (2 * radius)) * palette.length + random() * 1.4),
    );
    leaves.push({
      x,
      y,
      size: 11 + random() * 11,
      angle,
      fill: palette[Math.max(0, light)],
    });
  }
  // Drawn from the back: the higher leaves first, so the ones below overlap them.
  return leaves.sort((a, b) => a.y - b.y);
}

const GREENS = ['#2f6a3c', '#3f7f44', '#56964a', '#73ad55', '#9cc768'];
const TREE_GREENS = ['#2c6439', '#3d7a42', '#5a9a4c', '#86bb5e'];

const random = sequence(20261018);
const CANOPY = [
  ...bush(random, 14, 14, 36, 70, TREE_GREENS),
  ...bush(random, 70, 2, 28, 40, TREE_GREENS),
  ...bush(random, 200, -2, 24, 30, TREE_GREENS),
];
const BACK_BUSHES = [
  ...bush(random, 18, 146, 40, 90, GREENS),
  ...bush(random, 222, 138, 38, 84, GREENS),
];
const FRONT_BUSHES = [
  ...bush(random, 14, 206, 46, 100, GREENS),
  ...bush(random, 56, 250, 30, 46, GREENS),
  ...bush(random, 186, 252, 30, 46, GREENS),
  ...bush(random, 228, 198, 46, 100, GREENS),
];

/** Small five-petal flowers: where, how big, and the petals' colour. */
const FLOWERS: { x: number; y: number; r: number; petal: string }[] = [
  { x: 30, y: 146, r: 6, petal: '#e8707c' },
  { x: 16, y: 166, r: 4.5, petal: '#e8707c' },
  { x: 46, y: 160, r: 4, petal: '#fff6e6' },
  { x: 58, y: 132, r: 3.6, petal: '#fff6e6' },
  { x: 200, y: 128, r: 4, petal: '#fff6e6' },
  { x: 222, y: 152, r: 3.6, petal: '#fff6e6' },
  { x: 212, y: 186, r: 4.4, petal: '#f3a3ac' },
  { x: 40, y: 206, r: 4.2, petal: '#fff6e6' },
  { x: 196, y: 214, r: 3.8, petal: '#fff6e6' },
];

const PETALS = [0, 72, 144, 216, 288];

function Leaves({ leaves }: { leaves: Leaf[] }) {
  return leaves.map((leaf, index) => (
    <path
      key={index}
      d={leafPath(leaf.size)}
      fill={leaf.fill}
      transform={`translate(${leaf.x.toFixed(1)} ${leaf.y.toFixed(1)}) rotate(${leaf.angle.toFixed(0)})`}
    />
  ));
}

export function AvatarArt({ className }: { className?: string }) {
  const id = useId();
  const sky = `${id}-sky`;
  const skin = `${id}-skin`;
  return (
    <svg className={className} viewBox="0 0 240 240" aria-hidden="true" focusable="false">
      <defs>
        <linearGradient id={sky} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#b9dcdc" />
          <stop offset="0.5" stopColor="#dfe6d6" />
          <stop offset="0.75" stopColor="#f6e2c8" />
        </linearGradient>
        <linearGradient id={skin} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#f0bb94" />
          <stop offset="1" stopColor="#e19e78" />
        </linearGradient>
      </defs>

      {/* Sky, and two ranges of hills. */}
      <rect width="240" height="240" fill={`url(#${sky})`} />
      <path
        d="M0 118C26 98 52 92 82 104C104 88 132 80 160 94C186 82 214 84 240 100V240H0Z"
        fill="#9fc7ba"
      />
      <path
        d="M0 134C30 118 58 116 86 126C120 112 168 110 198 122C214 116 228 116 240 120V240H0Z"
        fill="#7eb196"
      />

      {/* The tree, leaning in from the top left. */}
      <path
        d="M-6 92C8 70 18 46 30 30C42 14 56 4 70 -6L80 2C64 12 52 22 42 36C30 52 20 76 8 100Z"
        fill="#6e4a33"
      />
      <Leaves leaves={CANOPY} />

      {/* Bushes behind the character, on dark masses. */}
      <ellipse cx="18" cy="160" rx="40" ry="36" fill="#3f7f44" />
      <ellipse cx="222" cy="150" rx="38" ry="34" fill="#3f7f44" />
      <Leaves leaves={BACK_BUSHES} />

      {/* The character, a little larger than drawn: tee, neck, head, hair and beard. */}
      <g transform="translate(120 112) scale(1.2) translate(-120 -108)">
        <path d="M36 250C40 198 68 170 102 162H138C172 170 200 198 204 250Z" fill="#2a292e" />
        <path d="M102 162C108 174 132 174 138 162" fill="none" stroke="#3a393f" strokeWidth="3" />
        <path d="M106 138H134V164C128 172 112 172 106 164Z" fill="#d68f69" />
        <ellipse cx="91" cy="113" rx="6" ry="8.5" fill="#e3a27c" />
        <ellipse cx="149" cy="113" rx="6" ry="8.5" fill="#e3a27c" />
        <ellipse cx="120" cy="110" rx="29" ry="35" fill={`url(#${skin})`} />
        {/* Cheeks. */}
        <ellipse cx="104" cy="122" rx="6" ry="3.5" fill="#e88a7a" opacity="0.35" />
        <ellipse cx="136" cy="122" rx="6" ry="3.5" fill="#e88a7a" opacity="0.35" />
        {/* Beard and moustache, around an open smile. */}
        <path
          d="M91.5 110C91 136 102 152 120 153.5C138 152 149 136 148.5 110C147 122 143 129 136 131C130 126 110 126 104 131C97 129 93 122 91.5 110Z"
          fill="#3b2920"
        />
        <path d="M109 131.5Q120 145 131 131.5Q120 134.5 109 131.5Z" fill="#7a2f2a" />
        <path d="M110.5 132Q120 135 129.5 132L128.5 134.5Q120 136.6 111.5 134.5Z" fill="#fff" />
        {/* Nose. */}
        <path
          d="M119 112C118 118 116 121 118 123C120 124 123 123.5 124 122"
          fill="none"
          stroke="#c97f5c"
          strokeWidth="1.8"
          strokeLinecap="round"
        />
        {/* Eyes, with a glint, and brows. */}
        <ellipse cx="108.5" cy="109" rx="3" ry="3.4" fill="#2a1c17" />
        <ellipse cx="131.5" cy="109" rx="3" ry="3.4" fill="#2a1c17" />
        <circle cx="109.5" cy="107.8" r="1" fill="#fff" />
        <circle cx="132.5" cy="107.8" r="1" fill="#fff" />
        <path
          d="M101 101.5Q108 97 115 100"
          fill="none"
          stroke="#3b2920"
          strokeWidth="2.6"
          strokeLinecap="round"
        />
        <path
          d="M125 100Q132 97 139 101.5"
          fill="none"
          stroke="#3b2920"
          strokeWidth="2.6"
          strokeLinecap="round"
        />
        {/* Tousled hair. */}
        <path
          d="M90 108C86 88 92 72 106 66C110 58 124 56 132 62C144 60 156 70 154 86C156 94 153 102 150 108C148 96 144 90 138 88C130 93 112 93 100 87C95 92 92 99 90 108Z"
          fill="#3a2820"
        />
        <circle cx="104" cy="66" r="7" fill="#3a2820" />
        <circle cx="118" cy="60" r="8" fill="#3a2820" />
        <circle cx="134" cy="62" r="7.5" fill="#3a2820" />
        <circle cx="146" cy="72" r="7" fill="#3a2820" />
        <circle cx="94" cy="78" r="6" fill="#3a2820" />
        <path
          d="M108 64C114 60 122 60 126 64"
          fill="none"
          stroke="#5a3d30"
          strokeWidth="2"
          strokeLinecap="round"
        />
        <path
          d="M132 68C138 68 144 72 146 78"
          fill="none"
          stroke="#5a3d30"
          strokeWidth="2"
          strokeLinecap="round"
        />
      </g>

      {/* Bushes in front, on dark masses, and flowers. */}
      <ellipse cx="14" cy="220" rx="46" ry="40" fill="#356f3d" />
      <ellipse cx="228" cy="212" rx="46" ry="40" fill="#356f3d" />
      <Leaves leaves={FRONT_BUSHES} />
      {FLOWERS.map((flower, index) => (
        <g key={index} transform={`translate(${flower.x} ${flower.y})`}>
          {PETALS.map((turn) => (
            <circle
              key={turn}
              cx={0}
              cy={-flower.r * 0.62}
              r={flower.r * 0.5}
              fill={flower.petal}
              transform={`rotate(${turn})`}
            />
          ))}
          <circle r={flower.r * 0.32} fill="#f2c14e" />
        </g>
      ))}
    </svg>
  );
}
```

### status-avatar/demo-pointer.tsx

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

'use client';

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

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it (nothing here uses
 * them yet). The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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