Menu tabs
NewA 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 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:
npx shadcn@latest add https://brunodelorence.com/r/menu-tabs.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
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
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.
| Prop | Type | Default | Description |
|---|---|---|---|
sections | string[] | ['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 | 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 | 8 | The number of red heart containers. |
bonusHearts | number | 15 | Yellow bonus hearts after the containers; quarters allowed. They pop in one after another. |
coins | number | 13878 | The coin count; it rolls to a new value. |
keys | boolean | 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 | 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. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// 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. 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);// 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.