zeroUI

Build a game settings menu in React

A game settings menu is tabs of rows: a setting's name on the left, its control on the right, and a footer that applies the changes. This guide builds one with zeroUI on shadcn, or installs the finished block with one command.

Start from the block

The quickest way is the Settings menu block: one file in components/blocks/ with every piece below already wired. Installing it brings the components it uses.

terminal
pnpm dlx shadcn@latest registry add @zeroui=https://zeroui.catforest.dev/r/{name}.jsonpnpm dlx shadcn@latest add @zeroui/settings-menu

The rest of this page explains how it is put together, so you can change it or build your own.

One setting, one row

Players scan a settings screen down the left edge, reading names, and reach for controls on the right. So each setting is a two-column row: the name with an optional line of explanation, then the control, pushed to the end. On a phone the columns stack.

row.tsx
function Row({ name, line, children }) {  return (    <div className="grid items-center gap-x-8 gap-y-3 py-4 sm:grid-cols-[1fr_minmax(0,16rem)]">      <div className="grid gap-0.5">        <span className="font-bold font-zero italic">{name}</span>        {line ? <span className="text-zero-sm text-zero-text-3">{line}</span> : null}      </div>      <div className="flex items-center sm:justify-end">{children}</div>    </div>  );}

The explanation is where a setting earns its place: "Stops tearing; adds a little delay" tells a player what vertical sync costs, which the name alone does not.

Tabs for each kind of setting

Group the rows by what they change (display, audio, controls, gameplay) and put each group in a Tabs panel. Volumes are Sliders, on and off are Switches, a choice from a list is a Select, and a choice of three is a Radio group. Give every control an aria-label: the row's name is not its label.

settings.tsx
<Tabs defaultValue="display">  <TabsList>    <TabsTab value="display">Display</TabsTab>    <TabsTab value="audio">Audio</TabsTab>    <TabsTab value="controls">Controls</TabsTab>  </TabsList>  <TabsPanel value="audio">    <Row name="Music">      <Slider value={music} onValueChange={(v) => setMusic(v as number)} aria-label="Music volume" />    </Row>    <Row name="Subtitles">      <Switch checked={subtitles} onCheckedChange={setSubtitles} aria-label="Subtitles" />    </Row>  </TabsPanel></Tabs>

Key bindings read best as keys: a row per action, its keys drawn with Kbd.

Apply only what changed

Keep two copies: what is on screen and what was last applied. The Apply button stays disabled until they differ, so a player can see at a glance whether anything is waiting.

settings.tsx
const [settings, setSettings] = useState(defaults);const [saved, setSaved] = useState(defaults);const changed = JSON.stringify(settings) !== JSON.stringify(saved);<Button disabled={!changed} onClick={() => setSaved(settings)}>  {changed ? "Apply" : "Applied"}</Button>

Make restoring the defaults deliberate

Restoring defaults wipes everything a player tuned, so it should not happen on a stray click. A Hold button has to be held while a fill runs across it. Released early, it drains back. It works with the pointer, with Enter or Space, or with a hotkey, and screen readers are told to press and hold.

settings.tsx
<HoldButton size="sm" onConfirm={() => setSettings(defaults)}>  Hold to restore defaults</HoldButton>

Where to go next

For the rest of a game's screens, see the blocks: a mission briefing, a loadout, a leaderboard and a HUD. To change the look of all of them at once, read Theme zeroUI with its tokens.