Game modal
NewA game’s modals: a choice stack with a gliding bright cursor, a message with an advance chevron, an item-get card over a golden burst and a satchel list.
game-modal/game-modal.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 pausing to ask one short question with a few answers (rest, buy, travel, overwrite), picked with a pad, the keys or a click.
- A line the player must acknowledge before play goes on: a result, a discovery, a story beat, with a chevron that says “press to continue”.
- The reward moment, when the player picks something up: the item’s art, its name, one value, one perk and a line of lore.
- A short inventory in a game’s frame (a satchel, a pouch), with the focused item named and explained beside the list.
- A chain of moments that lead into each other (choose, then read, then receive), each opening as the last one closes.
When not to use
- A confirmation in a product or a web app. Use the platform’s alert dialog, with its plain Cancel and Confirm buttons.
- Dialogue with a speaker, a portrait and lines typed out. Use a dialog box.
- A notice that should not stop play, such as a quest update or a pickup in passing. Use a toast or a HUD banner.
- A full inventory with tabs, a grid and stats compared. Use an item grid inside a menu screen.
- A long list of options, or options that need a description each. Use a choice list or a settings screen.
You can add this component with the shadcn CLI or by hand:
Into components/game-modal of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/game-modal.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 { GameModal, type GameModalKind } from './game-modal';export function CampMenu() { const [open, setOpen] = useState<GameModalKind | null>(null); return ( <> <button onClick={() => setOpen('choice')}>Rest</button> <GameModal open={open} onOpenChange={setOpen} choice={{ title: 'Rest by the fire?', subtitle: 'Time will pass while you rest', options: [ { label: 'Until morning', next: 'message' }, { label: 'Until night', next: 'message' }, { label: 'Cancel', cancel: true }, ], }} message={{ title: 'Rested by the fire', subtitle: 'Your strength is restored' }} onChoose={(index, option) => console.log(index, option.label)} /> </> );}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 moments a game stops and waits for a button. Dark plates with ornamental corner brackets carry an italic title and subtitle; a choice modal stacks its options on a darker panel, with a bright cream frame for a cursor; a message waits with a bobbing chevron at its foot; the item-get card pops the item over a radiant golden burst with its name, a value, a perk and a description; the satchel lists items in black cells with Close (B), and a small Title / Description panel explains the one under the cursor. Each opens with a rise, its brackets sliding in, and closes in a quick sink, with the focus kept inside. Use the arrows or W/S, A or Enter to pick, B or Escape to go back, + for the inventory.
- Four kinds share one frame language. A plate is #33322e with a 6 px radius and four 19 px corner brackets 8 px in: a chamfered L with a solid triangle in its corner and a pinhole in the triangle, drawn once as an SVG and turned for each corner. The item card is lighter grey with bright brackets; the satchel is near black with a double edge.
- The cursor is one element: a 1 px cream line with an olive light inside, a glow outside and a small solid triangle in each corner (four gradients). It sits behind the options and is translated by the cursor index times the option pitch, so it glides from option to option with a small overshoot without measuring anything; its glow breathes every 1.7 s.
- Opening, the dialog rises 0.9 em from 94% with an overshoot in 320 ms; the brackets slide in from outside their corners 70 ms later; title, subtitle and body follow 50 ms apart, the options slide in from the left 45 ms apart and the frame settles on the first one last. Closing sinks and fades in 170 ms; a modal that leads to another (choice to message, item to the satchel) closes, then the next one opens.
- A press remounts the pressed part, which replays its keyframes: the frame dips to 96.5% and flashes, the chevron and the button glyphs dip, the card flashes faintly; the action follows 190 ms later, so the press is seen first.
- The item-get card: the burst is a repeating conic gradient of soft rays masked by a radial fade, which blooms from 20% with an overshoot and then turns once every 24 s; the item glyph drops in from half its size and settles; the name, the rule, the value box and the perk chip, and the description come in after it.
- Everything is in em of the stage’s type (16 px at full size, the option label), and the stage sets its font size from its own width with a container query, so a modal scales as a whole down to 320 px; on a narrow stage the Title / Description panel moves under the satchel.
- Uncontrolled, it keeps its own modal: a Rest prompt opens the choice, and a row of chips opens each kind. A demo plays a short evening at camp (rest, pick an hour, read the message, get the item, look through the satchel, close) while on screen and untouched; any input hands it to the reader. Given open, it shows that modal and asks onOpenChange to change it.
| Prop | Type | Default | Description |
|---|---|---|---|
open | 'choice' | 'message' | 'item' | 'list' | null | — | The modal shown, controlled: null shows none. The demo is off, and every move to another modal (an option’s next, Close, B) asks onOpenChange. |
defaultOpen | 'choice' | 'message' | 'item' | 'list' | null | null | Uncontrolled, the modal shown at first. |
defaultCursor | number | 0 | The option or satchel item under the cursor when the first modal is shown; every modal opened later starts on its first. |
onOpenChange | (kind: GameModalKind | null) => void | — | Called when a modal opens, leads to another or closes. |
choice | ChoiceContent | Rest by the fire? | { title, subtitle?, options }. An option is { label, next?, cancel? }: next is the modal it leads to (absent closes), and B picks the option marked cancel. |
message | MessageContent | Rested by the fire | { title, subtitle?, body?, next? }: A or a click goes to next, or closes. |
item | ItemContent | Ember Blade | { name, glyph, value?, valueIcon?, perk?, perkIcon?, description, footer?, footerNext?, next? }. glyph is 'blade', 'emberfruit', 'capshroom', 'frostherb', 'riverfish' or 'amber'; valueIcon 'power' or 'guard'; perkIcon 'cold', 'climb' or 'speed'. + presses the footer hint and goes to footerNext. |
list | ListContent | Satchel, five items | { title, items }: each item is { name, description, glyph }. |
onChoose | (index: number, option: ModalOption) => void | — | An option of the choice modal was picked (Cancel included). |
onSelectItem | (index: number, item: ListItem) => void | — | A satchel item was picked with A, Enter or a click. |
autoplay | boolean | true | While on screen and untouched, a short evening at camp plays through every modal. Uncontrolled only. |
colorScheme | 'light' | 'dark' | 'system' | — | Forces light or dark for the plates’ edge and shadow, the prompt and the chips; by default it follows the page. The plates keep their colours. |
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 { useEffect, useEffectEvent, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode,} from 'react';import { Bracket, Chevron, Face, ItemGlyph, Perk, Stat, type ItemGlyphName, type PerkIcon, type StatIcon,/* Game modal (game-modal.tsx): the modals on their own, centred on a plain stage, with a row of chips under it to open each kind. Every size is in em of the stage's type: 1 em is 16 px at full size (the option label), so the whole modal scales as one on a narrow screen (a container query sets the stage's font size from its width). The plates keep their own dark colours on any page, as a game's modals do over any scene; only their edge, their shadow, the prompt's plate and the chips follow the colour scheme, through light-dark() tokens. No filter touches the art or the text: glows are box- and text-shadows. Motion: the dialog rises with an overshoot and sinks on close; the brackets slide in from outside; labels and options stagger in (--n); the cursor frame glides by a transition of its translate and breathes; presses replay keyframes by remounting their element. The item's burst blooms, then turns; the message's chevron bobs. Endless loops pause while the component is off screen. */.root { /* The plates. */ --plate: #33322e; --panel: #1a1917; --panel-dark: #0f0e0d; --panel-line: #5d5c57; --option: #0a0a09; --option-edge: #1f1e1b; --option-on: #121210; --frame: #bdb7a4;// Copyright (c) 2026 Bruno Delorence. MIT License.import { useId } from 'react';/* * The modal's art, drawn as small SVGs (the CSS sizes each one; every drawing is aria-hidden): * * - Bracket: the ornamental corner, a chamfered L with a solid triangle in its corner and a pinhole * in that triangle. Drawn for the top-left corner; the CSS turns it for the other three. * - Chevron: the advance mark at the foot of a message, an outlined triangle with a small solid one. * - Face: a controller face button (A, B, +), a light disc with a dark letter. * - Stat and perk icons: flat white or green marks. * - Item glyphs: a blade and five satchel items, flat and bold with one highlight each. */export type ItemGlyphName = 'blade' | 'emberfruit' | 'capshroom' | 'frostherb' | 'riverfish' | 'amber';export type StatIcon = 'power' | 'guard';export type PerkIcon = 'cold' | 'climb' | 'speed';export function Bracket({ className }: { className?: string }) { return ( <svg className={className} viewBox="0 0 19 19" aria-hidden="true"> <path d="M18.5 0.65H9.4L0.65 9.4V18.5" fill="none" stroke="currentColor" strokeWidth="1.3" />// 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);- Each modal is a role="dialog" with aria-modal, named by its title. Opened by the reader, it takes the focus (the option under the cursor, or its main control); Tab and Shift+Tab stay inside it; closing gives the focus back to what opened it. The prompt and the chips behind it are inert while it is open.
- Options and satchel items are real buttons with a roving tabindex: the focus follows the cursor, and Up/Down (or W/S), Home and End move it. A or Enter picks, B, Escape or Backspace goes back (in a choice it picks Cancel), + presses the inventory hint; the hints say their keys with aria-keyshortcuts.
- The message’s advance and the item card are buttons named Continue and OK; the satchel’s focused item is described by the Title / Description panel (aria-describedby).
- A polite live region reads each modal as it opens (its title and subtitle, “Got Ember Blade.”) and the option or item picked.
- Every control shows a :focus-visible ring; the brackets, burst, glyphs and frame are aria-hidden.
- Reduced motion: no rise, slides, glides, pops, bloom, turning burst, bob or breathing; modals and their parts fade. The demo never plays, and the loops pause off screen.