# Drill menu

> A nested menu that drills down in place: the item you choose rises into the title, and the back arrow brings it home.

A settings menu three levels deep. Choose an item that holds a level of its own and it slides up into the title’s place, greying as it goes, while the list around it blurs away and its children slide in below. The back arrow sinks the title back into its list as the items around it return, and the card’s height follows each level on the same spring.

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

Category: Navigation.

## Anatomy

1. **Card**: One surface for every level. It hangs from the top of a slot as tall as the longest level, so the title stays put while its height springs to fit.
2. **Back row**: The level’s title with a return arrow: one button, labelled “Gestures, back to Settings”. Escape, ← or Backspace do the same.
3. **Title**: Sits as far in as the items’ labels, so the item you open rises straight up into it, greying as it goes.
4. **Section item**: An item with children: a `menuitem` that opens its level on a click, Enter or →.
5. **Chevron**: Marks an item that opens a level. Going back, it fades in again on the item the title sank into.
6. **Option**: An item without children: a `menuitemcheckbox` switched on and off where it is.
7. **Check**: Pops in, in the gutter under the back arrow, when the option is on.
8. **Keys hint**: Keycaps for the arrow keys it answers to, under the card. Hidden on touch screens.

## When to use

- Settings or filters nested two to four levels deep, in a space too small for a sidebar and a panel side by side.
- A popover or a sheet whose size is fixed: each level replaces the last in place, and the card only grows to the longest one.
- Trees whose sections are worth a name of their own: the item you open becomes the title, so you always see where you are.
- On/off options at the end of a path, switched in place with `onToggle` and no Save button.

## When not to use

- A flat list of ten options or fewer. Show them all in one menu or a group of switches.
- Comparing or changing options from different sections at once: only one level is ever on screen. Use a settings page with sections.
- Choosing one value out of many, like a country. Use a select or a searchable combobox.
- Trees more than four levels deep, or ones people search more than browse. Add a search field, or use a command palette.
- Actions that run when picked (Rename, Delete). Its leaves are switches; use a plain menu for commands.

## Installation

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

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

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

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

## Usage

```tsx
import { DrillMenu } from './drill-menu';
import type { MenuItem } from './menu';

// Items with children open a level of their own; the others switch on and off.
const VIEW: MenuItem[] = [
  {
    id: 'sort',
    label: 'Sort',
    children: [
      { id: 'sort-folders', label: 'Folders first', on: true },
      { id: 'sort-recent', label: 'Recent on top' },
    ],
  },
  { id: 'hidden', label: 'Show hidden files' },
];

export function ViewMenu() {
  return (
    <DrillMenu
      title="View"
      items={VIEW}
      autoplay={false}
      onToggle={(item, on) => console.log(item.label, on)}
    />
  );
}
```

## States

- **Top level**: Its title alone, with no back arrow, over the sections.
- **A level in**: The opened item is the title now, with a back arrow; sections and options can share a level.
- **Options**: The end of a path: options only, a check beside each one that is on.
- **Pointed at**: A soft fill under the item. Focus from the keyboard draws a blue ring instead.
- **Pressed**: A step darker while held; letting go opens the level.
- **Back pointed at**: The whole back row is the button, title included.
- **Dark**: Every colour is a `light-dark()` pair: it follows the page, or `colorScheme`.

## Examples

### Long top level

A map’s layers in six rows, switches beside sections and most of them on; the card shrinks to fit a section as it opens.

```tsx
import { DrillMenu } from './drill-menu';
import type { MenuItem } from './menu';

// Switches sit beside sections in one long level, and each section opens a short one.
const LAYERS: MenuItem[] = [
  { id: 'traffic', label: 'Traffic', on: true },
  { id: 'cycling', label: 'Cycle lanes' },
  {
    id: 'transport',
    label: 'Public transport',
    children: [
      { id: 'metro', label: 'Metro lines', on: true },
      { id: 'buses', label: 'Bus routes', on: true },
      { id: 'trams', label: 'Tram lines' },
      { id: 'ferries', label: 'Ferries' },
    ],
  },
  {
    id: 'places',
    label: 'Places',
    children: [
      { id: 'food', label: 'Restaurants and cafés' },
      { id: 'fuel', label: 'Petrol stations' },
      { id: 'parking', label: 'Car parks', on: true },
    ],
  },
  { id: 'terrain', label: 'Terrain', on: true },
  { id: 'buildings', label: '3D buildings', on: true },
];

export function LongTopLevel() {
  return <DrillMenu title="Layers" items={LAYERS} autoplay={false} />;
}
```

### Deep link

A home’s controls opened four levels deep, on the kitchen’s lights, through defaultPath; the back arrow climbs out a level at a time.

```tsx
import { DrillMenu } from './drill-menu';
import type { MenuItem } from './menu';

const HOME: MenuItem[] = [
  {
    id: 'rooms',
    label: 'Rooms',
    children: [
      {
        id: 'living-room',
        label: 'Living room',
        children: [
          { id: 'floor-lamp', label: 'Floor lamp', on: true },
          { id: 'living-room-ceiling', label: 'Ceiling light' },
          { id: 'tv-backlight', label: 'TV backlight' },
        ],
      },
      {
        id: 'kitchen',
        label: 'Kitchen',
        children: [
          {
            id: 'kitchen-lights',
            label: 'Lights',
            children: [
              { id: 'kitchen-ceiling', label: 'Ceiling', on: true },
              { id: 'under-cabinets', label: 'Under the cabinets', on: true },
              { id: 'over-table', label: 'Over the table' },
              { id: 'by-window', label: 'By the window' },
            ],
          },
          { id: 'underfloor-heating', label: 'Underfloor heating', on: true },
          { id: 'extractor-fan', label: 'Extractor fan' },
        ],
      },
      {
        id: 'bedroom',
        label: 'Bedroom',
        children: [
          { id: 'bedside-lamps', label: 'Bedside lamps' },
          { id: 'bedroom-ceiling', label: 'Ceiling light' },
        ],
      },
      {
        id: 'office',
        label: 'Office',
        children: [
          { id: 'desk-lamp', label: 'Desk lamp', on: true },
          { id: 'office-ceiling', label: 'Ceiling light' },
        ],
      },
    ],
  },
  {
    id: 'climate',
    label: 'Climate',
    children: [
      { id: 'heating', label: 'Heating', on: true },
      { id: 'cooling', label: 'Cooling' },
      { id: 'eco-mode', label: 'Eco mode', on: true },
    ],
  },
  {
    id: 'automations',
    label: 'Automations',
    children: [
      { id: 'lights-off', label: 'Lights off at midnight', on: true },
      { id: 'heating-early', label: 'Heating on at 6:30', on: true },
      { id: 'lock-up', label: 'Lock up when I leave' },
    ],
  },
  {
    id: 'security',
    label: 'Security',
    children: [
      { id: 'alarm', label: 'Alarm' },
      { id: 'cameras', label: 'Cameras', on: true },
      { id: 'door-lock', label: 'Door lock', on: true },
    ],
  },
];

export function DeepLink() {
  // Four levels deep, on the kitchen's lights; the back arrow climbs out a level at a time.
  return (
    <DrillMenu
      title="Home"
      items={HOME}
      defaultPath={['rooms', 'kitchen', 'kitchen-lights']}
      autoplay={false}
    />
  );
}
```

### Filter count

A shop’s filters, whose title counts the ones switched on: onToggle keeps the count.

```tsx
'use client';

import { useState } from 'react';
import { DrillMenu } from './drill-menu';
import type { MenuItem } from './menu';

const FILTERS: MenuItem[] = [
  { id: 'in-stock', label: 'In stock', on: true },
  { id: 'on-sale', label: 'On sale' },
  {
    id: 'size',
    label: 'Size',
    children: [
      { id: 'small', label: 'Small' },
      { id: 'medium', label: 'Medium', on: true },
      { id: 'large', label: 'Large' },
      { id: 'extra-large', label: 'Extra large' },
    ],
  },
  {
    id: 'colour',
    label: 'Colour',
    children: [
      { id: 'black', label: 'Black' },
      { id: 'white', label: 'White' },
      { id: 'olive', label: 'Olive' },
      { id: 'navy', label: 'Navy' },
    ],
  },
  {
    id: 'price',
    label: 'Price',
    children: [
      { id: 'under-25', label: 'Under £25' },
      { id: '25-to-50', label: '£25 to £50' },
      { id: '50-to-100', label: '£50 to £100' },
      { id: 'over-100', label: 'Over £100' },
    ],
  },
];

export function FilterCount() {
  // In stock and Medium start on; onToggle keeps the count from there.
  const [count, setCount] = useState(2);
  return (
    <DrillMenu
      title={count ? `Filters (${count})` : 'Filters'}
      items={FILTERS}
      autoplay={false}
      onToggle={(_, on) => setCount((now) => now + (on ? 1 : -1))}
    />
  );
}
```

## How it works

- Each level on screen is a layer. A change of level adds the new one and keeps the old one laid over it, inert, just long enough to animate it away.
- The item you choose and the title it becomes are two elements, one in each layer. FLIP plays the new title from where the item is on screen while its colour turns from an item’s to a title’s. Going back runs the same in reverse, and a quick reversal starts from wherever the title is mid-flight.
- The old title rises out of sight and the old list clears with a little blur before the new items slide up one after another. Going back, the parent’s title comes down from above and its items return around the one coming home.
- The motion is a damped spring, simulated once and sampled into a CSS linear() easing, then played by the Web Animations API. The card’s height follows the same spring.
- Options are switches: a check pops into the gutter under the back arrow.
- Every colour is a light-dark() token on the root, so the menu follows the page’s color-scheme, or the colorScheme prop; the one colour script touches, the item’s as it rises into the title, is read from the computed style as it moves.

## API reference

- **items** (`MenuItem[]`, default `SETTINGS`): The top level’s items. Each has an id (unique in the tree), a label, and either children (a level of its own) or on (an option that starts switched on).
- **title** (`string`, default `'Settings'`): The top level’s title.
- **defaultPath** (`string[]`): The ids of the items leading to the level open at first, in order from the top; the top level when left out.
- **defaultPointer** (`{ target: string; pressed?: boolean }`): The item drawn under the pointer at first (an item’s id in the open level, or 'back'), pressed or not; the reader’s next change of level clears it. Setting it turns the demo off.
- **onToggle** (`(item: MenuItem, on: boolean) => void`): Called when an option is switched on or off.
- **autoplay** (`boolean`, default `true`): Browses the menu with a demo cursor while on screen and untouched.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- It is a menu: branches are menu items with aria-haspopup, and options are menuitemcheckbox with aria-checked. The back button names both levels (“Gestures, back to Settings”).
- The up and down arrows (and Home, End) move; Enter, Space or the right arrow open; Escape, the left arrow or Backspace go back. Focus moves to the first item of a new level, and back to the item you came from.
- Level changes (“Swipe, 4 items”) and switched options are announced in a polite live region. The second click of a double click is ignored, so it never drills two levels.
- Under reduced motion levels switch at once, with no movement or blur, and there is no demo.

## Source

### drill-menu/drill-menu.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type SVGProps,
} from 'react';
import { DemoCursor, glideTo, hideCursor, pressWith } from './demo-cursor';
import { SETTINGS, type MenuItem } from './menu';
import { spring, timing } from './spring';
import { useAutoplay } from './use-autoplay';
import styles from './drill-menu.module.css';

/*
 * Drill menu: a nested menu that drills down in place. Each level shows a back arrow and its
 * title over a list. Choose an item that opens a level of its own and it rises into the title's
 * place while the list around it fades away and its children slide in; go back and the title
 * sinks into the list it came from, as the items around it come back.
 *
 * - Each level on screen is a layer. A change of level adds the new one as the current layer and
 *   keeps the old one over it, inert, just long enough to animate it away.
 * - The item and the title it turns into are two elements, one in each layer. FLIP plays the new
 *   one from where the old one is on screen (mid-flight included, so a quick reversal starts from
 *   there), while its colour turns from an item's to a title's.
 * - The motion is a spring, sampled into a linear() easing (spring.ts) and played by the Web
 *   Animations API; the card's height follows the same spring.
 * - Keyboard: the arrow keys move between items, Enter or → opens, Escape or ← goes back, and
 *   focus follows from level to level. Changes are announced to screen readers.
 * - While on screen and untouched, a demo cursor browses a few levels (demo-cursor.tsx).
 * - Under reduced motion, levels switch at once.
 * - Its colours are light-dark() pairs in the stylesheets: it follows the page's color-scheme, or
 *   the one the colorScheme prop sets. The one colour read here, the travelling item's, comes
 *   from the computed style at the moment it moves.
 */

type Direction = 'in' | 'out';

/** A level on screen: the current one, or one on its way out. */
type Layer = {
  key: number;
  /** The ids of the items opened to get here, from the top. */
  path: string[];
  /** Set while it leaves: which way, and the id of the item that travels between the levels. */
  leaving?: { direction: Direction; shared: string };
};

/** A change of level, waiting for the new layer to be on screen to animate. */
type Move = {
  direction: Direction;
  shared: string;
  /** The layers' keys. */
  from: number;
  to: number;
  /** The card's height before the change, in px. */
  height: number;
  /** The reader's focus was in the menu: it follows to the new level. */
  focus: boolean;
};

/** The item that becomes a title (and back), and the card's height. */
const MOVE = spring(320, 30);
/** A level's items, arriving. */
const ARRIVE = spring(260, 27);
/** What leaves clears the way at once, then eases off. */
const LEAVE_EASE = 'cubic-bezier(0.2, 0.6, 0.35, 1)';
/** Arriving items wait this long, for the old ones to clear, and follow each other by STAGGER. */
const ARRIVE_DELAY = 70;
const STAGGER = 30;
/** How long the demo's pointer rests on an item before pressing it, then holds the press, in ms. */
const HOVER = 260;
const PRESS = 140;

const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** The level a path leads to: its title, its parent's title (none at the top) and its items. */
function levelAt(root: MenuItem[], rootTitle: string, path: string[]) {
  let items = root;
  let title = rootTitle;
  let parent: string | null = null;
  for (const id of path) {
    const item = items.find((entry) => entry.id === id);
    if (!item?.children) break;
    parent = title;
    title = item.label;
    items = item.children;
  }
  return { title, parent, items };
}

/** The start of `path` that leads to a level: it stops at the first id that opens none. */
function reachable(root: MenuItem[], path: string[]): string[] {
  const valid: string[] = [];
  let items = root;
  for (const id of path) {
    const item = items.find((entry) => entry.id === id);
    if (!item?.children) break;
    valid.push(id);
    items = item.children;
  }
  return valid;
}

/** The most items any level holds, the top one included. */
function longestLevel(items: MenuItem[]): number {
  return Math.max(
    items.length,
    ...items.map((item) => (item.children ? longestLevel(item.children) : 0)),
  );
}

/** The ids of the options that start switched on, anywhere in the tree. */
function initiallyOn(items: MenuItem[]): string[] {
  return items.flatMap((item) =>
    item.children ? initiallyOn(item.children) : item.on ? [item.id] : [],
  );
}

/** Animates `element` to `to` from wherever it is now, mid-animation included, and holds it. */
function leave(element: Element, to: Keyframe, duration: number) {
  const now = getComputedStyle(element);
  const from: Keyframe = {};
  for (const property of Object.keys(to)) from[property] = now.getPropertyValue(property);
  for (const running of element.getAnimations()) running.cancel();
  return element.animate([from, to], { duration, easing: LEAVE_EASE, fill: 'forwards' });
}

export function DrillMenu({
  items = SETTINGS,
  title = 'Settings',
  defaultPath,
  defaultPointer,
  onToggle,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The top level's items. An item with children opens a level of its own. */
  items?: MenuItem[];
  /** The top level's title. */
  title?: string;
  /** The ids of the items leading to the level open at first; the top level by default. */
  defaultPath?: string[];
  /**
   * The item drawn under the pointer at first (an item's id in the open level, or 'back'), pressed
   * or not, as in a still of it; the next change of level clears it. Setting it turns the demo off.
   */
  defaultPointer?: { target: string; pressed?: boolean };
  /** Called when an option is switched on or off. */
  onToggle?: (item: MenuItem, on: boolean) => void;
  /** Browses the menu on its own while it is on screen and untouched. */
  autoplay?: boolean;
  /**
   * Its colours: light, dark, or 'system' for the reader's setting. Unset, it follows the page's
   * color-scheme (light if the page sets none).
   */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const card = useRef<HTMLDivElement>(null);
  const cursor = useRef<HTMLSpanElement>(null);
  const [layers, setLayers] = useState<Layer[]>(() => [
    { key: 0, path: reachable(items, defaultPath ?? []) },
  ]);
  const [checked, setChecked] = useState(() => new Set(initiallyOn(items)));
  /** The item that takes the Tab key, in the current level. */
  const [active, setActive] = useState(0);
  /** Where the demo's pointer is: an item's id or 'back', pressed or not. */
  const [ghost, setGhost] = useState<{ target: string; pressed: boolean } | null>(() =>
    defaultPointer ? { target: defaultPointer.target, pressed: !!defaultPointer.pressed } : null,
  );
  const [message, setMessage] = useState('');
  const baseId = useId();
  const layerCount = useRef(0);
  const pending = useRef<Move | null>(null);
  const resize = useRef<Animation | null>(null);

  const current = layers[layers.length - 1];
  const level = levelAt(items, title, current.path);

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

  /** Shows the level at `path`. `shared` is the id of the item that travels between the levels. */
  const go = (path: string[], direction: Direction, shared: string, byReader: boolean) => {
    const box = card.current;
    if (!box) return;
    const key = ++layerCount.current;
    const reduce = prefersReducedMotion();
    // A pointer drawn by defaultPointer stays on the level it was drawn on.
    if (byReader) setGhost(null);
    pending.current = {
      direction,
      shared,
      from: current.key,
      to: key,
      height: box.offsetHeight,
      focus: byReader && box.contains(document.activeElement),
    };
    setLayers((now) =>
      reduce
        ? [{ key, path }]
        : [
            ...now.map((layer) =>
              layer.leaving ? layer : { ...layer, leaving: { direction, shared } },
            ),
            { key, path },
          ],
    );
    // The item that takes the Tab key: the first one going in, the one we left going out.
    const next = levelAt(items, title, path);
    const left = next.items.findIndex((item) => item.id === shared);
    setActive(direction === 'in' ? 0 : Math.max(0, left));
    if (byReader) announce(`${next.title}, ${next.items.length} items`);
  };

  const open = (item: MenuItem, byReader: boolean) =>
    go([...current.path, item.id], 'in', item.id, byReader);

  const back = (byReader: boolean) => {
    const id = current.path[current.path.length - 1];
    if (id) go(current.path.slice(0, -1), 'out', id, byReader);
  };

  const toggle = (item: MenuItem, byReader: boolean) => {
    const on = !checked.has(item.id);
    setChecked((now) => {
      const next = new Set(now);
      if (on) next.add(item.id);
      else next.delete(item.id);
      return next;
    });
    onToggle?.(item, on);
    if (byReader) announce(`${item.label}, ${on ? 'on' : 'off'}`);
  };

  // A change of level, now that both levels are on screen: the shared item travels, the old level
  // leaves, the new one arrives, and the card takes its new height.
  useLayoutEffect(() => {
    const move = pending.current;
    const box = card.current;
    if (!move || !box) return;
    pending.current = null;
    const from = box.querySelector(`[data-layer="${move.from}"]`);
    const to = box.querySelector(`[data-layer="${move.to}"]`);
    const head = to?.querySelector<HTMLElement>('[data-head]');
    if (!to || !head) return;
    const arriving = Array.from(to.querySelectorAll<HTMLElement>('[data-item]'));
    // Going out, the item we leave, back in its list.
    const returning = arriving.find((item) => item.dataset.item === move.shared);
    // Focus follows the reader to the new level; so it does if it reached the old one (now inert)
    // while the change was on its way.
    if (move.focus || from?.contains(document.activeElement)) {
      (move.direction === 'in' ? arriving[0] : returning)?.focus();
    }
    // Under reduced motion the old level is gone already: nothing to animate.
    const oldHead = from?.querySelector('[data-head]');
    if (!from || !oldHead) return;

    // Screen pixels to the card's own, should it sit in a scaled container.
    const scale = box.getBoundingClientRect().width / box.offsetWidth || 1;
    /** Plays `element` from where `start` is on screen to its own place, where `end` is. */
    const travel = (element: HTMLElement, start: Element, end: Element) => {
      const a = start.getBoundingClientRect();
      const b = end.getBoundingClientRect();
      const dx = (a.left - b.left) / scale;
      const dy = (a.top - b.top) / scale;
      element.animate(
        [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
        timing(MOVE),
      );
      end.animate(
        [{ color: getComputedStyle(start).color }, { color: getComputedStyle(end).color }],
        { duration: 320, easing: 'ease-out' },
      );
    };
    /** Brings `element` in from `hidden` to its own look, after `delay` ms. */
    const arrive = (element: Element, hidden: Keyframe, delay: number) =>
      element.animate(
        [hidden, { opacity: 1, transform: 'none', filter: 'blur(0)' }],
        timing(ARRIVE, { delay, fill: 'backwards' }),
      );

    const leaving: Animation[] = [];
    const oldItems = Array.from(from.querySelectorAll('[data-item]'));
    if (move.direction === 'in') {
      // The chosen item rises into the title's place, greying as it goes, and grows a back arrow.
      const label = from.querySelector(`[data-item="${move.shared}"] [data-label]`);
      const titleText = head.querySelector('[data-title]');
      if (label && titleText) travel(head, label, titleText);
      head.querySelector('[data-arrow]')?.animate(
        [
          { opacity: 0, transform: 'translateX(6px)' },
          { opacity: 1, transform: 'none' },
        ],
        { duration: 280, delay: 100, easing: 'ease-out', fill: 'backwards' },
      );
      // The old title rises out of sight, the old list clears, the children slide up into place.
      leaving.push(
        leave(oldHead, { opacity: 0, transform: 'translateY(-24px)', filter: 'blur(3px)' }, 220),
      );
      for (const item of oldItems) {
        leaving.push(
          leave(item, { opacity: 0, transform: 'translateY(-8px)', filter: 'blur(4px)' }, 160),
        );
      }
      arriving.forEach((item, i) =>
        arrive(
          item,
          { opacity: 0, transform: 'translateY(18px)', filter: 'blur(4px)' },
          ARRIVE_DELAY + i * STAGGER,
        ),
      );
    } else {
      // The title sinks back into its place in the list, darkening as it goes, while the rest of
      // the old head (its arrow, its hover) fades where it is.
      const titleText = oldHead.querySelector('[data-title]');
      const label = returning?.querySelector('[data-label]');
      if (returning && titleText && label) travel(returning, titleText, label);
      returning?.querySelector('[data-trail]')?.animate([{ opacity: 0 }, { opacity: 1 }], {
        duration: 240,
        delay: 160,
        easing: 'ease-out',
        fill: 'backwards',
      });
      const still = getComputedStyle(oldHead).transform;
      leaving.push(leave(oldHead, { opacity: 0, transform: still }, 140));
      for (const item of oldItems) {
        leaving.push(
          leave(item, { opacity: 0, transform: 'translateY(10px)', filter: 'blur(4px)' }, 160),
        );
      }
      // The parent's title comes down from above; its items come back around the returning one.
      arrive(head, { opacity: 0, transform: 'translateY(-20px)', filter: 'blur(3px)' }, 60);
      const home = returning ? arriving.indexOf(returning) : 0;
      arriving.forEach((item, i) => {
        if (item === returning) return;
        arrive(
          item,
          { opacity: 0, transform: 'translateY(-6px) scale(0.98)', filter: 'blur(4px)' },
          ARRIVE_DELAY + (Math.abs(i - home) - 1) * STAGGER,
        );
      });
    }

    // The card's height follows, from wherever it is.
    resize.current?.cancel();
    const height = box.offsetHeight;
    if (Math.abs(height - move.height) > 0.5) {
      resize.current = box.animate(
        [{ height: `${move.height}px` }, { height: `${height}px` }],
        timing(MOVE),
      );
    }

    // The old level goes once it has faded away.
    Promise.all(leaving.map((animation) => animation.finished)).then(
      () => setLayers((now) => now.filter((layer) => layer.key !== move.from)),
      () => {},
    );
  }, [layers]);

  const onListKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[data-item]'));
    const index = buttons.findIndex((button) => button === document.activeElement);
    const count = buttons.length;
    const focusAt = (i: number) => buttons[(i + count) % count]?.focus();
    const item = level.items[index];
    switch (event.key) {
      case 'ArrowDown':
        focusAt(index + 1);
        break;
      case 'ArrowUp':
        focusAt(index - 1);
        break;
      case 'Home':
        focusAt(0);
        break;
      case 'End':
        focusAt(count - 1);
        break;
      case 'ArrowRight':
        if (!item?.children) return;
        open(item, true);
        break;
      case 'ArrowLeft':
      case 'Escape':
      case 'Backspace':
        if (!current.path.length) return;
        back(true);
        break;
      default:
        return;
    }
    event.preventDefault();
  };

  const onBackKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
    if (event.key === 'ArrowDown') {
      card.current?.querySelector<HTMLElement>('[data-item][tabindex="0"]')?.focus();
    } else if (event.key === 'ArrowLeft' || event.key === 'Escape' || event.key === 'Backspace') {
      back(true);
    } else return;
    event.preventDefault();
  };

  // The demo runs across renders: it reads the latest state and actions here.
  const latest = useRef({ level, path: current.path, checked, open, back, toggle });
  useLayoutEffect(() => {
    latest.current = { level, path: current.path, checked, open, back, toggle };
  });

  // The demo: from the top, open a section, then a level inside it, and switch an option; back up
  // to the top, then the next section. While on screen and untouched.
  const touring = autoplay && !defaultPointer && playing;
  useEffect(() => {
    if (!touring) return;
    let alive = true;
    let timer = 0;
    const wait = (ms: number) =>
      new Promise<void>((resolve) => {
        timer = window.setTimeout(resolve, ms);
      });
    // Glides the cursor to `target` (an item's id, or 'back'), presses it and lets go; false if
    // the demo stopped.
    const press = async (target: string) => {
      // The reader is in the menu (it has their focus): the demo waits for them to leave it.
      if (root.current?.contains(document.activeElement)) {
        take();
        return false;
      }
      const pointer = cursor.current;
      const level = card.current?.querySelector('[data-layer]:not([data-leaving])');
      const element =
        target === 'back'
          ? level?.querySelector('[data-head]')
          : level?.querySelector(`[data-item="${target}"] [data-label]`);
      if (!pointer || !element) return false;
      await wait(glideTo(pointer, element));
      if (!alive) return false;
      setGhost({ target, pressed: false });
      await wait(HOVER);
      if (!alive) return false;
      setGhost({ target, pressed: true });
      pressWith(pointer, true);
      await wait(PRESS);
      if (!alive) return false;
      setGhost(null);
      pressWith(pointer, false);
      return true;
    };
    (async () => {
      await wait(900);
      for (let turn = 0; alive; turn += 1) {
        while (latest.current.path.length) {
          if (!(await press('back'))) return;
          latest.current.back(false);
          await wait(650);
          if (!alive) return;
        }
        await wait(400);
        const sections = latest.current.level.items.filter((item) => item.children);
        const section = sections[turn % sections.length];
        if (!alive || !section || !(await press(section.id))) return;
        latest.current.open(section, false);
        await wait(850);
        let options = section.children ?? [];
        const deeper = options.find((item) => item.children);
        if (deeper) {
          if (!alive || !(await press(deeper.id))) return;
          latest.current.open(deeper, false);
          await wait(850);
          options = deeper.children ?? [];
        }
        // An option that is off, so the check comes in; or the first one.
        const leaves = options.filter((item) => !item.children);
        const option = leaves.find((item) => !latest.current.checked.has(item.id)) ?? leaves[0];
        if (option) {
          if (!alive || !(await press(option.id))) return;
          latest.current.toggle(option, false);
          await wait(1100);
        }
      }
    })();
    return () => {
      alive = false;
      window.clearTimeout(timer);
      setGhost(null);
      if (cursor.current) hideCursor(cursor.current);
    };
  }, [touring, root, take]);

  /** The demo pointer's mark for an element of the current level. */
  const ghostOn = (target: string) =>
    ghost?.target === target ? (ghost.pressed ? 'pressed' : '') : undefined;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onFocusCapture={take}
    >
      <div className={styles.stage} data-part="stage">
        <div className={styles.slot} style={{ '--rows': longestLevel(items) } as CSSProperties}>
          <div ref={card} className={styles.card} data-part="card">
            {layers.map((layer) => {
              const shown = layer === current;
              const { title: heading, parent, items: list } = levelAt(items, title, layer.path);
              const titleId = `${baseId}-${layer.key}`;
              const { leaving } = layer;
              return (
                <div
                  key={layer.key}
                  className={styles.layer}
                  data-layer={layer.key}
                  data-leaving={leaving ? '' : undefined}
                  inert={!shown || undefined}
                >
                  <div className={styles.head}>
                    {parent === null ? (
                      <span className={styles.top} data-head data-part="top">
                        <span className={styles.title} id={titleId} data-title data-part="title">
                          {heading}
                        </span>
                      </span>
                    ) : (
                      <button
                        type="button"
                        className={styles.back}
                        data-head
                        data-part="back"
                        data-source={leaving?.direction === 'out' || undefined}
                        data-ghost={shown ? ghostOn('back') : undefined}
                        aria-label={`${heading}, back to ${parent}`}
                        onClick={(event) => {
                          if (event.detail <= 1) back(true);
                        }}
                        onKeyDown={onBackKeyDown}
                      >
                        <BackIcon className={styles.arrow} data-arrow data-part="arrow" />
                        <span className={styles.title} id={titleId} data-title data-part="title">
                          {heading}
                        </span>
                      </button>
                    )}
                  </div>
                  <div
                    role="menu"
                    aria-labelledby={titleId}
                    className={styles.list}
                    data-part="list"
                    onKeyDown={onListKeyDown}
                  >
                    {list.map((item, i) => {
                      const branch = !!item.children;
                      return (
                        <button
                          key={item.id}
                          type="button"
                          role={branch ? 'menuitem' : 'menuitemcheckbox'}
                          aria-haspopup={branch ? 'menu' : undefined}
                          aria-checked={branch ? undefined : checked.has(item.id)}
                          tabIndex={shown && i === Math.min(active, list.length - 1) ? 0 : -1}
                          className={styles.item}
                          data-item={item.id}
                          data-part={branch ? 'section' : 'option'}
                          data-source={
                            (leaving?.direction === 'in' && leaving.shared === item.id) || undefined
                          }
                          data-ghost={shown ? ghostOn(item.id) : undefined}
                          onClick={(event) => {
                            // The second click of a double click lands on the next level: ignored.
                            if (event.detail > 1) return;
                            if (branch) open(item, true);
                            else toggle(item, true);
                          }}
                          onFocus={() => setActive(i)}
                        >
                          {!branch && <CheckIcon className={styles.check} data-part="check" />}
                          <span className={styles.label} data-label data-part="label">
                            {item.label}
                          </span>
                          {branch && (
                            <ChevronIcon
                              className={styles.chevron}
                              data-trail
                              data-part="chevron"
                            />
                          )}
                        </button>
                      );
                    })}
                  </div>
                </div>
              );
            })}
          </div>
        </div>

        <p className={styles.keys} aria-hidden="true" data-part="keys">
          <kbd>↑</kbd>
          <kbd>↓</kbd> move <kbd>→</kbd> open <kbd>←</kbd> back
        </p>
        <DemoCursor ref={cursor} />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

type IconProps = SVGProps<SVGSVGElement>;

function Icon({ children, ...props }: IconProps) {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.8"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      {...props}
    >
      {children}
    </svg>
  );
}

/** A return arrow: back up a level. */
function BackIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M7.5 4.5 4 8l3.5 3.5" />
      <path d="M4.5 8h7.25a4.25 4.25 0 0 1 0 8.5H10" />
    </Icon>
  );
}

function ChevronIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="m7.5 4.5 5.5 5.5-5.5 5.5" />
    </Icon>
  );
}

function CheckIcon(props: IconProps) {
  return (
    <Icon strokeWidth="2.1" {...props}>
      <path d="m4 10.5 4 4 8-9" />
    </Icon>
  );
}
```

### drill-menu/drill-menu.module.css

```css
/* Drill menu (drill-menu.tsx): a light stage with the menu in a white card, or at night a
   near-black stage with the menu in a dark grey one. The card hangs from the top of a slot as tall
   as the menu's longest level, and the slot sits in the middle of the stage, so the title stays
   put while the card grows and shrinks below it. Sizes are in em: the menu's font-size follows
   the component's width (a container), from 15px on a phone to 18px. */

.root {
  /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root
     inherits from the page, or the one the colorScheme prop sets (below). */
  /* The stage, then the card and the keys a step up. */
  --surface: #eaeaed;
  --surface-2: #fff;
  /* Items; titles and the back arrow; chevrons; the keys' line on the stage. */
  --ink: #1d1d1f;
  --ink-muted: #6e6e73;
  --ink-faint: #8e8e93;
  --stage-ink: #636366;
  /* The stage's and the keys' edges, the card's, and the stage's dots. */
  --line: rgb(0 0 0 / 0.06);
  --card-line: rgb(0 0 0 / 0.05);
  --dots: rgb(0 0 0 / 0.05);
  /* An item or the back row under the pointer, then pressed. */
  --hover: #f2f2f5;
  --press: #e8e8ed;
  /* The focus ring, and what would sit on it. */
  --accent: #0a84ff;
  --accent-ink: #fff;
  /* The card's soft drop and the shade right under it; the shade under each key. */
  --shadow: rgb(0 0 0 / 0.22);
  --shadow-near: rgb(0 0 0 / 0.05);
  --key-shadow: rgb(0 0 0 / 0.08);
  container-type: inline-size;
  min-width: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#eaeaed, #111113);
    --surface-2: light-dark(#fff, #1c1c1e);
    --ink: light-dark(#1d1d1f, #f5f5f7);
    --ink-muted: light-dark(#6e6e73, #a1a1a6);
    --ink-faint: light-dark(#8e8e93, #808085);
    --stage-ink: light-dark(#636366, #86868b);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --card-line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.08));
    --dots: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
    --hover: light-dark(#f2f2f5, #2c2c2e);
    --press: light-dark(#e8e8ed, #333336);
    --accent: light-dark(#0a84ff, #3594ff);
    --accent-ink: light-dark(#fff, #fff);
    --shadow: light-dark(rgb(0 0 0 / 0.22), rgb(0 0 0 / 0.6));
    --shadow-near: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --key-shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5));
  }
}

/* The colorScheme prop. Without it the root sets nothing and inherits 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;
}

.stage {
  position: relative;
  display: grid;
  place-items: center;
  min-height: max(420px, 58cqi);
  padding: 40px 16px 56px;
  border-radius: 18px;
  background:
    radial-gradient(var(--dots) 1px, transparent 1px) 0 0 / 14px 14px,
    var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  font-size: clamp(15px, 12.6px + 0.6cqi, 18px);
  font-weight: 500;
  line-height: 1.33;
}

/* As tall as the longest level: its padding, its head, and --rows items (set by the component). */
.slot {
  width: min(100%, 21.5em);
  height: calc(3.7em + var(--rows, 5) * 3.05em);
}

.card {
  position: relative;
  overflow: hidden;
  overflow: clip;
  border-radius: 1.45em;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--card-line),
    0 1px 2px var(--shadow-near),
    0 18px 40px -18px var(--shadow);
  -webkit-user-select: none;
  user-select: none;
}

/* A level: its head, then its items. One on its way out lies over the current one. */
.layer {
  padding: 0.4em;
}
.layer[data-leaving] {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  pointer-events: none;
}

.head {
  display: flex;
  height: 2.9em;
}
/* The title sits as far in as the items' labels: an item rises straight up into it. */
.top,
.back {
  display: flex;
  align-items: center;
  min-width: 0;
  max-width: 100%;
  height: 2.9em;
}
.top {
  padding: 0 0.9em 0 2.3em;
}
.back {
  gap: 0.45em;
  margin: 0;
  padding: 0 0.9em 0 0.55em;
  border: 0;
  border-radius: 0.8em;
  background: transparent;
  font: inherit;
  cursor: pointer;
  transition: background-color 0.15s ease;
}
.arrow {
  flex: none;
  width: 1.3em;
  height: 1.3em;
  color: var(--ink-muted);
}
.title {
  overflow: hidden;
  color: var(--ink-muted);
  white-space: nowrap;
  text-overflow: ellipsis;
}

.list {
  display: flex;
  flex-direction: column;
  gap: 0.15em;
  margin-top: 0.15em;
}
.item {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.6em;
  width: 100%;
  height: 2.9em;
  margin: 0;
  padding: 0 0.8em 0 2.3em;
  border: 0;
  border-radius: 0.8em;
  background: transparent;
  font: inherit;
  color: var(--ink);
  text-align: left;
  cursor: pointer;
  transition: background-color 0.15s ease;
}
.label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.chevron {
  flex: none;
  width: 1.05em;
  height: 1.05em;
  color: var(--ink-faint);
}
/* An option's check, in the gutter under the back arrow: it pops in when switched on. */
.check {
  position: absolute;
  top: 0.85em;
  left: 0.6em;
  width: 1.2em;
  height: 1.2em;
  opacity: 0;
  transform: scale(0.4);
  transition:
    opacity 0.15s ease,
    transform 0.35s cubic-bezier(0.3, 1.6, 0.45, 1);
}
.item[aria-checked='true'] .check {
  opacity: 1;
  transform: none;
}

/* Hover, the demo's pointer, presses, focus. */
@media (hover: hover) {
  .item:hover,
  .back:hover {
    background: var(--hover);
  }
}
.item[data-ghost],
.back[data-ghost] {
  background: var(--hover);
}
.item:active,
.back:active,
.item[data-ghost='pressed'],
.back[data-ghost='pressed'] {
  background: var(--press);
}
.item:focus-visible,
.back:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* The item travelling to the next level is drawn there now: its old self hides. */
.item[data-source] .label,
.back[data-source] .title {
  visibility: hidden;
}

/* The keys, for those with a keyboard. */
.keys {
  position: absolute;
  bottom: 18px;
  left: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.4em;
  width: 100%;
  margin: 0;
  font-size: 0.75em;
  font-weight: 400;
  color: var(--stage-ink);
}
.keys kbd {
  display: inline-grid;
  place-items: center;
  min-width: 1.75em;
  height: 1.75em;
  padding: 0 0.35em;
  border-radius: 0.5em;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--line),
    0 1px 0 var(--key-shadow);
  font: inherit;
  color: var(--ink);
}
.keys kbd + kbd {
  margin-left: -0.15em;
}
@media (hover: none) {
  .keys {
    display: none;
  }
}

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

@media (prefers-reduced-motion: reduce) {
  .item,
  .back,
  .check {
    transition: none;
  }
}
```

### drill-menu/menu.ts

```tsx
/*
 * The drill menu's content: a tree of items. An item with children opens a level of its own; an
 * item without is an option, switched on and off.
 */

export type MenuItem = {
  /** Unique across the whole tree. */
  id: string;
  label: string;
  /** The level this item opens. */
  children?: MenuItem[];
  /** An option that starts switched on. */
  on?: boolean;
};

export const SETTINGS: MenuItem[] = [
  {
    id: 'gestures',
    label: 'Gestures',
    children: [
      {
        id: 'swipe',
        label: 'Swipe',
        children: [
          { id: 'swipe-left', label: 'Left to go back', on: true },
          { id: 'swipe-right', label: 'Right to go forward', on: true },
          { id: 'swipe-down', label: 'Down to refresh' },
          { id: 'swipe-up', label: 'Up to see all windows' },
        ],
      },
      {
        id: 'tap',
        label: 'Tap',
        children: [
          { id: 'tap-wake', label: 'Tap to wake', on: true },
          { id: 'tap-zoom', label: 'Double tap to zoom', on: true },
          { id: 'tap-hold', label: 'Hold for quick actions' },
        ],
      },
      {
        id: 'pinch',
        label: 'Pinch',
        children: [
          { id: 'pinch-zoom', label: 'Pinch to zoom', on: true },
          { id: 'pinch-tabs', label: 'Pinch to see all tabs' },
        ],
      },
      {
        id: 'drag',
        label: 'Drag',
        children: [
          { id: 'drag-select', label: 'Drag to select text', on: true },
          { id: 'drag-files', label: 'Drag files between windows', on: true },
          { id: 'drag-hold', label: 'Hold to rearrange' },
        ],
      },
      { id: 'shake', label: 'Shake to undo', on: true },
    ],
  },
  {
    id: 'display',
    label: 'Display',
    children: [
      {
        id: 'text',
        label: 'Text',
        children: [
          { id: 'text-bold', label: 'Bold text' },
          { id: 'text-larger', label: 'Larger text', on: true },
          { id: 'text-shapes', label: 'Button shapes' },
        ],
      },
      {
        id: 'colour',
        label: 'Colour',
        children: [
          { id: 'colour-warm', label: 'Warmer at night', on: true },
          { id: 'colour-contrast', label: 'Increase contrast' },
          { id: 'colour-invert', label: 'Invert colours' },
        ],
      },
      { id: 'reduce-motion', label: 'Reduce motion' },
    ],
  },
  {
    id: 'sounds',
    label: 'Sounds',
    children: [
      {
        id: 'alerts',
        label: 'Alerts',
        children: [
          { id: 'alerts-messages', label: 'New messages', on: true },
          { id: 'alerts-reminders', label: 'Reminders', on: true },
          { id: 'alerts-calendar', label: 'Calendar events' },
        ],
      },
      { id: 'keyboard-clicks', label: 'Keyboard clicks' },
      { id: 'lock-sound', label: 'Lock sound', on: true },
    ],
  },
  {
    id: 'privacy',
    label: 'Privacy',
    children: [
      {
        id: 'location',
        label: 'Location',
        children: [
          { id: 'location-using', label: 'While using the app', on: true },
          { id: 'location-precise', label: 'Precise location' },
          { id: 'location-family', label: 'Share with family' },
        ],
      },
      { id: 'usage-data', label: 'Share usage data' },
      { id: 'suggestions', label: 'Personalised suggestions', on: true },
    ],
  },
];
```

### drill-menu/spring.ts

```tsx
/*
 * Springs for the Web Animations API. A damped spring released from 0 towards 1 is simulated
 * once, then sampled into a linear() easing: played over the time it takes to settle, it
 * overshoots and settles like the spring, and the browser runs it like any other curve.
 */

export type Spring = {
  /** The spring's motion, as a CSS easing. */
  easing: string;
  /** How long it takes to settle, in ms. */
  duration: number;
};

/** For browsers without linear(): a curve that overshoots a touch, then settles. */
const FALLBACK = 'cubic-bezier(0.25, 1.15, 0.4, 1)';

/**
 * A spring of unit mass: `stiffness` pulls it towards its target, `damping` holds it back. The
 * lower the damping, the more it overshoots.
 */
export function spring(stiffness: number, damping: number): Spring {
  const step = 1 / 1000;
  const positions = [0];
  let position = 0;
  let velocity = 0;
  // One millisecond at a time, until it rests within a thousandth of the way (two seconds at most).
  while (positions.length < 2000) {
    velocity += (stiffness * (1 - position) - damping * velocity) * step;
    position += velocity * step;
    positions.push(position);
    if (Math.abs(1 - position) < 0.001 && Math.abs(velocity) < 0.01) break;
  }
  const duration = positions.length - 1;
  // About one stop per frame, evenly spaced, ending on the target.
  const stops = Math.ceil(duration / 16) + 1;
  const values = Array.from({ length: stops }, (_, i) =>
    i === stops - 1 ? 1 : Number(positions[Math.round((i / (stops - 1)) * duration)].toFixed(4)),
  );
  return { easing: `linear(${values.join(', ')})`, duration };
}

let linearSupported: boolean | undefined;

/** Options for element.animate() that play `motion`, plus any of your own. */
export function timing(
  motion: Spring,
  options: KeyframeAnimationOptions = {},
): KeyframeAnimationOptions {
  linearSupported ??= CSS.supports('animation-timing-function', 'linear(0, 1)');
  return {
    duration: motion.duration,
    easing: linearSupported ? motion.easing : FALLBACK,
    ...options,
  };
}
```

### drill-menu/demo-cursor.tsx

```tsx
'use client';

import type { Ref } from 'react';
import styles from './demo-cursor.module.css';

/*
 * The demo's pointer: while the demo plays on its own, an arrow glides to what it is about to
 * press and dips as it presses, so the demo reads like someone using the component. It sits in
 * the stage (its offset parent). Each glide is a CSS transition, the two axes on different
 * easings so that the path bends a little: nothing runs between moves.
 */

const ARROW = 'M4 2.5 15 13h-4.6l2.4 5.2-2.4 1-2.4-5.2L4 17.2z';

export function DemoCursor({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} className={styles.cursor} data-state="hidden" aria-hidden="true">
      <span className={styles.vertical}>
        <span className={styles.ring} />
        <svg className={styles.arrow} viewBox="0 0 20 20">
          {/* Its shadow: the arrow again, offset and faint (a CSS filter would cost a large
              GPU surface while the cursor moves). */}
          <path className={styles.shade} d={ARROW} transform="translate(0.3 1.1)" />
          <path className={styles.body} d={ARROW} />
        </svg>
      </span>
    </span>
  );
}

/** Puts the arrow's tip at (x, y) in the stage's px, gliding there in `ms`. */
function place(cursor: HTMLElement, x: number, y: number, ms: number) {
  cursor.style.setProperty('--move', `${ms}ms`);
  cursor.style.setProperty('--x', `${x}px`);
  cursor.style.setProperty('--y', `${y}px`);
}

/**
 * Glides the cursor to the middle of `target` (it fades in nearby first, if hidden) and returns
 * how long the trip takes, in ms.
 */
export function glideTo(cursor: HTMLElement, target: Element) {
  const stage = cursor.offsetParent;
  if (!(stage instanceof HTMLElement)) return 0;
  // Screen px to the stage's own, should it sit in a scaled container.
  const box = stage.getBoundingClientRect();
  const scale = box.width / stage.offsetWidth || 1;
  const rect = target.getBoundingClientRect();
  const x = (rect.left + rect.width / 2 - box.left) / scale;
  const y = (rect.top + rect.height / 2 - box.top) / scale;
  if (cursor.dataset.state === 'hidden') {
    // It comes in from below and to the right, inside the stage.
    place(
      cursor,
      Math.min(x + 64, stage.offsetWidth - 24),
      Math.min(y + 72, stage.offsetHeight - 24),
      0,
    );
    cursor.getBoundingClientRect();
    cursor.dataset.state = 'shown';
  }
  const distance = Math.hypot(
    x - parseFloat(cursor.style.getPropertyValue('--x')),
    y - parseFloat(cursor.style.getPropertyValue('--y')),
  );
  // Already there: no trip. Otherwise, longer trips take a little longer.
  const ms = distance < 2 ? 0 : Math.round(Math.min(900, Math.max(380, distance * 2.2)));
  place(cursor, x, y, ms);
  return ms;
}

/** The arrow dips, and a ring spreads where it presses. */
export function pressWith(cursor: HTMLElement, down: boolean) {
  cursor.dataset.state = down ? 'pressed' : 'shown';
  if (!down) return;
  cursor.querySelector(`.${styles.ring}`)?.animate(
    [
      { transform: 'scale(0.2)', opacity: 0.55 },
      { transform: 'scale(1)', opacity: 0 },
    ],
    { duration: 520, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' },
  );
}

/** Fades the cursor away where it is. */
export function hideCursor(cursor: HTMLElement) {
  cursor.dataset.state = 'hidden';
}
```

### drill-menu/demo-cursor.module.css

```css
/* The demo's cursor (demo-cursor.tsx): an arrow whose tip sits at (--x, --y) in the stage. The
   outer box carries the horizontal move and the inner one the vertical move, eased differently,
   which bends the path into a gentle arc. It follows the color-scheme it sits in: a dark arrow
   edged in white on light, a light one edged in black on dark. */

.cursor {
  /* The arrow and its edge, its shadow and the shadow's edge, the ring where it presses. */
  --pointer: #1d1d1f;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.2);
  --pointer-shadow-edge: rgb(0 0 0 / 0.1);
  --pointer-ring: rgb(0 0 0 / 0.14);
  --x: 0px;
  --y: 0px;
  --move: 0ms;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  pointer-events: none;
  opacity: 0;
  transform: translateX(var(--x));
  transition:
    opacity 0.25s ease,
    transform var(--move) cubic-bezier(0.45, 0, 0.25, 1);
}
@supports (color: light-dark(#fff, #000)) {
  .cursor {
    --pointer: light-dark(#1d1d1f, #f5f5f7);
    --pointer-edge: light-dark(#fff, #000);
    --pointer-shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.5));
    --pointer-shadow-edge: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.25));
    --pointer-ring: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.22));
  }
}
.cursor[data-state='shown'],
.cursor[data-state='pressed'] {
  opacity: 1;
}
.vertical {
  display: block;
  transform: translateY(var(--y));
  transition: transform var(--move) cubic-bezier(0.3, 0.55, 0.25, 1);
}
.arrow {
  display: block;
  width: 22px;
  height: 22px;
  /* The tip, at (4, 2.5) of the drawing, on the point. */
  margin: -2.75px 0 0 -4.4px;
  overflow: visible;
  transform-origin: 4.4px 2.75px;
  transition: transform 0.12s ease;
}
.body {
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.shade {
  fill: var(--pointer-shadow);
  stroke: var(--pointer-shadow-edge);
  stroke-width: 2.4;
  stroke-linejoin: round;
}
.cursor[data-state='pressed'] .arrow {
  transform: scale(0.86);
}
/* A soft ring where it presses. */
.ring {
  position: absolute;
  top: -15px;
  left: -15px;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--pointer-ring);
  opacity: 0;
}
```

### drill-menu/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```