# Menu tabs

> A game pause menu’s header: the section title centred, its neighbours under L and R, hearts on the left and the coin count on the right, switching with a slide.

The strip across the top of a pause menu, which tells the player where they are, where they can go and how they are doing without reading anything else. The current section sits big and centred with a page dot for each section; the neighbours wait small on either side under the shoulder key that takes you there, and an end has nothing on its side, so the menu does not wrap. L and R (or Q and E) slide the whole strip one slot, the title shrinking into a side label as the neighbour grows into its place; past an end the strip bumps. Health and money stay in view, because most of what you do in a menu changes them: hearts refill a quarter at a time when you eat, bonus hearts pop in, and the count rolls. The bar is its own dark plate, so it reads on a light or a dark page.

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

Category: Games · Menus.

## Anatomy

1. **Plate**: Its own dark plate, 1012 × 82 design units (360 wide, stacked, on a phone), radius 6. Every size is a multiple of `--u`, 1 px here, so the bar scales as one drawing.
2. **Health**: Red heart containers then yellow bonus hearts, fifteen to a row, read out as one `role="img"` label.
3. **Shoulder key**: L or R, sitting over the neighbour it leads to. It is gone at an end, and dips and flashes when pressed.
4. **Side label**: The neighbour’s name, small, 43 units clear of the title and never closer than 117 to the centre. A click goes there.
5. **Title**: The current section in bold italic, centred: the selected `role="tab"`, which takes focus.
6. **Page dots**: One square dot a section under the title; the current one is lit.
7. **Coin count**: A coin and the count, which rolls to a new value and glows while it rolls.
8. **Key legend**: Q and E as chips under the bar, on the page itself, so they follow its scheme. Left out with `keys={false}`.

## When to use

- The header of a game’s pause menu, with a few top-level sections the player pages through with the shoulder buttons.
- Sections in a fixed order where the neighbours matter: the player should see where L and R lead before pressing them.
- A menu where what you do changes your state: eating heals, buying spends, so health and money stay in view at the top.
- Controller-first navigation that also works with Q and E, the arrows and a click on a neighbour, on screens of any width.
- Three to six sections. The strip shows the current one and one neighbour a side, with a page dot for each.

## When not to use

- Navigation for an app or a website, read with a mouse. Use plain tabs or a segmented control that shows every label.
- Many sections, or ones the player picks by name. Use a list menu or a grid of icons.
- Sections that loop round. This one stops at the ends on purpose; use a carousel that wraps.
- A HUD shown during play. Use a hearts row and a coin counter on their own, without the tabs.
- Sub-pages inside one section, such as item categories. Use a row of icon tabs under this header.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/menu-tabs.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 { useState } from 'react';
import { MenuTabs } from './menu-tabs';

export function PauseMenu({ health, gold }: { health: number; gold: number }) {
  const [section, setSection] = useState(1);
  return (
    <MenuTabs
      sections={['Adventure Log', 'Inventory', 'System']}
      section={section}
      onSectionChange={setSection}
      hearts={health}
      maxHearts={8}
      bonusHearts={4}
      coins={gold}
    />
  );
}
```

## States

- **First section**: `section={0}`: nothing on the left and no L key; pressing L bumps the strip.
- **Middle section**: `section={1}`: a neighbour under each key.
- **Last section**: `section={2}`: the menu does not wrap, so the right side is empty.
- **Hurt**: `hearts={5.25}`, no bonus: the sixth heart holds one quarter, the last two are empty sockets.
- **Nearly out**: `hearts={0.75}`: three quarters of the first heart, filled clockwise from the top left.
- **Bonus hearts**: `bonusHearts={15}` on a full bar: yellow hearts after the red ones, wrapping to a second row.
- **Five sections**: Five dots; the sections two away wait off the plate, ready to slide in.
- **No keys**: `keys={false}`: no Q and E legend and no keyboard switching, for a menu that handles input itself.
- **Wide**: From 640 px the bar is one row, 1012 units wide: health on the left, the tabs centred, the coins on the right. The other tiles show the stacked layout used under 640 px.

## How it works

- Every size is a multiple of one design unit, --u, worked out from the component’s width with a container query unit (min(1px, (100cqi − 32px) / 1012), or --menu-tabs-unit when set), so the bar scales as one drawing and keeps the kit’s proportions: a 1012 × 82 plate, a 21-unit bold italic title, 11-unit labels under 18 × 13 keys, 14 × 12 hearts at a 15.85 pitch, fifteen to a row. Below 640 px the drawing is 360 units wide and stacks health and coins over the tabs.
- Each section is a tab placed from the centre by its offset from the current one. The title and the side label are two copies of the name in one grid cell: as a tab leaves the centre the title fades and shrinks to 0.52 towards the label’s place while the label grows in from 1.9 times; the tab itself slides with translate in 300 ms on a slightly overshooting curve. The neighbours sit 43 units from the title’s edges and never closer than 117 units to the centre, from the words’ measured widths (a ResizeObserver), so a long title pushes them out; the L and R keys move with them.
- A press dips its key 1.5 units and flashes it white for 140 ms. Past an end the tablist is nudged 7 units towards the press and springs back with the Web Animations API, so the input still gets an answer. The page dot moves with a colour fade.
- Health is a row of SVG hearts: an empty socket under the filled quarters, each quarter the full heart clipped to a quadrant, clockwise from the top left. A new value is stepped towards every 45 ms, a quarter at a time for the red hearts, then a whole bonus heart at a time; a heart that completes pops (1.35 times, a white flash) and a bonus heart that arrives scales in with an overshoot. The coin count rolls to a new value on requestAnimationFrame with an ease-out, glowing while it rolls.
- Uncontrolled, a demo plays while it is on screen and untouched: the menu closes and opens on the first section, R to the second, the player eats (hearts refill, bonus hearts pop in), R to the last, R again bumps, L twice, L bumps. Any real input hands it to the reader and puts the full bar back.
- There is no scene behind the bar: like the kit’s screens it brings its own dark plate (#131919 under a light rule), so its colours do not change with the page. What touches the page follows the scheme through light-dark() tokens: a faint light edge round the plate on a dark page, a soft lift on a light one, and the key chips underneath. Keys, hearts and the coin are SVG; glows are text shadows behind sharp shapes, with no blur filters.

## API reference

- **sections** (`string[]`, default `['Adventure Log', 'Inventory', 'System']`): The section names, in order. The menu does not wrap: the ends have nothing on their outer side.
- **section** (`number`): The current section’s index, controlled. Turns the demo off.
- **defaultSection** (`number`, default `0`): Uncontrolled, the section shown first.
- **onSectionChange** (`(index: number) => void`): Called with the new index when the reader switches section, by key, click or tap.
- **hearts** (`number`): Health, in hearts out of maxHearts; quarters allowed (5.25). The bar steps to a new value a quarter at a time. Turns the demo off.
- **maxHearts** (`number`, default `8`): The number of red heart containers.
- **bonusHearts** (`number`, default `15`): Yellow bonus hearts after the containers; quarters allowed. They pop in one after another.
- **coins** (`number`, default `13878`): The coin count; it rolls to a new value.
- **keys** (`boolean`, default `true`): Listen to Q / E (and L / R, the arrows, Home and End) while the tabs have focus; also shows the key chips under the bar.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short run of play: the menu opening, switching, healing, bumping at the ends. Any input stops it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark, or the reader’s; without it, the page’s color-scheme. The bar keeps its colours on both; its edge and the key chips follow it. Set --menu-tabs-unit on the root to fix the size (1px is the kit’s 1012 × 82).
- **className** (`string`): Added to the root.

## Accessibility

- The sections are a real tablist named Menu sections, with one tab stop: the current tab. Q / E, L / R and the arrow keys move the selection and the focus with it, Home and End jump to the ends, and the neighbours’ tabs list Q or E in aria-keyshortcuts.
- A click or tap on a neighbour’s label (or its key) switches to it; tabs out of sight take no pointer.
- Health and coins are images with words: “Health: 8 of 8 hearts, plus 15 bonus” and “13,878 coins”. When the props change them, a polite live region reads the new value; the demo’s changes are not announced.
- Focus is a light outline round the current tab, on the bar’s own dark plate in either scheme.
- Under reduced motion the demo does not play and every change is instant: no slide, no bump, no stepping or popping hearts, no rolling count.

## Source

### menu-tabs/menu-tabs.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './menu-tabs.module.css';

/*
 * Menu tabs: the header of a game's pause menu. It tells the player where they are, where they can
 * go and how they are doing, at a glance.
 *
 * - The current section's name sits big and centred, in bold italic, with a page dot for each
 *   section under it. The sections on either side sit small under the shoulder key that takes you
 *   there: L on the left, R on the right. At an end there is nothing on that side, so the menu
 *   does not wrap.
 * - Switching (L / R, the Q / E keys, the arrows, or a click on a neighbour) slides the whole strip
 *   one slot: the title shrinks and fades into a side label as it moves away, the neighbour grows
 *   into the title as it comes in, the next one slides in from beyond the edge, and the page dot
 *   moves. The pressed shoulder key dips and flashes. Past an end, the strip nudges towards the
 *   press and springs back.
 * - Health on the left: red heart containers, with yellow bonus hearts after them, fifteen to a
 *   row. A heart holds quarters, filled clockwise from the top left. A new value is not jumped to:
 *   hearts refill a quarter at a time (a heart pops when it completes) and bonus hearts pop in one
 *   after another, the way a game heals you when you eat from the menu.
 * - The coin count on the right rolls to a new value and glows while it rolls.
 * - The bar is its own dark plate, as in the kit, so it reads the same on a light or a dark page.
 *   Every size is a multiple of --u, one design unit (1 px at the kit's 1012 × 82): the bar scales
 *   with the component's width, and below 640 px it stacks health and coins over the tabs.
 * - Uncontrolled, a demo plays: the menu opens on the first section, R to the second, the player
 *   eats (hearts refill, bonus hearts pop in), R to the last, R again bumps, L back twice, L bumps,
 *   and the menu closes and opens again. Any real input hands it to the reader.
 */

export type MenuTabsProps = {
  /** The section names, in order. */
  sections?: string[];
  /** The current section's index, controlled. Turns the demo off. */
  section?: number;
  /** Uncontrolled, the section shown first. */
  defaultSection?: number;
  /** Called with the new index when the reader switches section. */
  onSectionChange?: (index: number) => void;
  /** Health, in hearts (quarters allowed), out of maxHearts. Turns the demo off. */
  hearts?: number;
  /** The number of red heart containers. */
  maxHearts?: number;
  /** Yellow bonus hearts on top of the containers (quarters allowed). */
  bonusHearts?: number;
  /** The coin count. */
  coins?: number;
  /** Listen to Q / E (and L / R, the arrows, Home and End) while the tabs have focus. */
  keys?: boolean;
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
};

const SECTIONS = ['Adventure Log', 'Inventory', 'System'];
const MAX_HEARTS = 8;
const BONUS = 15;
const COINS = 13878;
const PER_ROW = 15;
/** How far a quarter of health takes to refill, and a bonus heart to pop in. */
const QUARTER_MS = 45;
const BONUS_MS = 50;

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const quarters = (value: number) => Math.round(value * 4) / 4;
const px = (value: number | undefined) => (value === undefined ? undefined : `${value}px`);

export function MenuTabs({
  sections = SECTIONS,
  section,
  defaultSection = 0,
  onSectionChange,
  hearts,
  maxHearts = MAX_HEARTS,
  bonusHearts,
  coins = COINS,
  keys = true,
  autoplay = true,
  colorScheme,
  className,
}: MenuTabsProps) {
  const last = Math.max(0, sections.length - 1);
  const controlled = section !== undefined || hearts !== undefined;
  const { ref, take, reduced, playing: canPlay } = useAutoplay<HTMLDivElement>();
  const playing = autoplay && !controlled && canPlay;

  const [ownSection, setOwnSection] = useState(() => clamp(defaultSection, 0, last));
  const current = clamp(section ?? ownSection, 0, last);
  // Health the demo drives while it plays; otherwise the props (or a full bar).
  const [demoHealth, setDemoHealth] = useState<{ hearts: number; bonus: number } | null>(null);
  const targetHearts = quarters(clamp(hearts ?? demoHealth?.hearts ?? maxHearts, 0, maxHearts));
  const targetBonus = quarters(Math.max(0, bonusHearts ?? demoHealth?.bonus ?? BONUS));
  const [shown, setShown] = useState(true);

  // ---- Health, shown a quarter at a time ----
  const [health, setHealth] = useState({ hearts: targetHearts, bonus: targetBonus });
  const [ready, setReady] = useState(false);
  useEffect(() => setReady(true), []);
  const instant = reduced || !shown || !ready;
  useEffect(() => {
    if (instant) {
      setHealth({ hearts: targetHearts, bonus: targetBonus });
      return;
    }
    // Hearts first, a quarter per tick; then bonus hearts, a whole one per tick.
    const timer = window.setInterval(() => {
      setHealth((now) => {
        if (now.hearts !== targetHearts) {
          const step = targetHearts > now.hearts ? 0.25 : -0.25;
          return { ...now, hearts: now.hearts + step };
        }
        if (now.bonus !== targetBonus) {
          const gap = targetBonus - now.bonus;
          const step = Math.abs(gap) < 1 ? gap : Math.sign(gap);
          return { ...now, bonus: now.bonus + step };
        }
        window.clearInterval(timer);
        return now;
      });
    }, QUARTER_MS);
    return () => window.clearInterval(timer);
  }, [targetHearts, targetBonus, instant]);

  // ---- Switching ----
  const trackRef = useRef<HTMLDivElement>(null);
  const [held, setHeld] = useState<{ side: 'left' | 'right'; id: number } | null>(null);
  const heldTimer = useRef(0);
  const press = useCallback((side: 'left' | 'right') => {
    window.clearTimeout(heldTimer.current);
    setHeld((now) => ({ side, id: (now?.id ?? 0) + 1 }));
    heldTimer.current = window.setTimeout(() => setHeld(null), 140);
  }, []);
  useEffect(() => () => window.clearTimeout(heldTimer.current), []);

  const bump = useCallback(
    (direction: -1 | 1) => {
      const track = trackRef.current;
      if (!track || reduced || !track.animate) return;
      // Pressing L at the start pushes the strip right, against the wall, and it springs back.
      const push = -direction * 7;
      track.animate(
        [
          { translate: '0 0' },
          { translate: `calc(${push} * var(--u)) 0`, offset: 0.35 },
          { translate: `calc(${-push * 0.25} * var(--u)) 0`, offset: 0.7 },
          { translate: '0 0' },
        ],
        { duration: 300, easing: 'ease-out' },
      );
    },
    [reduced],
  );

  const go = useCallback(
    (to: number, via?: 'left' | 'right') => {
      if (via) press(via);
      if (to < 0 || to > last) {
        bump(to < 0 ? -1 : 1);
        return;
      }
      if (to === current) return;
      if (section === undefined) setOwnSection(to);
      onSectionChange?.(to);
    },
    [press, bump, last, current, section, onSectionChange],
  );

  // Focus follows the selection while it is in the tabs.
  useEffect(() => {
    const track = trackRef.current;
    if (!track || !track.contains(document.activeElement)) return;
    track.querySelector<HTMLElement>('[aria-selected="true"]')?.focus({ preventScroll: true });
  }, [current]);

  // The neighbours sit a fixed gap (43 units) from the title's edges, and never closer than 117
  // units from the centre, so a long title pushes them out. That needs the words' widths: measured
  // once laid out, and again when the font or the size changes. Until then, CSS uses 117 units.
  const [widths, setWidths] = useState<{ titles: number[]; labels: number[]; unit: number }>();
  useLayoutEffect(() => {
    const track = trackRef.current;
    if (!track) return;
    const measure = () => {
      const unit = track.offsetHeight / 79;
      const read = (part: string) =>
        [...track.querySelectorAll<HTMLElement>(`[data-part="${part}"]`)].map(
          (span) => span.offsetWidth,
        );
      if (unit > 0) setWidths({ titles: read('title'), labels: read('label'), unit });
    };
    measure();
    const observer = new ResizeObserver(measure);
    track.querySelectorAll('[data-part="title"], [data-part="label"]').forEach((span) => {
      observer.observe(span);
    });
    observer.observe(track);
    return () => observer.disconnect();
  }, [sections]);
  const place = (index: number) => {
    const offset = clamp(index - current, -2, 2);
    if (!widths || offset === 0) return offset === 0 ? 0 : undefined;
    const { titles, labels, unit } = widths;
    const near = current + Math.sign(offset);
    const side = Math.max(117 * unit, titles[current] / 2 + labels[near] / 2 + 43 * unit);
    return Math.sign(offset) * (Math.abs(offset) === 1 ? side : side + 117 * unit);
  };

  const onKeyDown = (event: KeyboardEvent) => {
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    take();
    if (!keys) return;
    const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
    let handled = true;
    if (key === 'q' || key === 'l' || key === 'ArrowLeft') go(current - 1, 'left');
    else if (key === 'e' || key === 'r' || key === 'ArrowRight') go(current + 1, 'right');
    else if (key === 'Home') go(0);
    else if (key === 'End') go(last);
    else handled = false;
    if (handled) event.preventDefault();
  };

  // ---- The demo ----
  const restore = useEffectEvent(() => {
    setDemoHealth(null);
    setShown(true);
  });
  const step = useEffectEvent((name: string) => {
    if (name === 'close') setShown(false);
    else if (name === 'reset') {
      setOwnSection(0);
      setDemoHealth({ hearts: Math.min(maxHearts, 5.25), bonus: 0 });
    } else if (name === 'open') setShown(true);
    else if (name === 'eat') setDemoHealth({ hearts: maxHearts, bonus: BONUS });
    else if (name === 'next') go(current + 1, 'right');
    else if (name === 'prev') go(current - 1, 'left');
  });
  useEffect(() => {
    if (!playing) {
      restore();
      return;
    }
    const timeline: [number, string][] = [
      [700, 'close'],
      [950, 'reset'],
      [1150, 'open'],
      [2300, 'next'],
      [3300, 'eat'],
      [5600, 'next'],
      [6800, 'next'],
      [7900, 'prev'],
      [9000, 'prev'],
      [10100, 'prev'],
    ];
    const period = 11600;
    const timers: number[] = [];
    // One pass of the timeline, timed from now: run at once, then again every period.
    const run = () => {
      timers.splice(0).forEach((timer) => window.clearTimeout(timer));
      for (const [at, name] of timeline) timers.push(window.setTimeout(() => step(name), at));
    };
    run();
    const loop = window.setInterval(run, period);
    return () => {
      window.clearInterval(loop);
      timers.forEach((timer) => window.clearTimeout(timer));
    };
  }, [playing]);

  // ---- Words for assistive tech: health and coins; announced when the props change them ----
  const total = Math.ceil(health.bonus);
  const healthLabel = `Health: ${targetHearts} of ${maxHearts} hearts${
    targetBonus ? `, plus ${targetBonus} bonus` : ''
  }`;
  const coinLabel = `${coins.toLocaleString('en-US')} coins`;
  const [news, setNews] = useState('');
  const said = useRef({ hearts, bonusHearts, coins });
  useEffect(() => {
    const before = said.current;
    said.current = { hearts, bonusHearts, coins };
    if (before.coins !== coins) setNews(coinLabel);
    if (before.hearts !== hearts || before.bonusHearts !== bonusHearts) setNews(healthLabel);
  }, [hearts, bonusHearts, coins, healthLabel, coinLabel]);

  const id = useId();
  const quarter = `${id}-q`;

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      onKeyDown={onKeyDown}
      onPointerDown={take}
      onWheel={take}
      onFocus={take}
    >
      <div className={styles.bar} data-part="bar" data-shown={shown ? '' : undefined}>
        <svg className={styles.defs} aria-hidden="true">
          {/* Quarters of a heart, clockwise from the top left. */}
          <clipPath id={`${quarter}0`}>
            <rect x="-1" y="-1" width="8" height="7" />
          </clipPath>
          <clipPath id={`${quarter}1`}>
            <rect x="7" y="-1" width="8" height="7" />
          </clipPath>
          <clipPath id={`${quarter}2`}>
            <rect x="7" y="6" width="8" height="7" />
          </clipPath>
          <clipPath id={`${quarter}3`}>
            <rect x="-1" y="6" width="8" height="7" />
          </clipPath>
        </svg>

        <div className={styles.hearts} data-part="hearts" role="img" aria-label={healthLabel}>
          {Array.from({ length: maxHearts + total }, (_, index) => {
            const bonus = index >= maxHearts;
            const fill = bonus
              ? clamp(health.bonus - (index - maxHearts), 0, 1)
              : clamp(health.hearts - index, 0, 1);
            return (
              <Heart
                key={index}
                fill={fill}
                bonus={bonus}
                enter={bonus && ready && !instant}
                quarter={quarter}
                style={
                  {
                    gridRow: Math.floor(index / PER_ROW) + 1,
                    gridColumn: (index % PER_ROW) + 1,
                  } as CSSProperties
                }
              />
            );
          })}
        </div>

        <div className={styles.strip} data-part="tabs">
          <span
            className={styles.key}
            data-part="key"
            data-side="left"
            style={{ '--x': px(current > 0 ? place(current - 1) : undefined) } as CSSProperties}
            data-on={current > 0 ? '' : undefined}
            data-held={held?.side === 'left' ? '' : undefined}
            aria-hidden="true"
            onClick={() => go(current - 1, 'left')}
          >
            <KeyCap letter="L" />
          </span>
          <span
            className={styles.key}
            data-part="key"
            data-side="right"
            style={{ '--x': px(current < last ? place(current + 1) : undefined) } as CSSProperties}
            data-on={current < last ? '' : undefined}
            data-held={held?.side === 'right' ? '' : undefined}
            aria-hidden="true"
            onClick={() => go(current + 1, 'right')}
          >
            <KeyCap letter="R" />
          </span>

          <div ref={trackRef} className={styles.track} role="tablist" aria-label="Menu sections">
            {sections.map((name, index) => {
              const offset = clamp(index - current, -2, 2);
              return (
                <button
                  key={`${index}-${name}`}
                  type="button"
                  role="tab"
                  className={styles.tab}
                  data-part="tab"
                  data-offset={offset}
                  aria-selected={offset === 0}
                  tabIndex={offset === 0 ? 0 : -1}
                  aria-keyshortcuts={
                    keys && Math.abs(offset) === 1 ? (offset < 0 ? 'Q' : 'E') : undefined
                  }
                  style={{ '--o': offset, '--x': px(place(index)) } as CSSProperties}
                  onClick={() => go(index, offset < 0 ? 'left' : offset > 0 ? 'right' : undefined)}
                >
                  <span className={styles.title} data-part="title">
                    {name}
                  </span>
                  <span className={styles.label} data-part="label" aria-hidden="true">
                    {name}
                  </span>
                </button>
              );
            })}
          </div>

          <span className={styles.dots} data-part="dots" aria-hidden="true">
            {sections.map((name, index) => (
              <span
                key={`${index}-${name}`}
                className={styles.dot}
                data-part="dot"
                data-on={index === current ? '' : undefined}
              />
            ))}
          </span>
        </div>

        <Coins value={coins} label={coinLabel} instant={instant} />
      </div>

      {keys && (
        <p className={styles.legend} aria-hidden="true" data-part="legend">
          <kbd>Q</kbd>
          <kbd>E</kbd>
          <span>switch section</span>
        </p>
      )}
      <p className={styles.live} aria-live="polite">
        {news}
      </p>
    </div>
  );
}

/** One heart: an empty socket with its filled quarters on top. Pops when it completes. */
function Heart({
  fill,
  bonus,
  enter,
  quarter,
  style,
}: {
  fill: number;
  bonus: boolean;
  enter: boolean;
  quarter: string;
  style: CSSProperties;
}) {
  // A heart that mounts while the bar is live (a new bonus heart) pops in.
  const [entering] = useState(enter);
  const [previous, setPrevious] = useState(fill);
  const [pops, setPops] = useState(0);
  if (fill !== previous) {
    setPrevious(fill);
    if (fill === 1 && previous < 1) setPops((count) => count + 1);
  }
  const filled = Math.round(fill * 4);
  return (
    <span
      className={styles.heart}
      data-part="heart"
      data-bonus={bonus ? '' : undefined}
      data-enter={entering ? '' : undefined}
      style={style}
    >
      <svg
        key={pops}
        className={styles.heartArt}
        data-pop={pops ? '' : undefined}
        data-empty={filled === 0 ? '' : undefined}
        viewBox="0 0 14 12"
        aria-hidden="true"
      >
        {!bonus && <path className={styles.socket} d={HEART} />}
        {filled === 4 ? (
          <path className={styles.fill} d={HEART} />
        ) : (
          [0, 1, 2, 3]
            .filter((index) => index < filled)
            .map((index) => (
              <path
                key={index}
                className={styles.fill}
                d={HEART}
                clipPath={`url(#${quarter}${index})`}
              />
            ))
        )}
      </svg>
    </span>
  );
}

const HEART =
  'M7 11.6 1.5 6.2C-.2 4.6.1 1.6 2.3.7 3.9 0 5.7.6 7 2.3 8.3.6 10.1 0 11.7.7c2.2.9 2.5 3.9.8 5.5Z';

/** A shoulder key: a light cap with a slanted outer corner, drawn for L and mirrored for R. */
function KeyCap({ letter }: { letter: 'L' | 'R' }) {
  return (
    <svg className={styles.cap} data-part="cap" viewBox="0 0 18 13" aria-hidden="true">
      <g transform={letter === 'R' ? 'translate(18 0) scale(-1 1)' : undefined}>
        <path
          className={styles.capFace}
          d="M5.4 0H16.6Q18 0 18 1.4V11.6Q18 13 16.6 13H1.4Q0 13 0 11.6V8.6Q0 7.8.4 7.1L3.9.8Q4.4 0 5.4 0Z"
        />
      </g>
      <text className={styles.capLetter} x={letter === 'L' ? 9.6 : 8.4} y="7.1">
        {letter}
      </text>
    </svg>
  );
}

/** The coin count: an original coin and the number, which rolls to a new value. */
function Coins({ value, label, instant }: { value: number; label: string; instant: boolean }) {
  const [shownValue, setShownValue] = useState(value);
  const [rolling, setRolling] = useState(false);
  const from = useRef(value);
  useEffect(() => {
    const start = from.current;
    if (instant || start === value) {
      from.current = value;
      setShownValue(value);
      return;
    }
    let frame = 0;
    const begin = performance.now();
    const duration = Math.min(900, 300 + Math.abs(value - start) * 4);
    setRolling(true);
    const tick = (now: number) => {
      const t = Math.min(1, (now - begin) / duration);
      const eased = 1 - (1 - t) ** 3;
      const next = Math.round(start + (value - start) * eased);
      from.current = next;
      setShownValue(next);
      if (t < 1) frame = requestAnimationFrame(tick);
      else setRolling(false);
    };
    frame = requestAnimationFrame(tick);
    return () => {
      cancelAnimationFrame(frame);
      setRolling(false);
    };
  }, [value, instant]);
  return (
    <span
      className={styles.coins}
      data-part="coins"
      data-rolling={rolling ? '' : undefined}
      role="img"
      aria-label={label}
    >
      <svg className={styles.coin} data-part="coin" viewBox="0 0 18 18" aria-hidden="true">
        <circle className={styles.coinRim} cx="9" cy="9" r="8.4" />
        <circle className={styles.coinFace} cx="9" cy="9" r="6" />
        <path
          className={styles.coinMark}
          d="M9 4.6 10.2 7.8 13.4 9 10.2 10.2 9 13.4 7.8 10.2 4.6 9 7.8 7.8Z"
        />
        <path className={styles.coinShine} d="M4.2 6.2A5.6 5.6 0 0 1 7.4 3.3" />
      </svg>
      <span className={styles.count} data-part="count">
        {shownValue.toLocaleString('en-US')}
      </span>
    </span>
  );
}
```

### menu-tabs/menu-tabs.module.css

```css
/* Menu tabs. The bar is one drawing in design units: every size is a multiple of --u, 1 px at the
   kit's 1012 × 82, worked out from the component's width with a container query unit (or set
   --menu-tabs-unit to fix it). Below 640 px the bar stacks health and coins over the tabs, on a
   360-unit-wide drawing. The bar is its own dark plate, so it keeps its colours on a light page and
   a dark one; only what sits on the page itself (the plate's edge, the key chips) follows the
   scheme, through light-dark() tokens. */

.root {
  /* The bar. */
  --plate: #131919;
  --ink: #e2ded3;
  --dot: #66645d;
  --rule: #676d65;
  --key: #e2ded3;
  --key-ink: #121313;
  --flash: #fff;
  --heart: #f1362f;
  --bonus: #ffe465;
  --socket: rgb(226 222 211 / 0.16);
  --heart-edge: rgb(0 0 0 / 0.4);
  --coin-rim: #c98a1c;
  --coin-face: #f2c14e;
  --coin-mark: #fff1bf;
  --glow: rgb(255 214 102 / 0.65);
  /* On the page. */
  --edge: rgb(255 255 255 / 0);
  --lift: 0 1px 2px rgb(0 0 0 / 0.16);
  --chip: rgb(0 0 0 / 0.05);
  --chip-line: rgb(0 0 0 / 0.14);
  --chip-ink: rgb(0 0 0 / 0.62);
  --u: var(--menu-tabs-unit, min(1px, calc((100cqi - 32px) / 1012)));
  position: relative;
  container-type: inline-size;
  display: grid;
  justify-items: center;
  align-content: center;
  gap: 16px;
  box-sizing: border-box;
  width: 100%;
  padding: 24px 16px;
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(rgb(255 255 255 / 0), rgb(255 255 255 / 0.13));
    --lift: 0 1px 2px light-dark(rgb(0 0 0 / 0.16), rgb(0 0 0 / 0));
    --chip: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07));
    --chip-line: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.16));
    --chip-ink: light-dark(rgb(0 0 0 / 0.62), rgb(255 255 255 / 0.7));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* ---- The plate ---- */

.bar {
  position: relative;
  width: calc(1012 * var(--u));
  height: calc(82 * var(--u));
  overflow: hidden;
  border-radius: calc(6 * var(--u));
  background: var(--plate);
  box-shadow:
    0 0 0 1px var(--edge),
    var(--lift);
  color: var(--ink);
  line-height: 1;
  opacity: 0;
  translate: 0 calc(-10 * var(--u));
  transition:
    opacity 160ms ease-in,
    translate 160ms ease-in;
}
.bar[data-shown] {
  opacity: 1;
  translate: 0 0;
  transition:
    opacity 200ms ease-out,
    translate 320ms cubic-bezier(0.2, 0.9, 0.3, 1.15);
}
/* The light rule under the bar, with its soft fall-off. */
.bar::after {
  content: '';
  position: absolute;
  inset: auto 0 0;
  height: calc(3 * var(--u));
  background: linear-gradient(
    var(--rule) 0 33.4%,
    rgb(103 115 107 / 0.5) 33.4% 66.7%,
    rgb(103 115 107 / 0.22) 66.7%
  );
  pointer-events: none;
}
.defs {
  position: absolute;
  width: 0;
  height: 0;
}

/* ---- Health ---- */

.hearts {
  position: absolute;
  left: calc(41 * var(--u));
  top: calc(22 * var(--u));
  display: grid;
  grid-template-columns: repeat(15, calc(14 * var(--u)));
  grid-auto-rows: calc(12 * var(--u));
  column-gap: calc(1.85 * var(--u));
  row-gap: calc(2.5 * var(--u));
}
.heart {
  display: block;
  width: calc(14 * var(--u));
  height: calc(12 * var(--u));
}
.heart[data-enter] {
  animation: enter 320ms cubic-bezier(0.3, 1.6, 0.5, 1) both;
}
.heartArt {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.heartArt[data-pop] {
  animation: pop 340ms cubic-bezier(0.3, 1.4, 0.5, 1);
}
.socket {
  fill: var(--socket);
}
.fill {
  fill: var(--heart);
  stroke: var(--heart-edge);
  stroke-width: 0.5;
  paint-order: stroke;
}
.heart[data-bonus] .fill {
  fill: var(--bonus);
}
.heartArt[data-pop] .fill {
  animation: flash 340ms ease-out;
}

/* ---- The tabs ---- */

.strip {
  position: absolute;
  left: 50%;
  top: 0;
  width: 0;
  height: calc(79 * var(--u));
}
.key {
  position: absolute;
  top: calc(26 * var(--u));
  width: calc(18 * var(--u));
  left: 0;
  height: calc(13 * var(--u));
  cursor: pointer;
  opacity: 0;
  scale: 0.6;
  transition:
    opacity 120ms,
    scale 160ms ease-in,
    translate 300ms cubic-bezier(0.2, 0.9, 0.25, 1.06);
}
.key[data-side='left'] {
  translate: calc(var(--x, -117 * var(--u)) - 50%) 0;
}
.key[data-side='right'] {
  translate: calc(var(--x, 117 * var(--u)) - 50%) 0;
}
.key[data-on] {
  opacity: 1;
  scale: 1;
  transition:
    opacity 160ms 80ms,
    scale 320ms 80ms cubic-bezier(0.3, 1.5, 0.5, 1),
    translate 300ms cubic-bezier(0.2, 0.9, 0.25, 1.06);
}
.key:not([data-on]) {
  pointer-events: none;
}
.cap {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  transition: translate 260ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.key[data-held] .cap {
  translate: 0 calc(1.5 * var(--u));
  transition-duration: 50ms;
}
.capFace {
  fill: var(--key);
  transition: fill 220ms;
}
.key[data-held] .capFace {
  fill: var(--flash);
  transition-duration: 40ms;
}
.capLetter {
  fill: var(--key-ink);
  font-size: 9.4px;
  font-weight: 800;
  text-anchor: middle;
  dominant-baseline: central;
}

.track {
  position: absolute;
  inset: 0;
}
.tab {
  position: absolute;
  left: 0;
  top: 0;
  display: grid;
  justify-items: center;
  height: 100%;
  margin: 0;
  padding: 0 calc(6 * var(--u));
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: inherit;
  font: inherit;
  font-style: italic;
  white-space: nowrap;
  cursor: pointer;
  translate: calc(var(--x, var(--o) * 117 * var(--u)) - 50%) 0;
  transition: translate 300ms cubic-bezier(0.2, 0.9, 0.25, 1.06);
  touch-action: manipulation;
}
.tab[data-offset='0'] {
  cursor: default;
}
.tab[data-offset='2'],
.tab[data-offset='-2'] {
  pointer-events: none;
}
.tab:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: calc(-6 * var(--u));
}
.title,
.label {
  grid-area: 1 / 1;
  align-self: start;
  transition:
    opacity 120ms,
    scale 300ms cubic-bezier(0.2, 0.9, 0.25, 1.06),
    translate 300ms cubic-bezier(0.2, 0.9, 0.25, 1.06);
}
/* The title, at the centre: shrinks towards the label's place as it leaves. */
.title {
  margin-top: calc(26 * var(--u));
  font-size: calc(21 * var(--u));
  font-weight: 650;
  letter-spacing: -0.005em;
  opacity: 0;
  scale: 0.52;
  translate: 0 calc(15 * var(--u));
}
.tab[data-offset='0'] .title {
  opacity: 1;
  scale: 1;
  translate: 0 0;
  transition:
    opacity 180ms 30ms,
    scale 300ms cubic-bezier(0.2, 0.9, 0.25, 1.06),
    translate 300ms cubic-bezier(0.2, 0.9, 0.25, 1.06);
}
/* The neighbour's label, under its key: grows towards the title's place as it comes in. */
.label {
  margin-top: calc(45 * var(--u));
  font-size: calc(11 * var(--u));
  font-weight: 450;
  opacity: 0;
  scale: 1.9;
  translate: 0 calc(-15 * var(--u));
}
.tab[data-offset='1'] .label,
.tab[data-offset='-1'] .label {
  opacity: 1;
  scale: 1;
  translate: 0 0;
  transition:
    opacity 180ms 30ms,
    scale 300ms cubic-bezier(0.2, 0.9, 0.25, 1.06),
    translate 300ms cubic-bezier(0.2, 0.9, 0.25, 1.06);
}
.tab[data-offset='2'] .label,
.tab[data-offset='-2'] .label {
  scale: 1;
  translate: 0 0;
}

.dots {
  position: absolute;
  top: calc(54 * var(--u));
  left: 0;
  display: flex;
  gap: calc(2.2 * var(--u));
  translate: -50% 0;
  pointer-events: none;
}
.dot {
  width: calc(2.8 * var(--u));
  height: calc(2.8 * var(--u));
  border-radius: calc(0.8 * var(--u));
  background: var(--dot);
  transition: background-color 220ms;
}
.dot[data-on] {
  background: var(--ink);
}

/* ---- Coins ---- */

.coins {
  position: absolute;
  right: calc(28 * var(--u));
  top: calc(23.5 * var(--u));
  display: flex;
  align-items: center;
  gap: calc(8 * var(--u));
  height: calc(18 * var(--u));
}
.coin {
  display: block;
  width: calc(18 * var(--u));
  height: calc(18 * var(--u));
  overflow: visible;
  transition: scale 200ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.coins[data-rolling] .coin {
  scale: 1.15;
}
.coinRim {
  fill: var(--coin-rim);
}
.coinFace {
  fill: var(--coin-face);
}
.coinMark {
  fill: var(--coin-mark);
}
.coinShine {
  fill: none;
  stroke: #fff;
  stroke-width: 1.3;
  stroke-linecap: round;
  opacity: 0.85;
}
.count {
  font-size: calc(15.5 * var(--u));
  font-style: italic;
  font-weight: 400;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.01em;
  transition:
    color 300ms,
    text-shadow 300ms;
}
.coins[data-rolling] .count {
  color: #fff;
  text-shadow: 0 0 calc(6 * var(--u)) var(--glow);
  transition-duration: 80ms;
}

/* ---- Narrow: health and coins over the tabs ---- */

@container (max-width: 639px) {
  .bar {
    --u: var(--menu-tabs-unit, min(1px, calc((100cqi - 32px) / 360)));
    width: calc(360 * var(--u));
    height: calc(126 * var(--u));
  }
  .hearts {
    left: calc(14 * var(--u));
    top: calc(12 * var(--u));
  }
  .coins {
    right: calc(14 * var(--u));
    top: calc(16 * var(--u));
  }
  .strip {
    top: calc(44 * var(--u));
  }
}

/* ---- The keys, as small chips under the bar, and the live region ---- */

.legend {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 6px;
  margin: 0;
  color: var(--chip-ink);
  font-size: 12px;
  line-height: 1;
  pointer-events: none;
}
.legend kbd {
  display: inline-block;
  min-width: 1.9em;
  box-sizing: border-box;
  padding: 0.4em 0.45em;
  border: 1px solid var(--chip-line);
  border-radius: 6px;
  background: var(--chip);
  font: inherit;
  font-weight: 600;
  text-align: center;
}
.legend span {
  margin-left: 2px;
}
@container (max-width: 479px) {
  .legend {
    display: none;
  }
}
.live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@keyframes enter {
  from {
    opacity: 0;
    scale: 0.2;
  }
}
@keyframes pop {
  40% {
    scale: 1.35;
  }
}
@keyframes flash {
  from {
    fill: var(--flash);
  }
}

@media (prefers-reduced-motion: reduce) {
  .bar,
  .bar[data-shown],
  .key,
  .key[data-on],
  .cap,
  .capFace,
  .tab,
  .title,
  .label,
  .tab[data-offset='0'] .title,
  .tab[data-offset='1'] .label,
  .tab[data-offset='-1'] .label,
  .dot,
  .coin,
  .count {
    transition: none;
  }
  .heart[data-enter],
  .heartArt[data-pop],
  .heartArt[data-pop] .fill {
    animation: none;
  }
}
```

### menu-tabs/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. `visible` alone says whether it is on screen
 * in a visible tab, for clocks that should stop when nobody can see them.
 */
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, visible, reduced, playing: visible && !reduced && !taken };
}
```