Build a polished React settings page
Structure settings people can scan, save changes without surprises, and handle the destructive actions at the bottom of the page.
On this page
Structure people can scan
Nobody reads a settings page. People arrive looking for one thing, scan for the word they have in mind, change it, and leave. Every choice below serves that: find it fast, change it with confidence, and know it worked.
- Group by what people think about, not by your database tables: profile, notifications, billing, security. Five to seven groups is plenty for most products.
- Name settings by their effect. “Email me when someone mentions me” beats “Mention notifications: on”.
- Put the dangerous things last, in their own section, away from everyday controls.
Workspace
Click the name to rename it. Enter saves, Escape cancels.
Notifications
Toggles apply right away.
Delete workspace
Removes Northwind Studio and its projects. In this preview nothing is deleted.
Labels left, controls right
On a wide screen, a two column section puts the heading and a one line explanation on the left and the controls on the right. The eye runs down the left column to find the section, then across to act. Below about 640 px, the columns stack.
export function SettingsSection({ id, title, description, children }: { id: string; title: string; description?: string; children: React.ReactNode;}) { return ( <section className={styles.section} aria-labelledby={id}> <div className={styles.intro}> <h2 id={id}>{title}</h2> {description ? <p>{description}</p> : null} </div> <div className={styles.controls}>{children}</div> </section> );}.section { display: grid; grid-template-columns: minmax(0, 220px) minmax(0, 1fr); gap: 16px 32px; padding: 24px;}.section + .section { border-top: 1px solid var(--border-subtle); } /* One column when the two no longer fit side by side */@media (max-width: 640px) { .section { grid-template-columns: minmax(0, 1fr); padding: 20px 16px; }}Install the controls used in this guide in one command. They share Arc’s tokens, so a label, a switch and a select line up without extra styling.
npx shadcn@latest add @uiarc/input @uiarc/select @uiarc/switch \ @uiarc/segmented-control @uiarc/inline-edit @uiarc/hold-to-confirmChoose a save model
Most settings pages mix two kinds of change, and each wants a different save model:
- Instant for independent, reversible toggles: a notification switch, a theme, a density. Apply the change as the control moves. A Save button next to a switch makes people wonder whether the switch did anything.
- Explicit for fields that belong together or need validation: name, email and time zone in a profile. People type, check, then commit with one Save.
For instant changes, update optimistically and roll back with a message next to the control if the request fails:
import { Switch } from "@/registry/components/switch/switch"; function DigestSetting() { const [on, setOn] = useState(true); return ( <div className={styles.row}> <span id="digest-label">Weekly digest</span> <Switch checked={on} aria-labelledby="digest-label" onCheckedChange={async next => { setOn(next); // optimistic try { await updatePreferences({ digest: next }); } catch { setOn(!next); // roll back and say why, next to the switch setError("Couldn’t update the digest. Try again."); } }} /> </div> );}Explicit save with dirty state
Keep the last saved values and the draft separately. Comparing the two tells you whether there is anything to save, so the button can be disabled when there isn’t, and a Discard action can bring the draft back.
const [saved, setSaved] = useState(profile);const [draft, setDraft] = useState(profile);const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle");const dirty = JSON.stringify(draft) !== JSON.stringify(saved); async function save(event: React.FormEvent) { event.preventDefault(); if (!dirty || emailError(draft.email)) return; setStatus("saving"); await saveProfile(draft); setSaved(draft); setStatus("saved");} return ( <form onSubmit={save} noValidate> <Input label="Full name" value={draft.name} onChange={event => setDraft({ ...draft, name: event.target.value })} /> {/* …more fields… */} <Button type="submit" loading={status === "saving"} disabled={!dirty}> {status === "saved" && !dirty ? "Saved" : "Save changes"} </Button> {dirty ? <Button type="button" variant="ghost" onClick={() => setDraft(saved)}>Discard</Button> : null} <span role="status">{dirty ? "Unsaved changes" : ""}</span> </form>);Confirm the save where it happened. The button itself changes to “Saved” and a status line says so for screen readers. A toast in a corner is easy to miss and says nothing about which section it means, so never rely on one alone.
If people can lose edits by leaving, warn them:
// Warn before a reload or tab close drops unsaved edits.useEffect(() => { if (!dirty) return; const warn = (event: BeforeUnloadEvent) => event.preventDefault(); window.addEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn);}, [dirty]);Validation that helps
Validate when someone leaves a field or presses Save, not on every keystroke. Showing “invalid email” while someone is halfway through typing it is noise. Say what a valid value looks like, not just that the current one is wrong.
<Input label="Email" type="email" value={draft.email} onChange={event => setDraft({ ...draft, email: event.target.value })} onBlur={() => setTouched(true)} error={touched ? emailError(draft.email) : undefined} autoComplete="email"/>Arc’s Input renders the error under the field, links it with aria-describedby, and sets aria-invalid, so screen readers read the message with the field. Try clearing the email in the demo above and moving on.
Rename in place
Some values are shown more than they are edited, like a workspace or project name. Rather than a form field that is always open, show the text and let people click it to edit. Enter saves and Escape cancels.
import { InlineEdit } from "@/registry/components/inline-edit/inline-edit"; <InlineEdit label="Workspace name" value={workspace.name} validate={next => (next.trim() ? null : "The workspace needs a name.")} // Resolve to confirm, reject to restore the old name and show the error onSave={next => renameWorkspace(workspace.id, next)}/>Inline edit saves optimistically. If onSave rejects, it restores the last saved value and shows why, so the page never claims a name that the server refused.
The destructive zone
Deleting a workspace should take a deliberate act, but a “type the workspace name to confirm” dialog is heavy for every destructive action. A hold to confirm button asks for a second of commitment instead. Letting go early rewinds it, so an accidental click does nothing.
import { HoldToConfirm } from "@/registry/components/hold-to-confirm/hold-to-confirm"; <HoldToConfirm label="Hold to delete workspace" confirmedLabel="Workspace deleted" duration={1200} onConfirm={() => deleteWorkspace(workspace.id)}/>Say exactly what will be removed, and in what state it leaves the account. When deletion cannot be undone, keep a typed confirmation for the rarest and largest actions, and use the hold for the rest.
Keyboard and screen readers
- Wrap the explicit save group in a
formso Enter in any field submits it. - Give every switch an accessible name, either with
aria-labelledbypointing at its visible label or witharia-label. - Announce save results with
role="status"and errors next to their field, not in a separate list at the top. - Hold to confirm works from the keyboard: hold Space or Enter for the same duration.
- Keep focus where it was after saving. Moving focus to a toast or the top of the page loses people’s place.