Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Action prompts

New

A game HUD’s context-sensitive button prompts: each context brings its set of verbs and button glyphs, swapped with a stagger.

action-prompts/action-prompts.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

  • A game HUD where the same buttons do different things by context: on a wall, while carrying, in a menu.
  • Telling the player which button does what right now, as a verb beside the button drawn in the controller’s shape.
  • Swapping one context’s actions for the next without a jump: prompts that still apply stay and glide, the rest leave and the new ones slide in.
  • Drawing the eye to a new or time-sensitive action (a charge to detonate, a thing to throw) with emphasis.
  • A row of controls at the foot of a game menu (Sort, Back, Select), with layout="row" and bare.

When not to use

  • A list of every control in the game. Use a controls screen or a table of buttons; prompts show only what applies now.
  • Keyboard shortcuts in a desktop app or a web tool. Use a shortcut hint with key caps, or the menu item’s own shortcut.
  • A single glyph inside a sentence or a tutorial line. Use the controller glyphs on their own.
  • An action the player must confirm or read before going on. Use a dialog or a toast with its own buttons.
  • Ordinary web buttons dressed as a game. Use buttons; the glyph only means something with a controller in hand.

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

Into components/action-prompts of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/action-prompts.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

controls-hint.tsx
import { ActionPrompts, type ActionSet } from './action-prompts';const SETS: ActionSet[] = [  { id: 'roam', label: 'Roam', prompts: [] },  {    id: 'climb',    label: 'Climb',    prompts: [      { label: 'Jump', button: 'X' },      { label: 'Let go', button: 'B' },    ],  },  {    id: 'carry',    label: 'Carry',    prompts: [      { label: 'Throw', button: 'R', emphasis: true },      { label: 'Cancel', button: 'B' },    ],  },];export function ControlsHint({ context }: { context: 'roam' | 'climb' | 'carry' }) {  return <ActionPrompts bare sets={SETS} context={context} />;}

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 corner of a game screen that says what the buttons do right now: Jump X and Let go B on a wall, Throw R while carrying, Sort, Back and Select in a menu. Each verb sits beside its button drawn in the controller’s shape (face disc, shoulder key, trigger, stick, d-pad). Changing context swaps the set prompt by prompt, keeping the ones that still apply; emphasised prompts glow, and pressing one dips its glyph with a ring of light. Each prompt sits on its own dark plate, so it reads on a light page or a dark one. Pick a context from the chips, then press the prompts or their letters.

  • Each context is a set of prompts: a verb and a button. A prompt’s id is its label and button unless given, so a prompt present in both the old and the new set (Cancel B, say) is the same row and stays on screen.
  • On a swap, before the browser paints, every row is measured. Leavers are taken out of the flow where they stood (pinned to the list’s foot and right edge) and slide 0.6 em right as they fade in 170 ms, 30 ms apart. Rows that stay glide from their old place to their new one by FLIP with the Web Animations API. Newcomers slide 0.9 em in from the right with an overshoot, 55 ms apart from the top, starting 150 ms in so they never cross the leavers, and each glyph pops in from half its size 60 ms after its verb.
  • The glyphs are small SVGs drawn in px of a 27 px verb and sized in em, so a prompt scales as one: a 32 px face disc, a 36 × 28 shoulder key with its outer corner cut, a trigger whose top slopes outwards, a d-pad marking its direction with a dark square, the four face buttons as rings with the one meant filled and pointing, and a stick seen slightly from above with a press or a direction arrow cut out of it by a mask.
  • An emphasised prompt (an action to notice: a charge to detonate, a thing to throw) gets a warm rim on its plate and a soft box-shadow light behind it, with a faint glow on the verb; it flares once as it arrives, then breathes every 1.8 s. No filter touches the glyphs or the verb, so they stay sharp at any pixel density.
  • Pressing a prompt remounts its glyph and verb, which replays their animations: the glyph dips to 0.82 and springs past 1, a ring expands and fades, the verb flashes brighter. A prompt with a next context moves there 220 ms later, so the press is seen before the set leaves.
  • Uncontrolled, it keeps its own context, with a row of context chips under the prompts, and a demo plays a short run of play (climb, carry, place a charge, ride, open a menu) while it is on screen and untouched; any input hands it to the reader. Given context, it shows that set and asks onContextChange to move.
  • There is no game behind the prompts here, so each sits on its own dark plate with round ends, as a HUD’s banners do, and the glyphs and verbs keep their colours on any page. The plate’s edge, its shadow and the chips follow the colour scheme through light-dark() tokens. The list sits in a room kept for four prompts, pinned to its foot with its right edge a little right of centre, so sets of any size sit about the middle without jumping sideways. bare draws the list alone, sized by --prompt-size.
PropTypeDefaultDescription
setsActionSet[]roam, climb, carry, charge, grab, ride, menuThe contexts, each { id, label, prompts }. A prompt is { label, button, emphasis?, next?, id? }: button is A, B, X, Y, +, -, L, R, ZL, ZR, dpad(-up/-down/-left/-right), face-up/down/left/right, or a stick: ls, rs, with -press, -up or -down.
contextstring—The context shown, controlled: changing it swaps the set. The demo is off, and the context chips ask onContextChange.
defaultContextstring'climb'Uncontrolled, the context it starts in.
onContextChange(context: string) => void—Asked to change context: a pressed prompt’s next, or a context chip.
onAction(prompt: ActionPrompt, context: string) => void—Called when a prompt is pressed, by click, tap, Enter or its letter.
layout'column' | 'row''column'A column lined up on the right, as in play, or a row, as at the foot of a menu.
barebooleanfalseThe list alone, without the room kept around it and the context chips, to lay over a game of your own. Their type is 27 px, or --prompt-size.
autoplaybooleantrueWhile on screen and untouched, a short run of play: contexts change and prompts are pressed. Uncontrolled only.
colorScheme'light' | 'dark' | 'system'—Forces light or dark for the plates’ edge and shadow and the chips; by default it follows the page. The glyphs and verbs keep their colours.
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.

action-prompts.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,} from 'react';import { Glyph, buttonName, glyphShape, type PromptButton } from './glyphs';import { useAutoplay } from './use-autoplay';import styles from './action-prompts.module.css';/* * Action prompts: the corner of a game HUD that says what the buttons do right now. The controls * are context-sensitive (the same B is Back in a menu, Let go on a wall, Cancel while aiming), so * each context brings its own set: a verb, then the button in the controller's shape. * * - A set swaps by prompt. Prompts that stay valid in the new context (Cancel B, say) stay and
glyphs.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.import { useId } from 'react';/* * The button glyphs, drawn as small SVGs in px of a 27 px prompt (the CSS sizes each one in em, so * they scale with the verb beside it). One light fill (--glyph) and one dark ink (--glyph-ink): * * - Face buttons (A, B, X, Y, +, −): a 32 px disc with the letter upright and bold. * - Shoulders (L, R): a 36 × 28 key with its outer top corner cut off. Triggers (ZL, ZR): a key *   whose top edge slopes down towards the outside. * - D-pad: a 32 px cross; a direction marks its arm with a dark square at the tip. * - Face cluster: the four buttons as rings in a diamond, the one meant filled in. * - Sticks: a cap seen slightly from above (a rim, a dark ring and the letter), on a short neck *   and foot. "press" puts an arrow above, pointing in; "up" / "down" put one at its lower right. *   Arrows are cut out of the stick with a mask, so a thin gap of the plate shows around them. */export type FaceButton = 'A' | 'B' | 'X' | 'Y' | '+' | '-';export type ShoulderButton = 'L' | 'R' | 'ZL' | 'ZR';export type DpadButton = 'dpad' | 'dpad-up' | 'dpad-down' | 'dpad-left' | 'dpad-right';export type ClusterButton = 'face-up' | 'face-down' | 'face-left' | 'face-right';export type StickButton =  'ls' | 'rs' | 'ls-press' | 'rs-press' | 'ls-up' | 'rs-up' | 'ls-down' | 'rs-down';
action-prompts.module.css
/* Action prompts (action-prompts.tsx): the HUD's prompts on their own, centred on a plain stage,   with a row of context chips below. Every size of a prompt is in em of its verb (27 px at full   size: a 32 px face button, a 36 × 28 shoulder key), so the stage can draw them smaller on a   narrow screen (a container query), and a bare list takes its size from --prompt-size.   A game draws its prompts straight over the world; here there is no world, so each prompt sits on   its own dark plate, as a HUD's banners do, and reads the same on a light or a dark page. The   glyphs and the verb keep their colours on both; only the plate's edge, the shadow and the chips   follow the colour scheme, through light-dark() tokens. No filter touches a shape: the shadows   and the emphasis glow are box- and text-shadows, layers drawn behind the sharp art.   Motion: newcomers and leavers are keyframes on their row (staggered by --i), the glyph's pop,   the press's dip and ring, and the emphasis's flare and breath are keyframes on their parts;   rows that stay glide by a FLIP the script plays with the Web Animations API. */.root {  /* The prompts: the verb, the glyph and its ink, the emphasis glow. */  --text: #e0ded1;  --text-hot: #f6f3e6;  --glyph: #e1ded1;  --glyph-ink: #24221d;  --glow: rgb(255 246 214 / 0.75);  --glow-wide: rgb(255 240 200 / 0.38);  /* The plate behind each prompt, its edge and its shadow on the page. */
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. */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(
  • Every prompt is a real button named by its verb and button (“Jump, X button”, “Rotate, Right stick, press”); the glyph art is aria-hidden.
  • While the component has focus, the letters A, B, X, Y, L and R press the prompt on that button, as a controller would; each prompt says its key with aria-keyshortcuts.
  • A polite live region reads a new set (“Climb: Jump, X button. Let go, B button.”) and the verb of a pressed prompt.
  • Leaving prompts are hidden from assistive technology and taken out of the tab order at once; if the focus was on one, it moves to the new set’s first prompt, or to the list.
  • The context chips are toggle buttons (aria-pressed) in a labelled group; everything shows a :focus-visible ring.
  • Reduced motion: no slides, pops, dips, rings or breathing; sets crossfade and the glow is still. The demo never plays.