Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Build it: screens and apps
  4. React Settings Page: Section Nav, Save Bar, Danger Zone
Auf Deutsch lesen
  1. Blog
  2. Build it: screens and apps
  3. React Settings Page: Section Nav, Save Bar, Danger Zone
Build it: screens and apps
Build it: screens and apps

React Settings Page: Section Nav, Save Bar, Danger Zone

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 11 min read

On this page

0%
  1. What does a good account settings page UI need?
  2. How do you set up the settings layout in React?
  3. How do you put each section in the URL in a Next.js settings page?
  4. How should switches save on a settings page?
  5. How do you build a danger zone that cannot be clicked by accident?
  6. How does the settings page work on a phone?
  7. What if you only need the phone list of settings?
  8. Is there a complete React settings page template?
  9. What should you do next?
  10. FAQ
    1. Should a settings page save automatically or use a Save button?
    2. How do I warn users about unsaved changes on a settings page?
    3. How should a settings page look on mobile?
    4. What belongs in a settings danger zone?
    5. Is there a ready-made React settings page template?

TL;DR

A good React settings page splits settings into sections with a left nav on desktop and an iOS-style list that pushes each section in on phones. Text fields collect into a draft that a save bar commits or discards, switches save on their own and roll back when the request fails, and destructive actions sit last in a danger zone behind a typed confirmation. With Wingo UI's Settings Layout that is one component plus your own fields and save handlers.

Published Oct 16, 2026

Every app grows a settings screen, and most of them are built last and in a hurry: one long form, a Save button at the bottom that nobody scrolls to, switches that look saved but are not, and a "Delete account" button sitting right next to "Change password". This tutorial builds a React settings page that avoids all of that, step by step: a section nav on desktop, an iOS-style push list on phones, a save bar that appears only when something changed, switches that save on their own, and a danger zone at the end. We build Wingo UI and the code uses our Settings Layout, so we are not neutral; the patterns work with any library.

What does a good account settings page UI need?

A good account settings page UI needs five things: addressable sections, a visible save model, toggles that apply at once, a separate danger zone and a real phone layout. Each one comes from a product people already use:

  • Sections, each with its own address. Profile, notifications, billing, security. A left column when there are many, tabs when there are a few. Support links should land on the right section, so the open section belongs in the URL.
  • A save model you can see. Shopify's Save Bar API (opens in a new tab) shows Save and Discard only while a form has unsaved changes, and its leaveConfirmation() asks the merchant before the app navigates away (as of October 2026). That is the model for text fields.
  • Toggles that apply at once. A switch that waits for a Save button lies about its state. It should save immediately and flip back if the request fails.
  • Destructive actions last. A separate block with a red outline, buttons instead of switches, and a confirmation that takes a deliberate act, like typing a word.
  • A phone layout. On a 390px screen a sidebar does not fit. iOS Settings solved this long ago: a grouped list of sections, and tapping one pushes it in.

Many React settings page templates cover the first point and stop there. Untitled UI's settings pages (opens in a new tab), for example, list 21 layouts (profile, password, team, billing, notifications, integrations) and, as of October 2026, the page does not mention unsaved changes, a danger zone or a phone layout. Those are the parts that take the time, so they get the most space here.

How do you set up the settings layout in React?

You set it up with one SettingsLayout that receives your sections as data and owns the nav and the save bar. Install it with the controls:

bash
npx wingo-ui@latest add settings-layout switch input alert-dialog

The Settings Layout component is the frame: it takes a list of sections, renders the nav, swaps the active page with a short fade, and owns the save bar. Each section is an object with an id, a label, an optional icon, group (an eyebrow label in the sidebar and one inset group on phones), description (the subtitle in the phone list), tone (the phone icon tile) and content. Inside a section you compose SettingsSection (a titled group of rows) and SettingsRow (a label, a description and one control on the right). A row wires its label to the control for you, so tapping the label text flips a switch.

Here is the client view with three sections, a save bar for the text fields and switches that save on their own:

tsx
// app/(app)/settings/settings-view.tsx
"use client"
import { Bell, TriangleAlert, User } from "lucide-react"
import { useState } from "react"
import { Input } from "@/components/ui/input"
import { SettingsLayout, SettingsRow, SettingsSection } from "@/components/ui/settings-layout"
import { Switch } from "@/components/ui/switch"
import { DangerZone } from "./danger-zone"
export type AccountSettings = { name: string; title: string; digest: boolean; mentions: boolean }
type Preference = "digest" | "mentions"
export function SettingsView({ initial, section }: { initial: AccountSettings; section: string }) {
const [saved, setSaved] = useState(initial)
const [draft, setDraft] = useState(initial)
const [prefError, setPrefError] = useState<Partial<Record<Preference, string>>>({})
// only text fields make the page dirty; switches save on their own
const dirty = draft.name !== saved.name || draft.title !== saved.title
const savePreference = async (key: Preference, value: boolean) => {
setPrefError((e) => ({ ...e, [key]: undefined }))
const res = await fetch("/api/settings/preferences", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
})
if (!res.ok) {
// the row shows this; the rejection makes the switch flip back
setPrefError((e) => ({ ...e, [key]: "Could not save. Try again." }))
throw new Error("Preference not saved")
}
setSaved((s) => ({ ...s, [key]: value }))
setDraft((d) => ({ ...d, [key]: value }))
}
return (
<SettingsLayout
title="Settings"
description="Your profile and how we reach you"
defaultValue={section}
onValueChange={(id) => {
// keep any other query params, only swap the section
const url = new URL(window.location.href)
url.searchParams.set("section", id)
window.history.replaceState(null, "", url)
}}
dirty={dirty}
onSave={async () => {
const res = await fetch("/api/settings/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: draft.name, title: draft.title }),
})
if (!res.ok) throw new Error("We could not save your profile. Try again.")
setSaved(draft)
}}
onDiscard={() => setDraft(saved)}
sections={[
{
id: "profile",
label: "Profile",
group: "Account",
icon: <User />,
tone: "info",
description: draft.name,
content: (
<SettingsSection title="Public profile" description="Shown to your teammates.">
<SettingsRow label="Full name" controlWidth="md">
<Input value={draft.name} onValueChange={(name) => setDraft({ ...draft, name })} autoComplete="name" />
</SettingsRow>
<SettingsRow label="Job title" controlWidth="md">
<Input value={draft.title} onValueChange={(title) => setDraft({ ...draft, title })} />
</SettingsRow>
</SettingsSection>
),
},
{
id: "notifications",
label: "Notifications",
group: "Account",
icon: <Bell />,
tone: "warning",
description: "Email digest and mentions",
content: (
<SettingsSection title="Email">
<SettingsRow label="Daily digest" description="New deals and overdue invoices, at 8 AM." error={prefError.digest}>
<Switch checked={saved.digest} onCheckedChange={(v) => savePreference("digest", v)} />
</SettingsRow>
<SettingsRow label="Mentions" description="When someone tags you in a note." error={prefError.mentions}>
<Switch checked={saved.mentions} onCheckedChange={(v) => savePreference("mentions", v)} />
</SettingsRow>
</SettingsSection>
),
},
{
id: "danger",
label: "Danger zone",
icon: <TriangleAlert />,
danger: true,
description: "Delete the account",
content: <DangerZone />,
},
]}
/>
)
}

Three details carry most of the weight. dirty compares the draft to the last saved values, so typing a name and then deleting the change hides the bar again. onSave returns a promise: while it runs the button spins, a resolved promise flashes "Changes saved" and slides the bar away, and a thrown error keeps the bar, shakes it and shows your message. onDiscard puts the saved values back, and the layout also calls it when someone confirms leaving a section with unsaved changes.

Tap Profile to push it in, edit the name and watch the save bar slide up with Discard and Save, then tap the back button. In a frame this narrow the sections are an iOS-style list; at 768px and wider the same component shows a sidebar
$ npx wingo-ui@latest add settings-layout
ProSettings Layout docs

How do you put each section in the URL in a Next.js settings page?

Read the section from the query string in the server page and write it back with history.replaceState on the client. In Next.js 16, searchParams is a promise, so await it:

tsx
// app/(app)/settings/page.tsx
import { getAccountSettings } from "@/server/settings" // your data layer
import { SettingsView } from "./settings-view"
export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ section?: string }>
}) {
const [{ section }, settings] = await Promise.all([searchParams, getAccountSettings()])
return <SettingsView initial={settings} section={section ?? "profile"} />
}

The view above updates the address with window.history.replaceState in onValueChange. Next.js supports the native History API (opens in a new tab) and keeps usePathname and useSearchParams in sync with it, and the call rewrites the address without a navigation, so switching sections never refetches the settings or reruns the server page. Building the URL from window.location.href keeps any other query params, which a bare ?section= string would drop. An unknown id in the URL falls back to the first enabled section.

There is one phone trade-off to know about. A query parameter selects the section, but on a phone the layout still opens on the grouped list, the way iOS Settings does. If deep links must open the section itself on phones, give each section an href and make it a real route (/settings/notifications) with value controlled from the pathname: the layout opens a routed section already pushed. Sections with an href render children instead of content, and guarding unsaved changes across routes becomes your router's job.

How should switches save on a settings page?

Immediately, with a rollback. The Switch treats a promise from onCheckedChange as a save: it flips at once, shows a small spinner in the thumb, ignores clicks until the promise settles, and flips back if it rejects. That is why savePreference in the view throws on a failed response and only updates saved after success. checked stays bound to the saved value, so while the request runs the switch shows the pending value, and afterwards it follows checked again and never claims a state the server does not have.

The error message is the part that trips people up. A Switch with its own label shows its errorText under the label on a failed save, but inside a SettingsRow the switch has no label of its own: the row supplies it. A bare switch still shakes, flips back and announces errorText to screen readers, yet nothing visible says why. That is why the view keeps a prefError map and passes it to the row's error prop, which renders the message under the description and links it to the switch with aria-describedby. Clear the entry at the start of each attempt so a retry that succeeds removes the message.

Keeping switches out of dirty matters. If a toggle also raised the save bar, people would see "You have unsaved changes" for something that already saved, and the next Discard would appear to undo it. One page, two save models, and each control makes it obvious which one it uses.

Flip New messages, then press Simulate an error and flip it again: the switch turns at once, spins while it saves, then rolls back and shows why
$ npx wingo-ui@latest add switch
FreeSwitch docs

The Switch is free. Inside a SettingsRow the bare track gets a 44px hit area on touch screens, the row's label is wired to it so tapping "Daily digest" flips it too, and a vertical swipe across the track still scrolls the page.

How do you build a danger zone that cannot be clicked by accident?

Put it last, mark it, and make the final step deliberate. SettingsSection takes danger for a red ring and title, SettingsRow takes danger for a red label, and the section item takes danger: true so the phone list shows it in red too. The confirmation is an Alert Dialog with a typed word:

tsx
// app/(app)/settings/danger-zone.tsx
"use client"
import { Trash2 } from "lucide-react"
import { useState } from "react"
import { AlertDialog } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { SettingsRow, SettingsSection } from "@/components/ui/settings-layout"
export function DangerZone() {
const [open, setOpen] = useState(false)
const [typed, setTyped] = useState("")
return (
<>
<SettingsSection title="Danger zone" description="Actions that cannot be undone." danger>
<SettingsRow label="Delete account" description="Removes your profile and every workspace you own." danger>
<Button variant="soft" tone="danger" size="sm" leftIcon={<Trash2 />} onClick={() => setOpen(true)}>
Delete account
</Button>
</SettingsRow>
</SettingsSection>
<AlertDialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setTyped("")
}}
tone="danger"
icon={<Trash2 />}
title="Delete your account?"
description="Your profile, workspaces and invoices will be deleted for good."
confirmLabel="Delete account"
cancelLabel="Cancel"
confirmDisabled={typed !== "DELETE"}
onConfirm={async () => {
const res = await fetch("/api/account", { method: "DELETE" })
if (!res.ok) throw new Error("We could not delete the account. Try again.")
window.location.assign("/goodbye")
}}
>
<Input label="Type DELETE to confirm" value={typed} onValueChange={setTyped} autoComplete="off" spellCheck={false} />
</AlertDialog>
</>
)
}

Focus lands on Cancel when the dialog opens, clicking the dimmed page does nothing, and the action stays disabled until the word matches. onConfirm returns a promise, so the button spins while the request runs and a thrown error shakes the card and shows the message above the buttons instead of closing it. Reset typed when the dialog closes, or the next open starts already armed.

How does the settings page work on a phone?

Settings Layout measures its own width, and below 768px it switches the nav. The default mobileNav="list" is the iOS pattern: the page title becomes a large title over a grouped list, each section is a row with its icon tile and description, and tapping a row pushes the section in. The back button, Escape or a swipe to the right takes you back. Two alternatives exist for shorter pages: "tabs" (sticky scrolling pill tabs) and "select" (a select that opens a bottom sheet with the same groups).

The save bar changes too. On a phone it always pins to the bottom, sits above the safe area and above a bottom tab bar that publishes its height as --bottom-bar-offset, lifts over the on-screen keyboard, and the layout pads the content so the bar never covers the last row. If you have fought position: fixed on iOS before, our post on the iOS Safari keyboard and fixed bars explains why that last part needs care, and the bottom navigation bar guide covers the tab bar side of --bottom-bar-offset.

On a desktop, the save button shows a key hint for saveShortcut (default mod+s: Cmd+S on a Mac, Ctrl+S elsewhere), hidden on touch screens. The shortcut is bound only while there is something to save, even inside a text field, so the browser's own Save Page still works the rest of the time. Pass saveShortcut="" to turn it off.

What if you only need the phone list of settings?

Use the List component when a whole layout is more than you need, such as a "Me" tab in a phone-first app with a few rows that link to pages, show a current value or toggle something. It draws the iOS grouped panel on its own:

tsx
import { Bell, Globe, User } from "lucide-react"
import { List, type ListGroupData } from "@/components/ui/list"
import { Switch } from "@/components/ui/switch"
const groups: ListGroupData[] = [
{
id: "account",
label: "Account",
items: [
{ id: "profile", title: "Profile", description: "emma.carter@northwind.app", icon: <User />, href: "/settings/profile" },
{ id: "notifications", title: "Notifications", icon: <Bell />, trailing: <Switch defaultChecked aria-label="Notifications" /> },
{ id: "language", title: "Language", icon: <Globe />, value: "English", href: "/settings/language" },
],
},
]
export function MeTab() {
return <List groups={groups} />
}

Rows with an href end in a chevron, value shows the current choice on the right, and trailing takes any control. When a row has both an href and a Switch in trailing, the row gets a stretched pressable instead of a wrapping button, so the switch never nests inside a link. The full Settings Page block uses the same List for its signed-in devices.

Is there a complete React settings page template?

Yes, if you would rather start from the finished screen. The Settings Page block is built on Settings Layout and ships six sections: profile (photo upload with crop, phone, time zone), account (email change with a 6-digit code, workspace address check), notifications (a switch per event and channel, quiet hours, digest), billing (plan, usage, invoices that turn into cards on phones), security (password, a three-step two-factor setup with a QR code and backup codes, sessions) and a danger zone with export and a typed delete. Its section ids are Romanian keys behind English labels: profil, cont, notificari, facturare, securitate, pericol.

bash
npx wingo-ui@latest add settings-page
tsx
"use client"
import { SettingsPage } from "@/components/blocks/settings-page"
export function SettingsRoute() {
return (
<SettingsPage
sections={["profil", "notificari", "securitate", "pericol"]}
onSave={async (section, values) => {
const res = await fetch(`/api/settings/${section}`, { method: "PUT", body: JSON.stringify(values) })
if (!res.ok) throw new Error("We could not save. Please try again.")
}}
onPreferenceChange={async (preference, value) => {
const res = await fetch("/api/settings/preferences", { method: "PATCH", body: JSON.stringify({ preference, value }) })
if (!res.ok) throw new Error("Could not save. Try again.")
}}
onDeleteAccount={async () => {
const res = await fetch("/api/account", { method: "DELETE" })
if (!res.ok) throw new Error("We could not delete the account. Try again.")
window.location.assign("/goodbye")
}}
/>
)
}

onSave covers the text fields of the open section and onPreferenceChange the switches that save on their own, with the same rollback as above (here a rejection flips the switch back with a toast). Both handlers must throw on a failed response: fetch only rejects on a network error, so a 500 that you do not turn into a throw counts as a successful save. The preference keys are paths such as notificari.quietHours or securitate.loginAlerts. Every string is a labels prop and every piece of data has its own prop (profile, billing, sessions), so you replace the sample one piece at a time. Our Next.js admin dashboard tutorial wires this block to Server Actions inside a full back office, and the rest of the build category covers other complete screens.

Open Delete account and type DELETE to enable the button, or press Export to watch the export count up in a toast first
$ npx wingo-ui@latest add settings-page
ProSettings Page docs

What should you do next?

Start with the layout and one section, wire its save, then add the switches and the danger zone. The Switch and Input are free, while Settings Layout, List, Alert Dialog and the Settings Page block are part of Wingo UI Pro (pricing). Install the layout with npx wingo-ui@latest add settings-layout and build your first section from the code above.

Components in this post

  • Settings Layout

    A React settings page layout like macOS and iOS: a section nav, grouped label and control rows, and a save bar that slides up on changes.

    Pro
  • Settings Page

    A React account settings page for a back office: profile, account, notifications, billing, security and a danger zone, with a save bar.

    Pro
  • Switch

    A React toggle switch for settings that apply at once: a spring thumb that stretches and drags, thumb icons, and async saves that roll back.

    Free
  • List

    A React list for settings, contacts, inboxes and orders: iOS grouped panels, selection, row menus, swipe actions and drag to reorder.

    Pro
  • Alert Dialog

    A React alert dialog for confirmations: an async confirm that loads, fails inline and shakes, plus an await confirm() API.

    Pro
  • Input

    A React text input: icons and addons inside the box, a clear button, a loading spinner, a character counter and floating labels.

    Free

FAQ

Should a settings page save automatically or use a Save button?

Use both, each for its own kind of control. Switches and other single-choice toggles save the moment they change and flip back if the request fails, while text fields collect into a draft that a save bar commits, because a half-typed name should never reach the server.

How do I warn users about unsaved changes on a settings page?

Track a dirty flag by comparing the draft to the last saved values, show a save bar while it is true, ask before switching sections, and register a beforeunload handler so closing the tab warns too. Settings Layout does all three from its dirty, confirmLeave and warnBeforeUnload props.

How should a settings page look on mobile?

Turn the section nav into a grouped list, like iOS Settings, where tapping a row pushes that section in with a back button and swipe back. Pin the save bar above the tab bar and the safe area so it never covers the last row or hides behind the on-screen keyboard.

What belongs in a settings danger zone?

Actions that cannot be undone, such as deleting the account, transferring ownership or removing a workspace. Put the zone last, give it a red outline and title, use a button instead of a switch, and ask for a typed confirmation before running the action.

Is there a ready-made React settings page template?

Wingo UI's Settings Page block ships profile, account, notifications, billing, security and danger zone sections with a save bar and phone layout, and installs with npx wingo-ui@latest add settings-page. It is part of Wingo UI Pro; the Switch and Input it uses are free.

  • Settings
  • React
  • Next.js
  • Forms
  • Mobile UI

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Build it: screens and apps

Nextjs Admin Dashboard Tutorial: 5 Screens, Phone-Ready

A nextjs admin dashboard tutorial for the App Router: app shell, KPI home, server-side data tables, settings and team pages, each one usable on a phone.

SRSerban Rusu·Oct 9, 2026·21 min read
Mobile UI in React

React Action Sheet: iOS-Style, Async and Responsive

Build a React action sheet like iOS: red destructive rows, a separate Cancel, an awaitable show() that waits for async actions, and a dialog from 768px.

SRSerban Rusu·Oct 16, 2026·9 min read
Next.js and Tailwind CSS

React Controllable State Hook: Code and 3 Pitfalls

Build a React controllable state hook so one component takes value or defaultValue. Full TypeScript code, three real pitfalls, and how the Radix hook differs.

SRSerban Rusu·Oct 16, 2026·10 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI