Skip to content

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
  1. Structure people can scan
  2. Labels left, controls right
  3. Choose a save model
  4. Explicit save with dirty state
  5. Validation that helps
  6. Rename in place
  7. The destructive zone
  8. Keyboard and screen readers

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.

Activate to edit.Enter saves, Escape cancels.

Profile

Changes here are saved together.

Notifications

Toggles apply right away.

Weekly digestA summary of project activity every Monday
MentionsEmail me when someone mentions me
DensityMore room around each row

Delete workspace

Removes Northwind Studio and its projects. In this preview nothing is deleted.

Press and hold for 1.2 seconds to confirm. With a keyboard, hold Space or Enter.
A live settings panel built from Arc components. The profile saves together, the toggles apply at once, and the delete is simulated.

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.

settings-section.tsx
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>  );}
settings.module.css
.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.

Terminal
npx shadcn@latest add @uiarc/input @uiarc/select @uiarc/switch \  @uiarc/segmented-control @uiarc/inline-edit @uiarc/hold-to-confirm

Choose 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:

digest-setting.tsx
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.

profile-form.tsx
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:

profile-form.tsx
// 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.

profile-form.tsx
<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.

workspace-name.tsx
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.

danger-zone.tsx
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 form so Enter in any field submits it.
  • Give every switch an accessible name, either with aria-labelledby pointing at its visible label or with aria-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.

More guides