# Account menu

> An avatar that opens an account menu with status, number-key hints and two submenus that open to the left.

The avatar at the end of an app’s header opens the account’s menu: who is signed in with a status chip, Update status, four entries with number keys, an offer, links out, and on a grey ledge underneath, Switch space and Log out. Update status and Switch space open submenus to the left on hover or with the arrow keys, kept open by a safe triangle while the pointer crosses other entries. Appearance flips the menu between light and dark.

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

Category: Navigation.

## Anatomy

1. **Avatar**: The menu button at the end of the header: a 34 px round button with the person’s avatar, glowing orange while the menu is open.
2. **Account**: Who is signed in: a larger avatar, the name and the email, hidden from screen readers, which hear them in the menu’s own name.
3. **Status chip**: The current status in a small tag, tinted green, amber or red for Online, Away and Do not disturb, grey for Offline.
4. **Entry**: A menu item: an icon, the label and something at its end. Update status, which opened the submenu, stays highlighted as a raised row.
5. **Key hint**: The key that chooses the entry while the menu has focus: 1 to 4 and S, also given by `aria-keyshortcuts`.
6. **Offer**: The `offer` badge on Upgrade to Premium, in the accent colour; an empty string hides it.
7. **Ledge**: The grey strip under the white card with Switch space, showing the current space’s tile, and Log out.
8. **Submenu**: Set status, opened to the menu’s left with its first entry level with Update status; the current status is a checked radio.

## When to use

- The signed-in person’s own corner of an app: their profile, settings, appearance and logging out, behind their avatar at the end of the header.
- Account-level choices with a few values each, such as a presence status or the workspace in use, kept one step away in a submenu.
- Frequent entries that deserve a key: number keys and a letter choose them without leaving the keyboard.
- Apps with several workspaces or teams, where switching space belongs with the account rather than in the navigation.
- Menus long enough that their submenus are reached by crossing other entries, where a safe triangle keeps the pointer’s path forgiving.

## When not to use

- The app’s main navigation between pages. Use a sidebar or tabs, which stay on screen.
- Actions on a selected item, such as a row or a file. Use a context menu or a row’s own overflow menu.
- Signing in, for someone who is not signed in yet. Use a plain Sign in button that opens the login.
- A full settings screen with forms. Use a settings page, and link to it from here.
- A choice between many spaces that needs search. Use a command palette or a space picker dialog.
- Touch-only screens where hover intent means nothing. Use a sheet that slides up, with its submenus as pages.

## Installation

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

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

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

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

## Usage

```tsx
import { AccountMenu } from './account-menu';

export function Header() {
  return (
    <AccountMenu
      name="Nora Lindell"
      email="nora@planko.app"
      defaultOpen={false}
      autoplay={false}
      onSelect={(id) => console.log(id)}
    />
  );
}
```

## States

- **Closed**: `defaultOpen={false}`: only the header, the avatar ready to open the menu.
- **Open**: The menu under the avatar, nothing highlighted yet; the avatar glows.
- **Status submenu**: `defaultSubmenu="status"`: Set status beside Update status, which stays highlighted.
- **Space submenu**: `defaultSubmenu="space"`: the spaces with their tiles and ⌘⌃ keys, Add new space level with Switch space.
- **Status set**: `defaultStatus="away"`: the chip turns amber and Away is the checked radio.
- **Do not disturb**: `defaultStatus="busy"` with a second space in use: a red chip, and the ledge shows that space’s tile.
- **No offer**: `offer=""`: Upgrade to Premium keeps its accent but loses the badge.
- **Narrow**: At a phone’s width there is no room beside the menu, so a submenu overlaps it instead.
- **Light**: Every colour is a light-dark() token: `colorScheme="light"`, or a light page.
- **Dark**: `colorScheme="dark"`, or a dark page, or the Appearance entry: near-black card and ledge, lighter status tints.

## How it works

- It is a WAI-ARIA menu button: the avatar has aria-haspopup and aria-expanded, the menu and its submenus have role menu, groups and separators, and the entries are menu items (radios in the submenus). One entry at a time is highlighted, and when the reader drives it the highlight is the focus too (a roving focus).
- The pointer highlights the entry under it. Resting 110 ms on Update status or Switch space opens its submenu (hover intent); a click opens it at once.
- Leaving the entry for its submenu, the pointer may cross other entries. While it stays in the safe triangle, from where it left the entry to the submenu’s near and far corners, nothing else highlights; if it stops in there for 320 ms, the entry under it wins and the submenu closes.
- Submenus open to the left of the menu, placed by the entry that opens them: Update status beside its first status, Switch space with Add new space level with it. Where the stage is too narrow for both side by side, the submenu overlaps the menu instead.
- The menus stay in the page, inert while closed. They open in 260 ms on an ease-out curve, scaling from 0.96 out of the avatar’s corner, and close faster, in 180 ms; submenus slide in 8 px from the menu’s side.
- Keys: Enter, Space or ↓ on the avatar open the menu on its first entry and ↑ on its last; ↑ ↓ Home End move, letters jump to an entry, 1–4 and S choose theirs, → or ← opens a submenu and closes it again, ⌘⌃1–3 and ⌘⌃P switch space or add one, Escape closes and Tab leaves.
- The demo pointer runs through the same hover code as a real pointer: every frame it reads the entry under the arrow’s tip, so the hover intent and the safe triangle work for it too.
- Every colour is a light-dark() token on the root, so the menu follows the page’s color-scheme or the colorScheme prop, and Appearance sets data-color-scheme to the other scheme.

## API reference

- **name** (`string`, default `'Nora Lindell'`): Who is signed in.
- **email** (`string`, default `'nora@planko.app'`): Their email, under the name.
- **spaces** (`AccountSpace[]`, default `SPACES`): The spaces Switch space offers, each { id, name, tile } with tile a CSS background; the first three get ⌘⌃1–3.
- **defaultSpace** (`string`): The id of the space in use at first; the first by default.
- **defaultStatus** (`'online' | 'away' | 'busy' | 'offline'`, default `'offline'`): The status at first, shown on the chip.
- **offer** (`string`, default `'20% off'`): The badge on Upgrade to Premium; an empty string hides it.
- **defaultOpen** (`boolean`, default `true`): The menu is open at first.
- **defaultSubmenu** (`'status' | 'space'`): A submenu open at first, with the menu.
- **onSelect** (`(id: string) => void`): Called with an entry’s id when it is chosen: profile, settings, notifications, premium, referrals, download, news, help, switch-account, logout or add-space.
- **onStatusChange** (`(status: AccountStatus) => void`): Called when a status is chosen.
- **onSpaceChange** (`(id: string) => void`): Called with a space’s id when it is switched to.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer opens the menu and walks through it; any input hands it to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme; Appearance flips it.
- **className** (`string`): Extra class for the root.

## Accessibility

- The avatar is a menu button named “Account menu” and the user’s name, with aria-haspopup, aria-expanded and aria-controls. The menu is named with the name, email and status; its header is hidden from screen readers, which hear that name instead.
- Entries are menu items in groups with separators; Update status and Switch space say they open a menu and whether it is expanded, statuses and spaces are menu item radios with aria-checked, and hotkeys are given by aria-keyshortcuts (the key caps themselves are hidden).
- Focus is roving: arrow keys, Home, End and typeahead move it, Escape closes a submenu back to its entry or the menu back to the avatar, and Tab closes the menu. Closed menus are inert, so they are never reached.
- A polite live region says what happened: a status set, a space switched, an entry chosen, the appearance changed.
- The demo pointer is hidden from screen readers and never moves focus. Under reduced motion the menus only fade and the demo never plays.

## Source

### account-menu/account-menu.tsx

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

'use client';

import {
  Fragment,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type MouseEvent as ReactMouseEvent,
  type ReactElement,
  type ReactNode,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import * as Icon from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './account-menu.module.css';

/*
 * Account menu: the avatar at the end of an app's header opens the account's menu. A white card
 * holds who is signed in, their status and the account's entries, with number keys for the first
 * four; under it, on a grey ledge, Switch space and Log out. Two entries open a submenu to the
 * left: Update status (Online, Away, Do not disturb, Offline, which the status chip shows) and
 * Switch space (the spaces, each with its gradient tile and a ⌘⌃ shortcut, and Add new space).
 *
 * - It is a WAI-ARIA menu button. The avatar opens the menu (Enter, Space or ↓ on the first entry,
 *   ↑ on the last; a click on the menu itself). In it, ↑ ↓ Home End move a roving focus, letters
 *   jump to an entry (typeahead), 1–4 and S choose their entries, → or ← opens a submenu and
 *   ← → or Escape close it again, Escape closes the menu and Tab leaves it.
 * - The pointer highlights what it is over. Resting on an entry with a submenu opens it after a
 *   short pause (hover intent). Leaving that entry for the submenu, the pointer may cross other
 *   entries: while it stays in the triangle between where it left and the submenu's edges (the
 *   safe triangle), and for a moment after it stops in there, the submenu stays open.
 * - Appearance flips the component between light and dark, and leaves the menu open to show it.
 * - The menu and its submenus stay in the page, inert while closed, and fade and scale in and out
 *   by their transforms. A submenu is placed by the entry that opens it: Update status's beside
 *   its first entry, Switch space's with its last entry level with the one that opened it.
 * - While on screen and untouched, a demo pointer opens the menu and walks through it, using the
 *   same hover code as a real pointer (it reads what lies under the arrow every frame).
 * - Every colour is a light-dark() token on the root: it follows the page's color-scheme, the
 *   colorScheme prop, or the Appearance entry.
 */

export type AccountStatus = 'online' | 'away' | 'busy' | 'offline';

export type AccountSpace = {
  id: string;
  name: string;
  /** The tile's CSS background (a gradient). */
  tile: string;
};

type SubKind = 'status' | 'space';
type Level = 'main' | SubKind;
type Via = 'key' | 'pointer' | 'demo';
type IconComponent = (props: { className?: string }) => ReactElement;

type Entry = {
  id: string;
  label: string;
  icon: IconComponent;
  /** A key that chooses it while the menu has focus. */
  hotkey?: string;
  /** What sits at its end. */
  end?: 'chevron' | 'external' | 'offer' | 'tile';
  opens?: SubKind;
  muted?: boolean;
};

const CARD: Entry[][] = [
  [
    {
      id: 'status',
      label: 'Update status',
      icon: Icon.Smile,
      end: 'chevron',
      opens: 'status',
      muted: true,
    },
  ],
  [
    { id: 'profile', label: 'Your profile', icon: Icon.Profile, hotkey: '1' },
    { id: 'appearance', label: 'Appearance', icon: Icon.Sun, hotkey: '2' },
    { id: 'settings', label: 'Settings', icon: Icon.Gear, hotkey: '3' },
    { id: 'notifications', label: 'Notifications', icon: Icon.Bell, hotkey: '4' },
  ],
  [
    { id: 'premium', label: 'Upgrade to Premium', icon: Icon.Gem, end: 'offer' },
    { id: 'referrals', label: 'Referrals', icon: Icon.Gift },
  ],
  [
    { id: 'download', label: 'Download app', icon: Icon.Download },
    { id: 'news', label: 'What’s new?', icon: Icon.News, end: 'external' },
    { id: 'help', label: 'Get help?', icon: Icon.Help, end: 'external' },
  ],
  [{ id: 'switch-account', label: 'Switch account', icon: Icon.SwitchAccount, hotkey: 'S' }],
];
const LEDGE: Entry[] = [
  {
    id: 'switch-space',
    label: 'Switch space',
    icon: Icon.SwitchSpace,
    end: 'tile',
    opens: 'space',
  },
  { id: 'logout', label: 'Log out', icon: Icon.LogOut },
];
const ENTRIES = [...CARD.flat(), ...LEDGE];
const ENTRY = new Map(ENTRIES.map((entry) => [entry.id, entry]));
const HOTKEYS = new Map(
  ENTRIES.filter((entry) => entry.hotkey).map((entry) => [entry.hotkey!.toLowerCase(), entry.id]),
);
const TRIGGER: Record<SubKind, string> = { status: 'status', space: 'switch-space' };

const STATUSES: { id: AccountStatus; label: string }[] = [
  { id: 'online', label: 'Online' },
  { id: 'away', label: 'Away' },
  { id: 'busy', label: 'Do not disturb' },
  { id: 'offline', label: 'Offline' },
];
const STATUS_LABEL = new Map(STATUSES.map((status) => [status.id, status.label]));

/** Invented spaces, each with a soft mesh of colour for a tile. */
export const SPACES: AccountSpace[] = [
  {
    id: 'bright',
    name: 'Bright Studio',
    tile: 'radial-gradient(circle at 88% 6%, #fcc83a 0 14%, transparent 50%), radial-gradient(circle at 4% 50%, #f84f8f 0 14%, transparent 52%), radial-gradient(circle at 96% 60%, #b8b0d8 0 8%, transparent 40%), linear-gradient(180deg, #fa8a26 0%, #f6667a 40%, #7d40c6 70%, #1b2bc6 100%)',
  },
  {
    id: 'fjord',
    name: 'Fjord Accounting',
    tile: 'radial-gradient(ellipse 90% 55% at 30% 8%, #c86bff 0 30%, transparent 72%), radial-gradient(ellipse 85% 40% at 75% 46%, #f51ffd 0 32%, transparent 78%), linear-gradient(180deg, #d066ff 0%, #f02cf6 44%, #ff9c4c 68%, #ffad08 100%)',
  },
  {
    id: 'halcyon',
    name: 'Halcyon AI',
    tile: 'radial-gradient(circle at 92% 30%, #f8dc92 0 14%, transparent 52%), radial-gradient(circle at 94% 94%, #b4d0cf 0 10%, transparent 44%), radial-gradient(circle at 4% 96%, #7c3fd2 0 14%, transparent 56%), linear-gradient(135deg, #fd5584 0%, #f05db5 40%, #c37bbd 70%, #e9b19e 100%)',
  },
];

/** How long the pointer rests on an entry before its submenu opens, in ms. */
const INTENT = 110;
/** How long the safe triangle holds a submenu open once the pointer stops inside it, in ms. */
const GRACE = 320;
/** The gap between the menu and a submenu, and the least margin to the stage's edges, in px. */
const GAP = 10;
const MARGIN = 8;

/** Whether `point` is inside `polygon` (ray casting). */
function inside(point: Point, polygon: Point[]) {
  let hit = false;
  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
    const a = polygon[i];
    const b = polygon[j];
    if (
      a.y > point.y !== b.y > point.y &&
      point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x
    )
      hit = !hit;
  }
  return hit;
}

export function AccountMenu({
  name = 'Nora Lindell',
  email = 'nora@planko.app',
  spaces = SPACES,
  defaultSpace,
  defaultStatus = 'offline',
  offer = '20% off',
  defaultOpen = true,
  defaultSubmenu,
  onSelect,
  onStatusChange,
  onSpaceChange,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** Who is signed in. */
  name?: string;
  email?: string;
  /** The spaces Switch space offers; the first three get ⌘⌃1–3. */
  spaces?: AccountSpace[];
  /** The id of the space in use at first (the first one by default). */
  defaultSpace?: string;
  /** The status at first. */
  defaultStatus?: AccountStatus;
  /** The badge on Upgrade to Premium; an empty string hides it. */
  offer?: string;
  /** The menu is open at first. */
  defaultOpen?: boolean;
  /** A submenu open at first, with the menu. */
  defaultSubmenu?: SubKind;
  /** Called with an entry's id when it is chosen (Appearance and the submenus excepted). */
  onSelect?: (id: string) => void;
  onStatusChange?: (status: AccountStatus) => void;
  onSpaceChange?: (id: string) => void;
  /** While on screen and untouched, a demo pointer opens the menu and walks through it. */
  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>(5000);
  const uid = useId();
  const startSub = defaultOpen ? (defaultSubmenu ?? null) : null;
  const [open, setOpen] = useState(defaultOpen);
  const [sub, setSub] = useState<SubKind | null>(startSub);
  const [active, setActive] = useState<string | null>(startSub ? TRIGGER[startSub] : null);
  const [subActive, setSubActive] = useState<string | null>(null);
  const [status, setStatus] = useState<AccountStatus>(defaultStatus);
  const [spaceId, setSpaceId] = useState(defaultSpace ?? spaces[0]?.id);
  // The Appearance entry overrides the colorScheme prop, until the prop changes.
  const [scheme, setScheme] = useState(colorScheme);
  const [schemeProp, setSchemeProp] = useState(colorScheme);
  if (schemeProp !== colorScheme) {
    setSchemeProp(colorScheme);
    setScheme(colorScheme);
  }
  const [message, setMessage] = useState('');

  const trigger = useRef<HTMLButtonElement>(null);
  const menu = useRef<HTMLDivElement>(null);
  const statusMenu = useRef<HTMLDivElement>(null);
  const spaceMenu = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  /** Every entry on screen, by level and id. */
  const items = useRef(new Map<string, HTMLElement>());
  /** The state as of now, for timers and the demo (updated as soon as it is set). */
  const live = useRef({ open, sub, active, subActive, status, spaceId, scheme });
  useLayoutEffect(() => {
    live.current = { open, sub, active, subActive, status, spaceId, scheme };
  });
  /** What to focus once the next render is on screen (a menu is inert until it opens). */
  const pendingFocus = useRef<(() => HTMLElement | null | undefined) | null>(null);
  const timers = useRef({ intent: 0, grace: 0, typed: 0 });
  /** The safe triangle, while the pointer is on its way to a submenu. */
  const grace = useRef<Point[] | null>(null);
  /** Where the pointer last was over the entry that opened the submenu. */
  const leftFrom = useRef<Point | null>(null);
  /** The entry the pointer stopped on inside the safe triangle, highlighted once it expires. */
  const held = useRef<{ level?: Level; id?: string; node: HTMLElement | null } | null>(null);
  const typed = useRef('');
  /** The scheme to restore if the demo stops between its two presses of Appearance. */
  const demoScheme = useRef<{ value: typeof scheme } | null>(null);

  const ids = {
    menu: `${uid}-menu`,
    status: `${uid}-status`,
    space: `${uid}-space`,
  };
  const subRef = (kind: SubKind) => (kind === 'status' ? statusMenu : spaceMenu);
  const itemKey = (level: Level, id: string) => `${level}:${id}`;
  const item = (level: Level, id: string) => items.current.get(itemKey(level, id));
  const register = (level: Level, id: string) => (node: HTMLElement | null) => {
    if (node) items.current.set(itemKey(level, id), node);
    else items.current.delete(itemKey(level, id));
  };
  const space = spaces.find((each) => each.id === spaceId) ?? spaces[0];
  const statusLabel = STATUS_LABEL.get(status) ?? status;

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));
  const focusInside = () => {
    const focused = document.activeElement;
    return !!focused && focused !== document.body && !!root.current?.contains(focused);
  };
  const clearTimers = () => {
    window.clearTimeout(timers.current.intent);
    window.clearTimeout(timers.current.grace);
    grace.current = null;
    leftFrom.current = null;
    held.current = null;
  };

  const highlight = (id: string | null, focus: boolean) => {
    live.current.active = id;
    setActive(id);
    if (focus && id) item('main', id)?.focus({ preventScroll: true });
  };
  const highlightSub = (id: string | null, focus: boolean) => {
    const kind = live.current.sub;
    live.current.subActive = id;
    setSubActive(id);
    if (focus && id && kind) item(kind, id)?.focus({ preventScroll: true });
  };

  const openMenu = (focus: 'first' | 'last' | 'menu' | null) => {
    clearTimers();
    live.current.open = true;
    live.current.sub = null;
    setOpen(true);
    setSub(null);
    setSubActive(null);
    if (focus === 'first' || focus === 'last') {
      const id = focus === 'first' ? ENTRIES[0].id : ENTRIES[ENTRIES.length - 1].id;
      highlight(id, false);
      pendingFocus.current = () => item('main', id);
    } else {
      highlight(null, false);
      if (focus === 'menu') pendingFocus.current = () => menu.current;
    }
  };
  const closeMenu = (restore: boolean) => {
    clearTimers();
    live.current.open = false;
    live.current.sub = null;
    setOpen(false);
    setSub(null);
    highlight(null, false);
    setSubActive(null);
    if (restore) trigger.current?.focus({ preventScroll: true });
  };
  const openSub = (kind: SubKind, focus: boolean) => {
    clearTimers();
    live.current.sub = kind;
    setSub(kind);
    highlight(TRIGGER[kind], false);
    if (focus) {
      const current = kind === 'status' ? live.current.status : live.current.spaceId;
      const id = current && item(kind, current) ? current : null;
      live.current.subActive = id;
      setSubActive(id);
      pendingFocus.current = () => (id ? item(kind, id) : subRef(kind).current);
    } else {
      live.current.subActive = null;
      setSubActive(null);
    }
  };
  const closeSub = (focusTrigger: boolean) => {
    const kind = live.current.sub;
    if (!kind) return;
    const hadFocus = !!subRef(kind).current?.contains(document.activeElement);
    clearTimers();
    live.current.sub = null;
    setSub(null);
    highlightSub(null, false);
    if (focusTrigger) highlight(TRIGGER[kind], true);
    else if (hadFocus) menu.current?.focus({ preventScroll: true });
  };

  /** Flips light and dark, from whatever the component shows now. */
  const flipScheme = () => {
    const node = root.current;
    let dark = false;
    if (node) {
      const value = getComputedStyle(node).colorScheme;
      dark = value.includes('dark');
      if (dark && value.includes('light'))
        dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    }
    const next = dark ? 'light' : 'dark';
    live.current.scheme = next;
    setScheme(next);
    return next;
  };

  const choose = (level: Level, id: string, via: Via) => {
    const restore = via !== 'demo' && focusInside();
    if (level === 'main') {
      const entry = ENTRY.get(id);
      if (!entry) return;
      if (entry.opens) {
        if (live.current.sub !== entry.opens || via === 'key') openSub(entry.opens, via === 'key');
        return;
      }
      if (id === 'appearance') {
        // The demo flips and flips back: the second time it puts back what was there, so a
        // component that followed the page still does.
        const saved = via === 'demo' ? demoScheme.current : null;
        if (via === 'demo') demoScheme.current = saved ? null : { value: live.current.scheme };
        const next = flipScheme();
        if (saved) {
          live.current.scheme = saved.value;
          setScheme(saved.value);
        }
        announce(next === 'dark' ? 'Dark appearance' : 'Light appearance');
        return;
      }
      onSelect?.(id);
      announce(`${entry.label.replace(/\?$/, '')} chosen`);
      closeMenu(restore);
    } else if (level === 'status') {
      const next = id as AccountStatus;
      live.current.status = next;
      setStatus(next);
      onStatusChange?.(next);
      announce(`Status: ${STATUS_LABEL.get(next)}`);
      closeSub(via === 'key');
      if (via !== 'key') highlight(TRIGGER.status, false);
    } else if (id === 'add-space') {
      onSelect?.('add-space');
      announce('Add new space chosen');
      closeMenu(restore);
    } else {
      const next = spaces.find((each) => each.id === id);
      if (!next) return;
      live.current.spaceId = id;
      setSpaceId(id);
      onSpaceChange?.(id);
      announce(`Switched to ${next.name}`);
      closeMenu(restore);
    }
  };

  /** Highlights what the pointer is over, opens submenus on intent, closes them past the triangle. */
  const applyHover = (level: Level | undefined, id: string | undefined, focus: boolean) => {
    if (level !== 'main' || !id) {
      // Off the entries: the highlight goes, unless it marks an open submenu's entry.
      if (!live.current.sub && live.current.active) highlight(null, false);
      window.clearTimeout(timers.current.intent);
      return;
    }
    const entry = ENTRY.get(id);
    if (live.current.sub && entry?.opens !== live.current.sub) closeSub(false);
    if (live.current.active !== id) highlight(id, focus);
    else if (focus && document.activeElement !== item('main', id))
      item('main', id)?.focus({ preventScroll: true });
    window.clearTimeout(timers.current.intent);
    const opens = entry?.opens;
    if (opens && live.current.sub !== opens)
      timers.current.intent = window.setTimeout(() => {
        if (live.current.open && live.current.active === id) openSub(opens, false);
      }, INTENT);
  };
  const hoverAt = (x: number, y: number, target: Element | null, demo: boolean) => {
    if (!live.current.open) return;
    const node = target?.closest<HTMLElement>('[data-item]') ?? null;
    const own = node && root.current?.contains(node) ? node : null;
    const level = own?.dataset.level as Level | undefined;
    const id = own?.dataset.item;
    const focus = !demo && focusInside();
    const kind = live.current.sub;
    if (kind && level === kind) {
      // In the submenu: the way there is over.
      window.clearTimeout(timers.current.grace);
      grace.current = null;
      held.current = null;
      if (live.current.subActive !== id) highlightSub(id ?? null, focus);
      return;
    }
    if (kind && level === 'main' && id === TRIGGER[kind]) {
      window.clearTimeout(timers.current.grace);
      grace.current = null;
      held.current = null;
      leftFrom.current = { x, y };
      applyHover(level, id, focus);
      return;
    }
    if (kind) {
      // Between the entry and its submenu: the safe triangle, from where the pointer left to
      // the submenu's near and far corners.
      const panel = subRef(kind).current;
      if (!grace.current && leftFrom.current && panel) {
        const box = panel.getBoundingClientRect();
        const toLeft = box.right <= leftFrom.current.x + 1;
        const near = toLeft ? box.right : box.left;
        const far = toLeft ? box.left : box.right;
        const apex = { x: leftFrom.current.x + (toLeft ? 4 : -4), y: leftFrom.current.y };
        grace.current = [
          apex,
          { x: near, y: box.top },
          { x: far, y: box.top },
          { x: far, y: box.bottom },
          { x: near, y: box.bottom },
        ];
      }
      if (grace.current && inside({ x, y }, grace.current)) {
        held.current = { level, id, node: own };
        window.clearTimeout(timers.current.grace);
        timers.current.grace = window.setTimeout(() => {
          const last = held.current;
          grace.current = null;
          leftFrom.current = null;
          held.current = null;
          if (last) applyHover(last.level, last.id, !demo && focusInside());
        }, GRACE);
        return;
      }
      window.clearTimeout(timers.current.grace);
      grace.current = null;
      leftFrom.current = null;
      held.current = null;
    }
    applyHover(level, id, focus);
  };

  /** The next entry from `from` in `list`: by a step, to an end, or by what was typed. */
  const step = (list: { id: string; label: string }[], from: string | null, key: string) => {
    const at = list.findIndex((each) => each.id === from);
    if (key === 'ArrowDown') return list[(at + 1) % list.length]?.id;
    if (key === 'ArrowUp') return list[(at - 1 + list.length) % list.length]?.id;
    if (key === 'Home') return list[0]?.id;
    if (key === 'End') return list[list.length - 1]?.id;
    // Typeahead: letters typed within half a second add up.
    window.clearTimeout(timers.current.typed);
    timers.current.typed = window.setTimeout(() => (typed.current = ''), 600);
    const text = (typed.current += key.toLowerCase());
    const start = text.length === 1 ? at + 1 : Math.max(at, 0);
    for (let i = 0; i < list.length; i++) {
      const each = list[(start + i) % list.length];
      if (each.label.toLowerCase().startsWith(text)) return each.id;
    }
    return undefined;
  };
  const focused = (level: Level) => {
    const node = document.activeElement as HTMLElement | null;
    return node?.dataset.level === level ? (node.dataset.item ?? null) : null;
  };
  /** ⌘⌃1–3 and ⌘⌃P, from anywhere in the open menu. */
  const spaceShortcut = (event: ReactKeyboardEvent) => {
    if (!event.metaKey || !event.ctrlKey || event.altKey) return false;
    const key = event.key.toLowerCase();
    const index = Number(key) - 1;
    if (index >= 0 && index < Math.min(3, spaces.length)) {
      event.preventDefault();
      choose('space', spaces[index].id, 'key');
      return true;
    }
    if (key === 'p') {
      event.preventDefault();
      choose('space', 'add-space', 'key');
      return true;
    }
    return false;
  };
  const isNavigation = (key: string) => ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(key);
  const isLetter = (event: ReactKeyboardEvent) =>
    event.key.length === 1 &&
    event.key !== ' ' &&
    !event.metaKey &&
    !event.ctrlKey &&
    !event.altKey;

  const onMenuKey = (event: ReactKeyboardEvent) => {
    if (spaceShortcut(event)) return;
    const current = focused('main') ?? live.current.active;
    const { key } = event;
    if (key === 'Escape') {
      event.preventDefault();
      closeMenu(true);
    } else if (key === 'Tab') {
      // Focus goes back to the avatar, and Tab carries on from there.
      closeMenu(true);
    } else if ((key === 'ArrowRight' || key === 'ArrowLeft') && current) {
      const opens = ENTRY.get(current)?.opens;
      if (opens) {
        event.preventDefault();
        openSub(opens, true);
      }
    } else if (isNavigation(key) || isLetter(event)) {
      const hot = HOTKEYS.get(key.toLowerCase());
      if (hot && !isNavigation(key) && !typed.current) {
        event.preventDefault();
        highlight(hot, true);
        choose('main', hot, 'key');
        return;
      }
      const next = step(ENTRIES, current, key);
      if (next) {
        event.preventDefault();
        if (live.current.sub) closeSub(false);
        highlight(next, true);
      }
    }
  };
  const onSubKey = (kind: SubKind) => (event: ReactKeyboardEvent) => {
    if (spaceShortcut(event)) return;
    const list =
      kind === 'status'
        ? STATUSES
        : [
            ...spaces.map((each) => ({ id: each.id, label: each.name })),
            { id: 'add-space', label: 'Add new space' },
          ];
    const { key } = event;
    if (key === 'Escape' || key === 'ArrowLeft' || key === 'ArrowRight') {
      event.preventDefault();
      closeSub(true);
    } else if (key === 'Tab') {
      closeMenu(true);
    } else if (isNavigation(key) || isLetter(event)) {
      const next = step(list, focused(kind) ?? live.current.subActive, key);
      if (next) {
        event.preventDefault();
        highlightSub(next, true);
      }
    }
  };

  const onTriggerClick = (event: ReactMouseEvent) => {
    if (live.current.open) closeMenu(event.detail === 0);
    else openMenu(event.detail === 0 ? 'first' : 'menu');
  };
  const onTriggerKey = (event: ReactKeyboardEvent) => {
    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();
      openMenu(event.key === 'ArrowDown' ? 'first' : 'last');
    } else if (event.key === 'Escape' && live.current.open) {
      event.preventDefault();
      closeMenu(true);
    }
  };

  // Focus what a render asked for, once it is on screen and no longer inert.
  useLayoutEffect(() => {
    const target = pendingFocus.current;
    if (!target) return;
    pendingFocus.current = null;
    target()?.focus({ preventScroll: true });
  });

  // A submenu sits beside the menu, to its left, level with the entry that opened it; where the
  // stage is too narrow, it overlaps the menu instead.
  const placeSub = useEffectEvent((kind: SubKind) => {
    const stage = root.current;
    const panel = subRef(kind).current;
    const card = menu.current;
    const opener = item('main', TRIGGER[kind]);
    if (!stage || !panel || !card || !opener) return;
    const frame = stage.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    const row = opener.getBoundingClientRect();
    // The submenu's entry that lines up with the opener: its first, or for spaces its last.
    const rows = panel.querySelectorAll<HTMLElement>('[data-item]');
    const match = kind === 'status' ? rows[0] : rows[rows.length - 1];
    const offset = match ? match.offsetTop : 0;
    const width = panel.offsetWidth;
    const height = panel.offsetHeight;
    let left = card.offsetLeft - GAP - width;
    if (left < MARGIN)
      left = Math.max(MARGIN, Math.min(card.offsetLeft + 24, stage.offsetWidth - width - MARGIN));
    let top = (row.top - frame.top) / scale - offset;
    top = Math.max(MARGIN, Math.min(top, stage.offsetHeight - height - MARGIN));
    panel.style.left = `${left}px`;
    panel.style.top = `${top}px`;
  });
  useLayoutEffect(() => {
    const stage = root.current;
    if (!sub || !stage) return;
    placeSub(sub);
    const observer = new ResizeObserver(() => placeSub(sub));
    observer.observe(stage);
    return () => observer.disconnect();
  }, [sub, root]);

  // A press outside the menus closes them.
  const pressOutside = useEffectEvent((event: PointerEvent) => {
    const target = event.target as Node;
    if ([menu, statusMenu, spaceMenu, trigger].some((each) => each.current?.contains(target)))
      return;
    closeMenu(false);
  });
  useEffect(() => {
    if (!open) return;
    document.addEventListener('pointerdown', pressOutside);
    return () => document.removeEventListener('pointerdown', pressOutside);
  }, [open]);

  useEffect(
    () => () => {
      window.clearTimeout(timers.current.intent);
      window.clearTimeout(timers.current.grace);
      window.clearTimeout(timers.current.typed);
    },
    [],
  );

  // The demo: a pointer opens the menu, flips the appearance and back, sets a status through its
  // submenu, then crosses the safe triangle to switch spaces, which closes the menu.
  const demoHover = useEffectEvent((x: number, y: number) =>
    hoverAt(x, y, document.elementFromPoint(x, y), true),
  );
  const demoPress = useEffectEvent((x: number, y: number) => {
    const target = document.elementFromPoint(x, y);
    if (target && trigger.current?.contains(target)) {
      if (live.current.open) closeMenu(false);
      else openMenu(null);
      return;
    }
    const node = target?.closest<HTMLElement>('[data-item]');
    if (node?.dataset.level && node.dataset.item)
      choose(node.dataset.level as Level, node.dataset.item, 'demo');
  });
  const demoState = useEffectEvent(() => ({
    open: live.current.open,
    status: live.current.status,
    spaceId: live.current.spaceId,
    spaces: spaces.map((each) => each.id),
  }));
  const demoItem = useEffectEvent((level: Level, id: string) => item(level, id) ?? null);
  const demoEnd = useEffectEvent(() => {
    const saved = demoScheme.current;
    demoScheme.current = null;
    if (saved) {
      live.current.scheme = saved.value;
      setScheme(saved.value);
    }
  });
  const demo = autoplay && playing;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    const avatar = trigger.current;
    if (!demo || !stage || !arrow || !avatar) return;
    const tip = () => {
      const box = arrow.getBoundingClientRect();
      return { x: box.left + box.width * 0.15, y: box.top + box.height * 0.1 };
    };
    // Every frame, the hover code reads what lies under the arrow.
    let frame = 0;
    let last = '';
    const track = () => {
      frame = requestAnimationFrame(track);
      const at = tip();
      const key = `${Math.round(at.x)},${Math.round(at.y)}`;
      if (key === last) return;
      last = key;
      demoHover(at.x, at.y);
    };
    frame = requestAnimationFrame(track);

    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      /** A point inside `node`, in the stage's own px. */
      const on = (node: Element | null, fx = 0.5, fy = 0.5): Point => {
        const frameBox = stage.getBoundingClientRect();
        const scale = frameBox.width / stage.offsetWidth || 1;
        if (!node) return { x: stage.offsetWidth / 2, y: stage.offsetHeight / 2 };
        const box = node.getBoundingClientRect();
        return {
          x: (box.left + box.width * fx - frameBox.left) / scale,
          y: (box.top + box.height * fy - frameBox.top) / scale,
        };
      };
      const press = async () => {
        // The glyphs dip, not the box: a scale on the box would scale its flight too.
        for (const glyph of arrow.children)
          glyph.animate([{ scale: '1' }, { scale: '0.82' }, { scale: '1' }], {
            duration: 240,
            easing: 'ease-out',
          });
        await wait(90);
        const at = tip();
        demoPress(at.x, at.y);
        await wait(150);
      };
      for (let round = 0; ; round++) {
        if (!demoState().open) {
          await glide(on(avatar, 0.55, 0.6), 900);
          glyph('hand');
          await wait(260);
          await press();
          glyph('arrow');
          await wait(650);
        }
        await glide(on(demoItem('main', 'profile'), 0.3, 0.55), 750);
        await wait(420);
        await glide(on(demoItem('main', 'appearance'), 0.34, 0.5), 420);
        await wait(520);
        await press();
        await wait(1500);
        await press();
        await wait(800);
        await glide(on(demoItem('main', 'status'), 0.55, 0.5), 650);
        await wait(600);
        const status = demoState().status === 'away' ? 'offline' : 'away';
        await glide(on(demoItem('status', status), 0.42, 0.5), 720);
        await wait(420);
        await press();
        await wait(1200);
        // From the far end of Switch space, the way to the submenu crosses Switch account.
        await glide(on(demoItem('main', 'switch-space'), 0.84, 0.5), 900);
        await wait(650);
        const { spaces: all, spaceId } = demoState();
        const target = all.find((id) => id !== spaceId) ?? all[0];
        await glide(on(demoItem('space', target), 0.4, 0.5), 820);
        await wait(520);
        await press();
        await wait(900);
        await glide({ x: stage.offsetWidth * 0.32, y: stage.offsetHeight * 0.72 }, 1000);
        await wait(1500);
      }
    });
    return () => {
      stop();
      cancelAnimationFrame(frame);
      demoEnd();
    };
  }, [demo, root]);

  const renderEnd = (entry: Entry) => {
    if (entry.hotkey)
      return (
        <kbd className={styles.kbd} data-part="hotkey" aria-hidden="true">
          {entry.hotkey}
        </kbd>
      );
    if (entry.end === 'chevron') return <Icon.Chevron className={styles.end} />;
    if (entry.end === 'external')
      return <Icon.External className={`${styles.end} ${styles.faint}`} />;
    if (entry.end === 'offer' && offer)
      return (
        <span className={styles.offer} data-part="offer">
          {offer}
        </span>
      );
    if (entry.end === 'tile' && space)
      return (
        <>
          <span className={styles.hidden}>: {space.name}</span>
          <span
            className={styles.tile}
            style={{ background: space.tile }}
            data-part="space-tile"
            aria-hidden="true"
          />
        </>
      );
    return null;
  };

  const renderEntry = (entry: Entry) => {
    const opens = entry.opens;
    return (
      <button
        key={entry.id}
        ref={register('main', entry.id)}
        type="button"
        role="menuitem"
        tabIndex={-1}
        className={styles.item}
        data-part="item"
        data-item={entry.id}
        data-level="main"
        data-highlighted={active === entry.id || undefined}
        data-muted={entry.muted || undefined}
        data-accent={entry.end === 'offer' || undefined}
        aria-haspopup={opens ? 'menu' : undefined}
        aria-expanded={opens ? sub === opens : undefined}
        aria-controls={opens ? ids[opens] : undefined}
        aria-keyshortcuts={entry.hotkey}
        onClick={(event) => choose('main', entry.id, event.detail === 0 ? 'key' : 'pointer')}
      >
        {entry.id === 'appearance' ? (
          <span className={styles.icon} aria-hidden="true">
            <Icon.Sun className={styles.sun} />
            <Icon.Moon className={styles.moon} />
          </span>
        ) : (
          <entry.icon className={styles.icon} />
        )}
        <span className={styles.label}>{entry.label}</span>
        {renderEnd(entry)}
      </button>
    );
  };

  const renderSubItem = (
    kind: SubKind,
    id: string,
    label: string,
    lead: ReactNode,
    end: ReactNode,
    radio: boolean,
    checked = false,
    shortcut?: string,
  ) => (
    <button
      key={id}
      ref={register(kind, id)}
      type="button"
      role={radio ? 'menuitemradio' : 'menuitem'}
      aria-checked={radio ? checked : undefined}
      aria-keyshortcuts={shortcut}
      tabIndex={-1}
      className={`${styles.item} ${styles.subItem}`}
      data-part="subitem"
      data-item={id}
      data-level={kind}
      data-current={checked || undefined}
      data-highlighted={sub === kind && subActive === id ? true : undefined}
      onClick={(event) => choose(kind, id, event.detail === 0 ? 'key' : 'pointer')}
    >
      {lead}
      <span className={styles.label}>{label}</span>
      {end}
    </button>
  );

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={scheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType === 'touch') return;
        take();
        hoverAt(event.clientX, event.clientY, event.target as Element, false);
      }}
      onPointerLeave={(event) => {
        if (event.pointerType !== 'touch') hoverAt(event.clientX, event.clientY, null, false);
      }}
    >
      {/* The app around the menu: tabs and cards, faint and decorative. */}
      <div className={styles.backdrop} data-part="backdrop" aria-hidden="true">
        <div className={styles.tabs}>
          <span className={styles.tab}>
            <Icon.Doc className={styles.tabIcon} />
            All docs
          </span>
          <span className={styles.tab}>
            <Icon.Star className={styles.tabIcon} />
            Favorites
          </span>
          <span className={styles.tab}>
            <Icon.Share className={styles.tabIcon} />
            Sharing
          </span>
          <span className={styles.tab}>
            <Icon.Trash className={styles.tabIcon} />
            Deleted
          </span>
        </div>
        <div className={styles.rule} />
        <div className={styles.cards}>
          {Array.from({ length: 12 }, (_, index) => (
            <div key={index} className={styles.ghostCard}>
              <div className={styles.ghostThumb} />
              <div className={styles.ghostBar} />
            </div>
          ))}
        </div>
      </div>

      <div className={styles.bar} data-part="header">
        <span className={`${styles.tool} ${styles.toolSquare}`} aria-hidden="true">
          <Icon.Search className={styles.toolIcon} />
        </span>
        <span className={`${styles.tool} ${styles.toolWide}`} aria-hidden="true">
          Feedback
        </span>
        <span className={styles.tool} aria-hidden="true">
          <Icon.Apps className={styles.toolIcon} />
          Apps
        </span>
        <span className={styles.divider} aria-hidden="true" />
        <button
          ref={trigger}
          type="button"
          className={styles.trigger}
          data-part="trigger"
          data-open={open}
          aria-label={`Account menu, ${name}`}
          aria-haspopup="menu"
          aria-expanded={open}
          aria-controls={ids.menu}
          onClick={onTriggerClick}
          onKeyDown={onTriggerKey}
        >
          <span className={`${styles.orb} ${styles.orbSmall}`} aria-hidden="true" />
        </button>
      </div>

      <div
        ref={menu}
        id={ids.menu}
        role="menu"
        aria-label={`${name}, ${email}, ${statusLabel}`}
        tabIndex={-1}
        className={styles.menu}
        data-part="menu"
        data-open={open}
        inert={!open}
        onKeyDown={onMenuKey}
      >
        <div className={styles.card} data-part="card">
          <div className={styles.who} data-part="account" aria-hidden="true">
            <span className={styles.orb} data-part="avatar" />
            <span className={styles.name}>{name}</span>
            <span className={styles.email}>{email}</span>
            <span className={styles.chip} data-part="status-chip" data-status={status}>
              {statusLabel}
            </span>
          </div>
          {CARD.map((group, index) => (
            <Fragment key={group[0].id}>
              {index > 0 && <div role="separator" className={styles.separator} />}
              <div role="group" className={styles.group}>
                {group.map(renderEntry)}
              </div>
            </Fragment>
          ))}
        </div>
        <div role="group" className={styles.ledge} data-part="ledge">
          {LEDGE.map(renderEntry)}
        </div>
      </div>

      <div
        ref={statusMenu}
        id={ids.status}
        role="menu"
        aria-label="Update status"
        tabIndex={-1}
        className={styles.sub}
        data-part="status-menu"
        data-open={sub === 'status'}
        inert={sub !== 'status'}
        onKeyDown={onSubKey('status')}
      >
        <p className={styles.subTitle} aria-hidden="true">
          Set status:
        </p>
        <div role="group" className={styles.subList}>
          {STATUSES.map((each) =>
            renderSubItem(
              'status',
              each.id,
              each.label,
              <span className={styles.dotSlot} aria-hidden="true">
                <span className={styles.dot} data-status={each.id} />
              </span>,
              status === each.id ? <Icon.Check className={styles.end} /> : null,
              true,
              status === each.id,
            ),
          )}
        </div>
      </div>

      <div
        ref={spaceMenu}
        id={ids.space}
        role="menu"
        aria-label="Switch space"
        tabIndex={-1}
        className={styles.sub}
        data-part="space-menu"
        data-open={sub === 'space'}
        inert={sub !== 'space'}
        onKeyDown={onSubKey('space')}
      >
        <p className={styles.subTitle} aria-hidden="true">
          Switch space:
        </p>
        <div role="group" className={styles.subList}>
          {spaces.map((each, index) =>
            renderSubItem(
              'space',
              each.id,
              each.name,
              <span
                className={styles.tileLarge}
                style={{ background: each.tile }}
                aria-hidden="true"
              />,
              index < 3 ? (
                <kbd className={`${styles.kbd} ${styles.kbdWide}`} aria-hidden="true">
                  ⌘⌃{index + 1}
                </kbd>
              ) : null,
              true,
              each.id === spaceId,
              index < 3 ? `Meta+Control+${index + 1}` : undefined,
            ),
          )}
          {renderSubItem(
            'space',
            'add-space',
            'Add new space',
            <Icon.PlusCircle className={`${styles.icon} ${styles.plus}`} />,
            <kbd className={`${styles.kbd} ${styles.kbdWide}`} aria-hidden="true">
              ⌘⌃P
            </kbd>,
            false,
            false,
            'Meta+Control+P',
          )}
        </div>
      </div>

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

### account-menu/account-menu.module.css

```css
/* Account menu (account-menu.tsx): an app's header and, faintly behind, its tabs and cards; the
   avatar opens the menu below it, and submenus open to the menu's left. Sizes are px at the
   drawing's own scale; the menu is 282 px wide.

   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), the one its
   colorScheme prop sets, or the one the Appearance entry flips to (data-color-scheme).

   Memory. The stage is one layout boundary of a fixed height, so nothing inside it lays out the
   page. The menus are always in the page, inert and hidden while closed; they open and close by
   opacity and transform, and the demo pointer moves by transforms. */

.root {
  /* The app's ground, the menu's white card and the grey ledge under it. */
  --page: #fff;
  --surface: #fff;
  --ledge: #f9f9f9;
  /* Words: the entries, the quieter lines and icons, the faintest icons. */
  --ink: #0a0b0c;
  --ink-2: #6c6e70;
  --ink-3: #a3a3a3;
  /* Lines between the groups, the menus' outline, and their lift. */
  --line: #f6f6f6;
  --edge: rgb(0 0 0 / 0.055);
  --shadow: rgb(0 0 0 / 0.13);
  --shadow-2: rgb(0 0 0 / 0.05);
  --card-edge: rgb(0 0 0 / 0.08);
  --card-shadow: rgb(0 0 0 / 0.05);
  /* The highlighted entry, raised; the entry in use, filled. */
  --raise: #fff;
  --raise-edge: #e8e8e8;
  --raise-shadow: rgb(0 0 0 / 0.05);
  --current: #f7f7f7;
  /* Key hints, the status chip, the offer. */
  --kbd: #fff;
  --kbd-edge: #ebebeb;
  --kbd-ink: #737373;
  --chip: #f8f8f8;
  --chip-ink: #5f6166;
  --accent: #2564ea;
  --accent-ink: #fff;
  /* The header's buttons, and the avatar's rim. */
  --tool: #fff;
  --tool-edge: #e6e6e6;
  --tool-ink: #4e5150;
  --tool-shadow: rgb(0 0 0 / 0.04);
  --rim: #dedede;
  /* The faint app behind. */
  --ghost: #f8f8f8;
  --ghost-edge: #f1f1f1;
  --ghost-ink: #a3a6a5;
  /* Statuses: their dots, and the chip's tint and words. */
  --online: #22a55a;
  --away: #f2a20c;
  --busy: #e5484d;
  --offline: #a1a1aa;
  --online-chip: #e8f7ee;
  --online-ink: #13753a;
  --away-chip: #fdf3e0;
  --away-ink: #9a5b05;
  --busy-chip: #fdeced;
  --busy-ink: #c32c31;
  /* The Appearance icon: the sun by day, the moon at night. */
  --sun: #6c6e70;
  --moon: transparent;
  /* The same on both schemes: the focus ring, and the demo's pointer. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  contain: size layout;
  width: 100%;
  height: 720px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--page);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font-family:
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Arial,
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --page: light-dark(#fff, #111113);
    --surface: light-dark(#fff, #1c1c1f);
    --ledge: light-dark(#f9f9f9, #151517);
    --ink: light-dark(#0a0b0c, #f1f1f3);
    --ink-2: light-dark(#6c6e70, #9d9da6);
    --ink-3: light-dark(#a3a3a3, #6b6b73);
    --line: light-dark(#f6f6f6, #28282c);
    --edge: light-dark(rgb(0 0 0 / 0.055), rgb(255 255 255 / 0.08));
    --shadow: light-dark(rgb(0 0 0 / 0.13), rgb(0 0 0 / 0.6));
    --shadow-2: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --card-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.07));
    --card-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.35));
    --raise: light-dark(#fff, #28282d);
    --raise-edge: light-dark(#e8e8e8, rgb(255 255 255 / 0.1));
    --raise-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --current: light-dark(#f7f7f7, #242428);
    --kbd: light-dark(#fff, #1c1c1f);
    --kbd-edge: light-dark(#ebebeb, #34343a);
    --kbd-ink: light-dark(#737373, #9d9da6);
    --chip: light-dark(#f8f8f8, #26262a);
    --chip-ink: light-dark(#5f6166, #b4b4bc);
    --accent: light-dark(#2564ea, #3b7cf6);
    --tool: light-dark(#fff, #18181b);
    --tool-edge: light-dark(#e6e6e6, #2c2c31);
    --tool-ink: light-dark(#4e5150, #c3c3ca);
    --tool-shadow: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
    --rim: light-dark(#dedede, #3a3a40);
    --ghost: light-dark(#f8f8f8, #17171a);
    --ghost-edge: light-dark(#f1f1f1, #1e1e22);
    --ghost-ink: light-dark(#a3a6a5, #55555c);
    --online-chip: light-dark(#e8f7ee, #12301f);
    --online-ink: light-dark(#13753a, #5fd08f);
    --away-chip: light-dark(#fdf3e0, #36270b);
    --away-ink: light-dark(#9a5b05, #f4b947);
    --busy-chip: light-dark(#fdeced, #3a1618);
    --busy-ink: light-dark(#c32c31, #ff7b7f);
    --sun: light-dark(#6c6e70, transparent);
    --moon: light-dark(transparent, #9d9da6);
  }
}
/* The colorScheme prop, or the Appearance entry. Without either, the page's color-scheme. */
.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 app behind: tabs under the header, a rule, and a grid of empty cards. */
.backdrop {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.tabs {
  position: absolute;
  top: 72px;
  left: 24px;
  display: flex;
  gap: 30px;
  white-space: nowrap;
}
.tab {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  color: var(--ghost-ink);
  font-size: 14px;
  line-height: 20px;
  letter-spacing: -0.01em;
}
.tabIcon {
  width: 16px;
  height: 16px;
}
.rule {
  position: absolute;
  top: 107px;
  right: 0;
  left: 0;
  height: 1px;
  background: var(--ghost-edge);
}
.cards {
  position: absolute;
  top: 166px;
  right: 24px;
  left: 24px;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 14px;
}
.ghostCard {
  height: 144px;
  box-sizing: border-box;
  padding: 9px;
  border-radius: 16px;
  box-shadow: inset 0 0 0 1px var(--ghost-edge);
}
.ghostThumb {
  height: 88px;
  border-radius: 10px;
  background: var(--ghost);
}
.ghostBar {
  height: 20px;
  margin-top: 14px;
  border-radius: 10px;
  background: var(--ghost);
}

/* The header: search, Feedback, Apps, a divider and the avatar. */
.bar {
  position: absolute;
  top: 16px;
  right: clamp(10px, 100% - 292px, 24px);
  display: flex;
  align-items: center;
  gap: 8px;
}
.tool {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  box-sizing: border-box;
  height: 31px;
  padding: 0 11px 0 8px;
  border: 1px solid var(--tool-edge);
  border-radius: 9px;
  background: var(--tool);
  box-shadow: 0 1px 2px var(--tool-shadow);
  color: var(--tool-ink);
  font-family: inherit;
  font-size: 14px;
  font-weight: 500;
  line-height: 20px;
  letter-spacing: -0.01em;
  white-space: nowrap;
}
.toolSquare {
  justify-content: center;
  width: 31px;
  margin-right: 1px;
  padding: 0;
}
.toolSquare .toolIcon {
  width: 20px;
  height: 20px;
}
.toolWide {
  padding: 0 12.5px;
}
.toolIcon {
  width: 18px;
  height: 18px;
  flex: none;
}
.divider {
  width: 1px;
  height: 20px;
  margin-left: 2px;
  background: var(--tool-edge);
}
.trigger {
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 34px;
  height: 34px;
  margin: 0;
  padding: 0;
  border: 1px solid var(--rim);
  border-radius: 50%;
  background: var(--surface);
  cursor: pointer;
  transition: box-shadow 200ms ease;
}
.trigger:hover,
.trigger[data-open='true'] {
  box-shadow: 0 0 10px 1px rgb(255 128 32 / 0.28);
}
.trigger:focus {
  outline: none;
}
.trigger:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The avatar: an orange orb with a pink ring and a sandy rim. */
.orb {
  display: block;
  grid-row: 1 / 3;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background:
    radial-gradient(circle at 50% 58%, transparent 18%, rgb(250 92 92 / 0.42) 32%, transparent 50%),
    radial-gradient(
      circle at 50% 47%,
      #ff8a00 0%,
      #ff7d0a 14%,
      #fd6a3a 26%,
      #f9795f 36%,
      #f7a073 46%,
      #f4bf83 58%,
      #f2cf95 72%,
      #efd7a8 86%,
      #ece0c6 100%
    );
  box-shadow:
    0 0 0 1px #fff,
    0 2px 5px rgb(0 0 0 / 0.12);
}
.orbSmall {
  width: 29px;
  height: 29px;
  box-shadow: none;
}

/* The menu: the ledge, with the card over its top. */
.menu {
  position: absolute;
  z-index: 3;
  top: 62px;
  /* Under the avatar's right edge, or as near as a narrow stage allows. */
  right: clamp(8px, 100% - 290px, 26px);
  box-sizing: border-box;
  width: 282px;
  max-width: calc(100% - 16px);
  padding-bottom: 9px;
  border-radius: 16px;
  outline: none;
  background: var(--ledge);
  box-shadow:
    0 0 0 1px var(--edge),
    0 18px 36px -10px var(--shadow),
    0 4px 10px -2px var(--shadow-2);
  opacity: 0;
  transform: translateY(-6px) scale(0.96);
  transform-origin: calc(100% - 15px) -10px;
  visibility: hidden;
  transition:
    opacity 140ms ease,
    transform 180ms cubic-bezier(0.4, 0, 1, 1),
    visibility 0s linear 180ms;
}
.menu[data-open='true'] {
  opacity: 1;
  transform: none;
  visibility: visible;
  transition:
    opacity 160ms ease,
    transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
    visibility 0s;
}
.card {
  border-radius: 16px;
  background: var(--surface);
  box-shadow:
    0 1px 0 var(--card-edge),
    0 4px 12px -2px var(--card-shadow);
}
.who {
  display: grid;
  grid-template-columns: 36px minmax(0, 1fr) auto;
  grid-template-rows: 20px 16px;
  column-gap: 13px;
  padding: 16px 14px 4px 16px;
}
.name {
  overflow: hidden;
  font-size: 14px;
  font-weight: 700;
  line-height: 20px;
  letter-spacing: -0.012em;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.email {
  grid-column: 2 / 4;
  overflow: hidden;
  color: var(--ink-2);
  font-size: 12px;
  line-height: 16px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.chip {
  grid-row: 1;
  grid-column: 3;
  align-self: center;
  height: 16px;
  padding: 0 6px;
  border-radius: 4px;
  background: var(--chip);
  color: var(--chip-ink);
  font-size: 10px;
  font-weight: 500;
  line-height: 16px;
  white-space: nowrap;
  transition:
    background-color 200ms ease,
    color 200ms ease;
}
.chip[data-status='online'] {
  background: var(--online-chip);
  color: var(--online-ink);
}
.chip[data-status='away'] {
  background: var(--away-chip);
  color: var(--away-ink);
}
.chip[data-status='busy'] {
  background: var(--busy-chip);
  color: var(--busy-ink);
}
.group {
  display: flex;
  flex-direction: column;
  gap: 2.7px;
  padding: 8px;
}
.separator {
  height: 1px;
  background: var(--line);
}
.ledge {
  display: flex;
  flex-direction: column;
  gap: 2.7px;
  padding: 12px 8px 0;
}

/* An entry. */
.item {
  display: flex;
  align-items: center;
  gap: 10px;
  box-sizing: border-box;
  width: 100%;
  height: 34.4px;
  margin: 0;
  padding: 0 6px 0 7px;
  border: 0;
  border-radius: 8px;
  outline: none;
  background: transparent;
  color: var(--ink);
  font-family: inherit;
  font-size: 14px;
  font-weight: 500;
  line-height: 20px;
  letter-spacing: -0.01em;
  text-align: left;
  cursor: pointer;
  transition:
    background-color 120ms ease,
    box-shadow 120ms ease;
}
.item[data-muted] {
  color: var(--ink-2);
}
.item[data-current] {
  background: var(--current);
}
.item[data-highlighted] {
  background: var(--raise);
  box-shadow:
    inset 0 0 0 1px var(--raise-edge),
    0 1px 2px var(--raise-shadow);
}
.icon {
  position: relative;
  flex: none;
  width: 21px;
  height: 21px;
  color: var(--ink-2);
}
.item[data-accent] .icon {
  color: var(--accent);
}
.sun,
.moon {
  position: absolute;
  inset: 0;
  width: 21px;
  height: 21px;
}
.sun {
  color: var(--sun);
}
.moon {
  color: var(--moon);
}
.label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.end {
  flex: none;
  width: 20px;
  height: 20px;
  color: var(--ink-2);
}
.faint {
  color: var(--ink-3);
}
.kbd {
  display: inline-grid;
  flex: none;
  place-items: center;
  box-sizing: border-box;
  min-width: 18px;
  height: 17px;
  margin-bottom: 1px;
  padding: 0 4px;
  border: 1px solid var(--kbd-edge);
  border-radius: 4px;
  background: var(--kbd);
  box-shadow: 0 1px 0 var(--kbd-edge);
  color: var(--kbd-ink);
  font-family: inherit;
  font-size: 10px;
  font-weight: 500;
  line-height: 1;
}
.kbdWide {
  height: 18px;
  padding: 0 5px;
  letter-spacing: 0.04em;
}
.offer {
  flex: none;
  height: 16px;
  padding: 0 5px;
  border-radius: 4px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 10px;
  font-weight: 500;
  line-height: 16px;
  white-space: nowrap;
}
.tile {
  flex: none;
  width: 20px;
  height: 20px;
  border-radius: 5px;
}

/* A submenu: placed by the script, to the menu's left. */
.sub {
  position: absolute;
  z-index: 4;
  top: 0;
  left: 0;
  box-sizing: border-box;
  width: 282px;
  padding: 8px;
  border-radius: 16px;
  outline: none;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--edge),
    0 18px 36px -10px var(--shadow),
    0 4px 10px -2px var(--shadow-2);
  opacity: 0;
  transform: translateX(8px) scale(0.98);
  transform-origin: 100% 70%;
  visibility: hidden;
  transition:
    opacity 120ms ease,
    transform 160ms cubic-bezier(0.4, 0, 1, 1),
    visibility 0s linear 160ms;
}
.sub[data-open='true'] {
  opacity: 1;
  transform: none;
  visibility: visible;
  transition:
    opacity 150ms ease,
    transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
    visibility 0s;
}
.subTitle {
  margin: 0;
  padding: 5px 8px 14px;
  color: var(--ink-2);
  font-size: 14px;
  font-weight: 400;
  line-height: 20px;
  letter-spacing: -0.01em;
}
.subList {
  display: flex;
  flex-direction: column;
  gap: 2.7px;
}
.subItem {
  gap: 9px;
  padding: 0 9px 0 6px;
}
.tileLarge {
  flex: none;
  width: 22px;
  height: 22px;
  border-radius: 6px;
}
.plus {
  margin: 0 0.5px;
  color: var(--ink);
}
.dotSlot {
  display: grid;
  flex: none;
  place-items: center;
  width: 22px;
  height: 22px;
}
.dot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--offline);
}
.dot[data-status='online'] {
  background: var(--online);
}
.dot[data-status='away'] {
  background: var(--away);
}
.dot[data-status='busy'] {
  background: var(--busy);
}
.dot[data-status='offline'] {
  background: transparent;
  box-shadow: inset 0 0 0 1.6px var(--offline);
}

/* The demo pointer, over everything; one of its glyphs shows at a time. */
.pointerLayer {
  position: absolute;
  z-index: 5;
  inset: 0;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.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;
}

@container (max-width: 420px) {
  .toolWide {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .menu,
  .menu[data-open='true'],
  .sub,
  .sub[data-open='true'] {
    transform: none;
    transition:
      opacity 100ms ease,
      visibility 0s linear 100ms;
  }
  .menu[data-open='true'],
  .sub[data-open='true'] {
    transition:
      opacity 100ms ease,
      visibility 0s;
  }
  .item,
  .chip,
  .trigger {
    transition: none;
  }
}
```

### account-menu/icons.tsx

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

/*
 * The account menu's icons: line drawings on a 24-unit grid, stroked in the colour of the text
 * around them (currentColor). Their size comes from the stylesheet. All are decorative: the
 * words next to them say what they mean.
 */

import type { ReactNode, SVGProps } from 'react';

function Svg({ children, ...props }: SVGProps<SVGSVGElement> & { children: ReactNode }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.6"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
      {...props}
    >
      {children}
    </svg>
  );
}

type IconProps = { className?: string };

export const Smile = (p: IconProps) => (
  <Svg {...p}>
    <circle cx="12" cy="12" r="9.2" />
    <path d="M8.4 14.2c2 2.3 5.2 2.3 7.2 0" />
    <circle cx="9.1" cy="9.9" r="0.4" fill="currentColor" strokeWidth="1.3" />
    <circle cx="14.9" cy="9.9" r="0.4" fill="currentColor" strokeWidth="1.3" />
  </Svg>
);

export const Profile = (p: IconProps) => (
  <Svg {...p}>
    <circle cx="12" cy="12" r="9.2" />
    <circle cx="12" cy="10" r="3.1" />
    <path d="M6.4 18.3c1.3-2.1 3.3-3.2 5.6-3.2s4.3 1.1 5.6 3.2" />
  </Svg>
);

export const Sun = (p: IconProps) => (
  <Svg {...p}>
    <circle cx="12" cy="12" r="4" />
    <path d="M12 2.8v1.5M12 19.7v1.5M2.8 12h1.5M19.7 12h1.5M5.5 5.5l1.1 1.1M17.4 17.4l1.1 1.1M5.5 18.5l1.1-1.1M17.4 6.6l1.1-1.1" />
  </Svg>
);

export const Moon = (p: IconProps) => (
  <Svg {...p}>
    <path d="M19.8 14.6A8.1 8.1 0 1 1 9.4 4.2a6.4 6.4 0 0 0 10.4 10.4Z" />
  </Svg>
);

export const Gear = (p: IconProps) => (
  <Svg {...p}>
    <path d="M12.46 4.71L13.78 2.87L17.19 4.29L16.83 6.52L17.48 7.17L19.71 6.81L21.13 10.22L19.29 11.54L19.29 12.46L21.13 13.78L19.71 17.19L17.48 16.83L16.83 17.48L17.19 19.71L13.78 21.13L12.46 19.29L11.54 19.29L10.22 21.13L6.81 19.71L7.17 17.48L6.52 16.83L4.29 17.19L2.87 13.78L4.71 12.46L4.71 11.54L2.87 10.22L4.29 6.81L6.52 7.17L7.17 6.52L6.81 4.29L10.22 2.87L11.54 4.71Z" />
    <circle cx="12" cy="12" r="3.1" />
  </Svg>
);

export const Bell = (p: IconProps) => (
  <Svg {...p}>
    <path d="M13 4.6a5.4 5.4 0 0 0-7.4 5V13l-1.7 3.1h14.2L16.4 13v-1" />
    <path d="M9.4 19.3a2.6 2.6 0 0 0 4.4 0" />
    <circle cx="17.6" cy="6.4" r="2.6" />
  </Svg>
);

export const Gem = (p: IconProps) => (
  <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" {...p}>
    <path
      d="M7.7 5.7h8.6a1.6 1.6 0 0 1 1.25.6l2.5 3.1a1.1 1.1 0 0 1-.05 1.45l-7.05 7.15a1.35 1.35 0 0 1-1.9 0l-7.05-7.15a1.1 1.1 0 0 1-.05-1.45l2.5-3.1a1.6 1.6 0 0 1 1.25-.6Z"
      fill="currentColor"
    />
  </svg>
);

export const Gift = (p: IconProps) => (
  <Svg {...p}>
    <rect x="3.6" y="8.3" width="16.8" height="4" rx="1" />
    <path d="M5.2 12.3v6.4a1.5 1.5 0 0 0 1.5 1.5h10.6a1.5 1.5 0 0 0 1.5-1.5v-6.4M12 8.3v11.9" />
    <path d="M12 8.3S11 3.6 8.4 3.9c-2 .2-1.9 3.1.3 4.1M12 8.3s1-4.7 3.6-4.4c2 .2 1.9 3.1-.3 4.1" />
  </Svg>
);

export const Download = (p: IconProps) => (
  <Svg {...p}>
    <path d="M8.6 9.4H7a2.6 2.6 0 0 0-2.6 2.6v5.4A2.6 2.6 0 0 0 7 20h10a2.6 2.6 0 0 0 2.6-2.6V12A2.6 2.6 0 0 0 17 9.4h-1.6" />
    <path d="M12 3.6v10.6M8.7 11.2l3.3 3.2 3.3-3.2" />
  </Svg>
);

export const News = (p: IconProps) => (
  <Svg {...p}>
    <path d="M13.6 5.2H6.2a2.4 2.4 0 0 0-2.4 2.4v9.6a2.4 2.4 0 0 0 2.4 2.4h11.6a2.4 2.4 0 0 0 2.4-2.4v-5.2" />
    <path d="M4.3 8.2l7.7 5.1 3.2-2.1" />
    <circle cx="18.2" cy="6.2" r="2.5" />
  </Svg>
);

export const Help = (p: IconProps) => (
  <Svg {...p}>
    <circle cx="12" cy="12" r="9.2" />
    <path d="M9.5 9.5a2.6 2.6 0 0 1 5.1.7c0 1.8-2.6 2.2-2.6 4" />
    <circle cx="12" cy="17.1" r="0.35" fill="currentColor" strokeWidth="1.3" />
  </Svg>
);

export const External = (p: IconProps) => (
  <Svg {...p}>
    <path d="M10.8 3.2H6.4a3.2 3.2 0 0 0-3.2 3.2v11.2a3.2 3.2 0 0 0 3.2 3.2h11.2a3.2 3.2 0 0 0 3.2-3.2v-4.4" />
    <path d="M14.8 3.2h6v6M20.6 3.4l-8.4 8.4" />
  </Svg>
);

export const SwitchAccount = (p: IconProps) => (
  <Svg {...p}>
    <circle cx="6.6" cy="4.9" r="1.8" />
    <path d="M3.6 10.4a3.1 3.1 0 0 1 6 0" />
    <circle cx="17.4" cy="15.1" r="1.8" />
    <path d="M14.4 20.6a3.1 3.1 0 0 1 6 0" />
    <path d="M13.2 4.2a8 8 0 0 1 6.6 6.4M18.2 9.6l1.6 1.2 1.1-1.7" />
    <path d="M10.8 19.8a8 8 0 0 1-6.6-6.4M5.8 14.4l-1.6-1.2-1.1 1.7" />
  </Svg>
);

export const SwitchSpace = (p: IconProps) => (
  <Svg {...p}>
    <circle cx="12" cy="12" r="9.2" />
    <path d="M7.8 9.9h8.4l-2.2-2.2M16.2 14.1H7.8l2.2 2.2" />
  </Svg>
);

export const LogOut = (p: IconProps) => (
  <Svg {...p}>
    <path d="M10.6 4.2H7.4a1.8 1.8 0 0 0-1.8 1.8v12a1.8 1.8 0 0 0 1.8 1.8h3.2" />
    <path d="M10.6 4.2v15.6" />
    <path d="M13.4 12h7M17.6 8.9l3 3.1-3 3.1" />
  </Svg>
);

export const Chevron = (p: IconProps) => (
  <Svg {...p}>
    <path d="M9.8 6.8 15 12l-5.2 5.2" />
  </Svg>
);

export const PlusCircle = (p: IconProps) => (
  <Svg {...p}>
    <circle cx="12" cy="12" r="9.2" />
    <path d="M12 8v8M8 12h8" />
  </Svg>
);

export const Check = (p: IconProps) => (
  <Svg {...p}>
    <path d="M5.5 12.5l4.2 4.2 8.8-9.2" />
  </Svg>
);

export const Search = (p: IconProps) => (
  <Svg {...p}>
    <circle cx="11" cy="11" r="6.6" />
    <path d="M16 16l3.8 3.8" />
  </Svg>
);

export const Apps = (p: IconProps) => (
  <Svg {...p}>
    <rect x="3.8" y="3.8" width="6.6" height="6.6" rx="1.7" />
    <rect x="13.6" y="3.8" width="6.6" height="6.6" rx="1.7" />
    <rect x="3.8" y="13.6" width="6.6" height="6.6" rx="1.7" />
    <rect x="13.6" y="13.6" width="6.6" height="6.6" rx="1.7" />
  </Svg>
);

export const Doc = (p: IconProps) => (
  <Svg {...p}>
    <path d="M14 3.8H7.6A1.8 1.8 0 0 0 5.8 5.6v12.8a1.8 1.8 0 0 0 1.8 1.8h8.8a1.8 1.8 0 0 0 1.8-1.8V7.6Z" />
    <path d="M13.8 3.8v4h4.2M9 12.4h6M9 15.8h4" />
  </Svg>
);

export const Star = (p: IconProps) => (
  <Svg {...p}>
    <path d="M12 3.8l2.5 5.1 5.6.8-4 4 .9 5.6-5-2.7-5 2.7.9-5.6-4-4 5.6-.8Z" />
  </Svg>
);

export const Share = (p: IconProps) => (
  <Svg {...p}>
    <rect x="4" y="4" width="16" height="16" rx="3" />
    <path d="M10 14l5-5M10.6 9H15v4.4" />
  </Svg>
);

export const Trash = (p: IconProps) => (
  <Svg {...p}>
    <path d="M4.6 6.6h14.8M9.4 6.6V4.8a1 1 0 0 1 1-1h3.2a1 1 0 0 1 1 1v1.8M6.4 6.6l.8 12a1.6 1.6 0 0 0 1.6 1.5h6.4a1.6 1.6 0 0 0 1.6-1.5l.8-12M10.2 10.6v5.6M13.8 10.6v5.6" />
  </Svg>
);
```

### account-menu/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. 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 (account-menu.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 something to
 * press. It is decorative: what it does shows on the menu 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>
  );
}
```

### account-menu/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 };
}
```