Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Menu tabs

New

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.

menu-tabs/menu-tabs.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/menu-tabs of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/menu-tabs.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

pause-menu.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}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.

  • 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.
PropTypeDefaultDescription
sectionsstring[]['Adventure Log', 'Inventory', 'System']The section names, in order. The menu does not wrap: the ends have nothing on their outer side.
sectionnumber—The current section’s index, controlled. Turns the demo off.
defaultSectionnumber0Uncontrolled, the section shown first.
onSectionChange(index: number) => void—Called with the new index when the reader switches section, by key, click or tap.
heartsnumber—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.
maxHeartsnumber8The number of red heart containers.
bonusHeartsnumber15Yellow bonus hearts after the containers; quarters allowed. They pop in one after another.
coinsnumber13878The coin count; it rolls to a new value.
keysbooleantrueListen to Q / E (and L / R, the arrows, Home and End) while the tabs have focus; also shows the key chips under the bar.
autoplaybooleantrueWhile 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).
classNamestring—Added to the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

menu-tabs.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
menu-tabs.module.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);
use-autoplay.ts
// 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);
  • 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.