Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Settings screen

New

A game’s Options screen: rows of ON/OFF toggles, ◀ value ▶ steppers and action buttons, a glowing cursor frame, and Restore and Back hints, driven by d-pad or keys.

settings-screen/settings-screen.tsx
Live · interactive

Options

Button mapping
Invert vertical camera
Invert horizontal camera
Camera sensitivity
Fast
Aim with motion controls
Change jump button
Fix minimap to north
HUD mode
Normal
Text box transparency
Regular
Voice language
Controller vibration

Swap jump button B with cancel button X

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’s Options or Settings page, opened mid-game, where the player changes a setting or two and goes straight back to play.
  • A list of discrete choices (on or off, a few named levels, a button that opens a sub-screen) driven by a d-pad, a stick or the arrow keys.
  • Settings that need a line of explanation each, shown once for the selected row instead of crowding every row.
  • A screen that must offer Restore defaults and Back as controller buttons, with the hints drawn in the corner as a console game does.
  • A full-screen menu that keeps its own dark look on any page, light or dark.

When not to use

  • App or account settings on a website, used with a mouse. Use a form with switches, selects and a Save button.
  • Continuous values the player tunes by feel, such as volume or field of view. Use a slider row with a number.
  • A short pick of one option among a few, such as a difficulty at the start. Use a choice list.
  • Many pages of settings in tabs (Controls, Audio, Display). Use menu tabs above several of these lists.
  • A setting that needs free text, such as a name. Use a text field or an on-screen keyboard.

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

Into components/settings-screen of any React project, with no setup:

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

options.tsx
import { SettingsScreen, DEFAULT_ROWS, type SettingValues } from './settings-screen';import { useState } from 'react';export function Options({ onClose }: { onClose: () => void }) {  const [values, setValues] = useState<SettingValues>({ 'invert-y': true, sensitivity: 'Normal' });  return (    <SettingsScreen      rows={DEFAULT_ROWS}      values={values}      onChange={(id, value) => setValues((all) => ({ ...all, [id]: value }))}      onAction={(id) => console.log('open', id)}      onBack={onClose}    />  );}

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 page a player opens mid-game to change a setting or two and get back to play. Each row is a label on a translucent strip and a control in a dark well: an ON/OFF toggle whose lit block slides, a stepper whose arrows dim at the ends of its list, or an action button. A cream frame with corner ticks glides to the selected row and breathes while it rests, a help line says what that setting does, disabled rows shake the frame when pushed, and Restore defaults flashes every row it changes. Up and down pick a row, left and right change it, on the keyboard or a gamepad.

  • The screen is drawn at 1014 × 570 design units and every size is a multiple of one unit, --u = min(1px, 100cqi / 1014) from the component’s own width (or --settings-unit), so it scales as one picture: 420-unit rows 42.2 apart, a 205-unit label strip and a 215 × 28 control, 13.5-unit italic labels and a 24-unit title. Under 640 px wide it turns into a tall card at full size, with the labels wrapping and the controls taking just over half the row.
  • A control is a translucent black well with a double rim (two inset shadows), so it picks up the strip under it: lighter on the selected row, as the kit draws it. The toggle splits about two thirds ON and one third OFF; its lit block is one layer whose clip-path inset moves from one segment to the other with a small overshoot, while the words swap brightness.
  • The selection frame is a single element over the list, moved by translate to the selected control’s offsets (measured after each move and on resize) in 120 ms. It is a 1-unit cream line, a soft glow that breathes between 55 and 100 % opacity, and four light corner ticks cut by clip-path. The selected row’s lighter strip fades in under it.
  • Moments are counters keyed into the elements that play them: a stepper’s new value remounts and slides in from the arrow’s side while that arrow flashes and nudges outwards; pushing past either end only bumps the value; an action flashes its well; a disabled row shakes the frame; Restore remounts a flash on every row it changes, 60 ms apart down the list; a footer glyph dips when its key is pressed.
  • The rows scroll inside a viewport that keeps the selected one in view with a 12-unit margin, and fades its clipped edge with a mask only while there is more above or below. Opening plays once, when the screen is first seen: the title drops in, the rows slide in 35 ms apart and the hints rise.
  • Input: the arrow keys, Enter, Space, A, Y, B and Escape while focus is inside; hover to pick a row and clicks on segments, arrows, buttons and hints; a gamepad’s d-pad, left stick and face buttons, polled each frame once one has connected, with the d-pad repeating after 380 ms. Uncontrolled, a demo visits the menu (sensitivity up to its end and back, a toggle, HUD mode, a locked row, a scroll, then Restore) while it is on screen, untouched and not focused.
  • The screen keeps its own dark colours on any page, as a game does: its art is a few large radial gradients drifting very slowly, abstract and with no image, so nothing is upscaled or blurred. Only its footing on the page follows the scheme through light-dark(): a soft shadow on light, a faint light edge on dark, and the focus ring.
PropTypeDefaultDescription
rowsSettingRow[]DEFAULT_ROWSThe rows, top to bottom: { id, label, description?, disabled? } plus type "toggle" with a boolean value, "stepper" with options and a value, or "action" with its button’s word. Each value is the default that Restore brings back; [A] [B] [X] [Y] in a description become glyphs.
valuesRecord<string, boolean | string>—The current values by row id, controlled. Turns the demo off.
defaultValuesRecord<string, boolean | string>—Uncontrolled, the values to start from; the rows’ own values when left out.
onChange(id: string, value: boolean | string) => void—Called whenever a row’s value changes, including each row Restore sets back.
defaultSelectedstring'swap-jump'The id of the row selected at first.
onSelect(id: string) => void—Called when the selection moves to a row.
onAction(id: string) => void—Called when an action row’s button is pressed.
onRestore() => void—Called after Restore defaults (Y) has set every row back.
onBack() => void—Called when Back is pressed: B, Escape, the hint or the gamepad’s bottom button.
titlestring'Options'The screen’s heading.
restoreLabelstring'Restore default settings'The words of the Y hint.
backLabelstring'Back'The words of the B hint.
keysbooleantrueListen to the keyboard while focus is inside, and to a connected gamepad.
autoplaybooleantrueUncontrolled, a short visit to the menu plays while it is on screen, untouched and not focused; any input hands it to the reader.
colorScheme'light' | 'dark' | 'system'—The page around it, light or dark, or the reader’s; without it, the page’s color-scheme. The screen itself stays dark.
classNamestring—Added to the root element.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

settings-screen.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  Fragment,  useCallback,  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type ReactNode,} from 'react';import { useAutoplay } from './use-autoplay';import styles from './settings-screen.module.css';/* * Settings screen: a game's "Options" page, drawn as a whole screen (its own dark surface with * abstract warm art, no scene) with the list in the middle and the button hints in the corner. * * - Each row is a label on a translucent strip and a control in a dark well on its right: an
settings-screen.module.css
/* Settings screen (settings-screen.tsx): a game's Options page as a whole screen: its own dark   surface with soft abstract art, the title, the list of rows in a scrolling viewport, the help   line and the button hints in the bottom-right corner.   Sizes are multiples of --u, one design unit (1 px at full size): the screen is drawn at 1014 × 570   units and --u scales it to the stage (min(1px, 100cqi / 1014), or --settings-unit when   set). A row is 420 × 32 units, 10.2 apart: a 205-unit label strip and a 215 × 28 control.   Narrower than 640 px, the screen becomes a tall card at full size and the rows fill its width.   Colours: the screen is always dark, as a game's is: translucent strips over the art, near-black   wells with a double rim, warm greys for the lit parts, a cream frame. Only what sits on the page   (the screen's shadow or edge, the focus ring) follows the scheme, through light-dark().   Motion: the frame glides between rows (a transform transition) and breathes (opacity keyframes);   the toggle's lit block slides by clip-path; a stepper's value slides in from its side; presses,   denials and restores are keyframes keyed per event. The opening plays once, staggered by --i. */.root {  /* The screen. */  --screen: #1a1814;  --ink: #e2ded3;  --strip: rgb(64 62 57 / 0.5);  --strip-on: linear-gradient(90deg, rgb(150 140 124 / 0.5), rgb(136 129 117 / 0.44));  --well: rgb(8 7 6 / 0.62);
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. `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 screen is a section named by its heading, and the rows are a list. Each control is real: a toggle is a switch button with aria-checked, a stepper a slider with its value as aria-valuetext, an action a button; each is named by its row’s label and described by the help line while selected.
  • Focus is roving: only the selected row’s control is in the tab order, and up and down move focus with the selection (without scrolling the page), so Tab leaves the list for the two footer hints, which are buttons. The demo never moves focus, and stops while focus is inside.
  • A disabled row stays reachable, with aria-disabled, so its help line can say why it is locked; it does not change.
  • Changes are announced in a polite live region (“Camera sensitivity: Fast”, “Default settings restored”). The selection frame is the focus mark, with an outline under :focus-visible as well; the art and the glyphs in the hints are hidden from assistive technology, while glyphs inside the help line are read as their letters.
  • Under reduced motion there is no demo, no opening, no drift or breathing, and values, the frame and the strips change at once.