Button hints
NewA game HUD’s map of what each button does now: a clover of face buttons that light up with a verb, shoulder hints and a stick combo.
button-hints/button-hints.tsxThe 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
- A game HUD that tells the player what each button does right now, in the place the button sits on the controller.
- Actions that come and go with the context: a chest in reach, an object in hand, a ledge to vault.
- A tutorial or onboarding screen that shows the controls and answers each real press with a flash.
- A web or prototype game played with a keyboard, a touch screen or a gamepad, where the same hints serve all three.
- A stick-plus-button move worth spelling out next to the button it uses.
When not to use
- A full list of every control, read before playing. Use a controls screen: a table of buttons and actions.
- Keyboard shortcuts in an app or a website. Use a shortcuts panel or a
kbdhint next to the command. - A single prompt tied to a thing in the world ("Press A to talk"). Use an action prompt that floats over that thing.
- Rebinding controls. Use a settings list with one row per action and a button to capture the new key.
- A layout with more than four face actions at once, or with other buttons than the clover. Use a radial or quick selector.
You can add this component with the shadcn CLI or by hand:
Into components/button-hints of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/button-hints.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 { ButtonHints, type HintButton } from './button-hints';export function Controls({ nearDoor, held }: { nearDoor: boolean; held: HintButton[] }) { return ( <ButtonHints actions={{ x: 'Jump', b: 'Dash', a: nearDoor ? 'Open' : undefined }} shoulders={{ zl: 'Lock on', zr: 'Aim' }} combo={null} pressed={held} onPress={(button) => console.log(button, 'pressed')} /> );}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 cluster a game keeps in a corner so the player never has to look down: the four face buttons in their places on the controller, each one lit with a black banner and its verb when it has something to do, dim when it has not. Shoulder hints sit on dark plates above, and a stick combo pairs a tilt with a button. Each piece carries its own dark ground, so the HUD stands alone and reads on a light or a dark page. Hints swell in and wipe out as the context changes, and every real press (keys, taps or a gamepad) lands with a dip, a flash and a spreading ring.
- 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) / 664), or --hints-unit when set), so the whole HUD scales as one drawing and keeps the kit’s proportions: a 70-unit ring round a 42-unit disc, 60-unit dim buttons 39 units out from the clover’s centre, 38-unit banners and 26-to-28-unit type.
- The dim clover is one SVG of four circles under a mask. Each lit button adds a black circle to the mask, scaled in with the button, so a thin gap opens round its ring the way the kit cuts it, showing whatever is behind the HUD.
- Lighting a button scales its ring from 0.857 (the dim circle’s size) to 1 with an overshoot, while its banner opens by clip-path from the button outwards (sideways from the middle for X and B, leftwards for Y, rightwards for A) 70 ms later, and the verb slides in from the button’s side after 150 ms. Going dark runs it all back in about 160 ms. A new verb on a button that stays lit is a remounted word that only fades in.
- Presses come from keys (A, B, X, Y; L and R; Shift+L and Shift+R for ZL and ZR; the arrows for the stick) while the component has focus, from pointers on each hint, from a gamepad polled each frame once one has connected, and from the pressed prop. Each source is tracked by its own id, so a key and a tap on the same button do not release each other.
- A held face button dips to 0.88 in 60 ms and springs back on release; its disc flashes white with a glow, and each press remounts a ring that spreads to 1.5 times and fades, and a flash on the banner’s word. A shoulder keycap sinks 2.5 units and whitens; the stick’s cap leans the way it is tilted and its arrow glows.
- Uncontrolled, a demo plays a short run of play while it is on screen and untouched: walking (Jump, Dash), a chest (A Open, pressed), carrying (Y Drop, R Throw, pressed), a ledge (tilt up + B Vault, pressed together) and an ability on L. Any real input hands it to the reader and brings back the full set of hints.
- The hints stand alone, with no scene behind them: like a game HUD, every piece brings its own dark ground (black rings and banners, dark plates and stick bubble), so it keeps its colours on a light page and a dark one. What sits straight on the page follows the scheme through light-dark() tokens: the press ring, the focus outline, the key chips underneath, and a faint light edge that keeps a black ring round on a dark page. All shapes are SVG and CSS, with no blur filters; glows are only shadows behind sharp shapes.
| Prop | Type | Default | Description |
|---|---|---|---|
actions | Partial<Record<FaceButton, string>> | — | The face buttons’ verbs, controlled: a button with a verb is lit, with its banner; one without stays dim. Turns the demo off. |
shoulders | Partial<Record<ShoulderButton, string>> | — | The shoulder buttons’ verbs (l, zl, r, zr), controlled: a shoulder hint shows while it has one. |
combo | ComboHint | null | — | A stick and face button pair, { direction, button, label }: shows the stick bubble and a plus, and puts the label on that button’s banner. null for none. |
defaultActions | Partial<Record<FaceButton, string>> | { x: Jump, a: Open, b: Dash } | Uncontrolled, the face verbs shown when the demo is not playing. |
defaultShoulders | Partial<Record<ShoulderButton, string>> | { l: Ability, zl: Lock on, r: Throw, zr: Aim } | Uncontrolled, the shoulder verbs shown when the demo is not playing. |
defaultCombo | ComboHint | null | null | Uncontrolled, the combo shown when the demo is not playing. |
pressed | HintButton[] | — | Buttons held down, from a game’s own input: each animates as pressed while it is listed (face and shoulder buttons, and up, down, left, right for the stick). |
onPress | (button: HintButton) => void | — | Called when a button goes down, from a key, a tap or a gamepad. |
keys | boolean | true | Listen to the keyboard while the component has focus, and to a connected gamepad; also shows the key chips under the HUD. |
autoplay | boolean | true | While on screen and untouched, a short run of play: hints coming and going with the context, presses landing. Any input stops it. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark, or the reader’s; without it, the page’s color-scheme. The HUD keeps its colours on both; the press ring, focus and key chips follow it. Set --hints-unit on the root to fix the size (1px is the kit’s 664 × 270). |
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 { Fragment, useCallback, useEffect, useEffectEvent, useId, useRef, useState, type CSSProperties,} from 'react';import { useAutoplay } from './use-autoplay';import styles from './button-hints.module.css';/* * Button hints: the game HUD's map of what each button does right now. The four face buttons sit * in a clover as they do on the controller (X top, Y left, A right, B bottom), so the player reads * a position, not a letter. * * - A face button with an action is lit: a light disc in a heavy black ring, with a black banner * that grows out of it, away from the clover (up for X, left for Y, right for A, down for B),/* Button hints (button-hints.tsx): the HUD's hints alone on the page's stage: the face buttons' clover, the shoulder hints above its sides and a stick combo below its left, with the keyboard keys behind them in small chips underneath. Every size is a multiple of --u, one design unit (1 px at full size): the root sets it from its own width (a container query unit), so the whole HUD scales as one; --hints-unit fixes it. The layout is the kit's: a 664 × 270 box with the clover's centre at (332, 166). Colours are the tokens below. The HUD's own pieces keep their colours, as a game's do: black rings and banners, dark plates, light discs and keycaps, each carrying its own dark ground, so they read on a light stage and a dark one alike. Only what sits straight on the stage (the press ring, focus, the key chips) follows the scheme, through light-dark(). Motion: lighting a button scales its ring in with an overshoot while an SVG mask opens a gap in the dim clover; its banner wipes out by clip-path and its word slides in after. Presses are short transitions on the held state plus keyframes (a spreading ring, a flash) keyed per press. */.root { /* The HUD. */ --ink: #e3ded2; --disc: #e3ded2; --disc-ink: #0b0a09; --ring: #000; --banner: #000;// 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 component is a focusable group named Button hints; while it has focus, the controller’s keys are listed in aria-keyshortcuts and pressing them plays the hints, without stealing keys anywhere else on the page.
- Each face button is a real button named by its letter and verb (“A: Open”, or “Y, no action”), and each shown shoulder hint and the stick combo are buttons too; hidden hints leave the tab order and the accessibility tree.
- A polite live region reads the hints whenever the context changes (“X: Jump, A: Open, B: Dash, ZL: Lock on”); presses are not announced, so play does not flood it.
- Focus is an outline round the focused hint, and round the whole component when it has focus, dark on a light page and white on a dark one.
- Under reduced motion the demo does not play and every change is instant: lit buttons and banners appear without scaling or wiping, and presses show as a flash without movement.