Settings menu
A game settings screen for React: a black title bar over tabs, one setting a row, and a footer that applies or, held, restores the defaults.
Install
# once per project: where @zeroui items come frompnpm dlx shadcn@latest registry add @zeroui=https://zeroui.catforest.dev/r/{name}.jsonpnpm dlx shadcn@latest add @zeroui/settings-menuPreview
Settings
ResolutionLower runs faster on older machines.
Brightness
Vertical syncStops tearing; adds a little delay.
import { type ReactNode, useState } from "react";import { Button } from "@/components/zero/button";import { HoldButton } from "@/components/zero/hold-button";import { Kbd } from "@/components/zero/kbd";import { RadioGroup, RadioGroupItem } from "@/components/zero/radio-group";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/zero/select";import { Slider } from "@/components/zero/slider";import { Switch } from "@/components/zero/switch";import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/zero/tabs";/* * A game's settings screen: a black title bar over tabs for each kind of setting, one setting a * row (its name and a line on the left, the control on the right), and a footer that applies or, * held, restores the defaults. Every value here is local state; wire them to your own store. */const defaults = { resolution: "1920x1080", brightness: 50, vsync: true, master: 80, music: 55, voices: 90, subtitles: true, difficulty: "standard", hints: true,};const resolutions = { "1280x720": "1280 × 720", "1920x1080": "1920 × 1080", "2560x1440": "2560 × 1440", "3840x2160": "3840 × 2160",};const bindings = [ ["Move", ["W", "A", "S", "D"]], ["Sprint", ["Shift"]], ["Interact", ["E"]], ["Open the map", ["M"]], ["Scrap an item", ["X"]],] as const;function Row({ name, line, children,}: { name: string; line?: string; children: ReactNode;}) { return ( <div className="grid items-center gap-x-8 gap-y-3 border-zero-line border-b-(length:--zero-stroke-thin) py-4 last:border-b-0 sm:grid-cols-[1fr_minmax(0,16rem)]"> <div className="grid gap-0.5"> <span className="font-bold font-zero text-zero-md italic">{name}</span> {line ? ( <span className="font-medium font-zero-text text-zero-sm text-zero-text-3"> {line} </span> ) : null} </div> <div className="flex items-center sm:justify-end">{children}</div> </div> );}function Level({ label, value, onChange,}: { label: string; value: number; onChange: (value: number) => void;}) { return ( <div className="flex w-full items-center gap-4"> <Slider value={value} onValueChange={(next) => onChange(next as number)} aria-label={label} /> <span className="w-8 text-right font-semibold font-zero-text text-zero-sm tabular-nums"> {value} </span> </div> );}export function SettingsMenu() { const [settings, setSettings] = useState(defaults); const [saved, setSaved] = useState(defaults); const set = <K extends keyof typeof defaults>( key: K, value: (typeof defaults)[K], ) => setSettings((current) => ({ ...current, [key]: value })); const changed = JSON.stringify(settings) !== JSON.stringify(saved); return ( <section aria-label="Settings" className="grid w-full max-w-3xl border-(length:--zero-stroke) border-zero-ink bg-zero-surface bg-(image:--zero-hatch)" > <Tabs defaultValue="display" className="gap-0"> <header className="grid gap-5 bg-zero-void px-6 pt-6 pb-5"> <h2 className="origin-left rotate-(--zero-tilt) w-fit font-black font-zero text-zero-2xl italic"> Settings </h2> <TabsList> <TabsTab value="display">Display</TabsTab> <TabsTab value="audio">Audio</TabsTab> <TabsTab value="controls">Controls</TabsTab> <TabsTab value="gameplay">Gameplay</TabsTab> </TabsList> </header> <TabsPanel value="display" className="px-6 py-2"> <Row name="Resolution" line="Lower runs faster on older machines."> <Select items={resolutions} value={settings.resolution} onValueChange={(value) => set("resolution", value as string)} > <SelectTrigger aria-label="Resolution" className="w-full"> <SelectValue /> </SelectTrigger> <SelectContent> {Object.entries(resolutions).map(([value, label]) => ( <SelectItem key={value} value={value}> {label} </SelectItem> ))} </SelectContent> </Select> </Row> <Row name="Brightness"> <Level label="Brightness" value={settings.brightness} onChange={(value) => set("brightness", value)} /> </Row> <Row name="Vertical sync" line="Stops tearing; adds a little delay."> <Switch checked={settings.vsync} onCheckedChange={(checked) => set("vsync", checked)} aria-label="Vertical sync" /> </Row> </TabsPanel> <TabsPanel value="audio" className="px-6 py-2"> <Row name="Master"> <Level label="Master volume" value={settings.master} onChange={(value) => set("master", value)} /> </Row> <Row name="Music"> <Level label="Music volume" value={settings.music} onChange={(value) => set("music", value)} /> </Row> <Row name="Voices"> <Level label="Voice volume" value={settings.voices} onChange={(value) => set("voices", value)} /> </Row> <Row name="Subtitles"> <Switch checked={settings.subtitles} onCheckedChange={(checked) => set("subtitles", checked)} aria-label="Subtitles" /> </Row> </TabsPanel> <TabsPanel value="controls" className="px-6 py-2"> {bindings.map(([action, keys]) => ( <Row key={action} name={action}> <span className="flex gap-1.5 text-zero-md"> {keys.map((key) => ( <Kbd key={key}>{key}</Kbd> ))} </span> </Row> ))} </TabsPanel> <TabsPanel value="gameplay" className="px-6 py-2"> <Row name="Difficulty" line="Can be changed between runs."> <RadioGroup value={settings.difficulty} onValueChange={(value) => set("difficulty", value as string)} aria-label="Difficulty" className="flex flex-wrap gap-x-5" > {["relaxed", "standard", "hard"].map((level) => ( <label key={level} className="flex items-center gap-2 font-medium capitalize" > <RadioGroupItem value={level} /> {level} </label> ))} </RadioGroup> </Row> <Row name="Hints" line="Marks where to go next on the map."> <Switch checked={settings.hints} onCheckedChange={(checked) => set("hints", checked)} aria-label="Hints" /> </Row> </TabsPanel> </Tabs> <footer className="flex flex-wrap items-center justify-between gap-3 border-zero-line border-t-(length:--zero-stroke-thin) bg-zero-void px-6 py-4"> <HoldButton size="sm" onConfirm={() => setSettings(defaults)}> Hold to restore defaults </HoldButton> <Button disabled={!changed} onClick={() => setSaved(settings)}> {changed ? "Apply" : "Applied"} </Button> </footer> </section> );}Behaviour
Display, audio, controls and gameplay, each a tab. A setting is a row: its name and a line on the left, its control on the right, stacking on a phone. Controls lists the bindings as keys.
Apply stays disabled until something changed. Hold to restore defaults has to be held, so a stray click never wipes a player's settings. Every value is local state: wire them to your own store.
Built from Tabs, Select, Slider, Switch, Radio group, Kbd, Button, Hold button. It installs those with it.