# 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.

- Type: React component (Layout)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.1.0
- Install: `npx wingo-ui@latest add settings-layout`
- Docs: https://wingo-ui.com/components/settings-layout

## Interaction

Pick a section in the sidebar (Up / Down move between items, Home / End jump, Enter opens; disabled items are skipped) and its page fades in at the top while the indicator glides over. Change anything and a dark bar slides up from the bottom: "You have unsaved changes · Discard · Save". Save with the button or mod+s (Cmd+S / Ctrl+S, bound only while there is something to save, even inside a text field): the button spins, then the bar says "Changes saved" for a moment and slides away; a failed save keeps the bar, shakes it and shows the error. Escape inside the bar does nothing, so discarding is always a deliberate click. Switching sections with unsaved changes asks first (Discard / Keep editing; in the tabs, arrow keys then only move focus and Enter or Space switches), and closing the tab warns too. In scroll mode every section sits on one page, the nav follows your scroll and a click glides to the section, below the sticky tabs when there are tabs. Below 768px (the layout's own width) the nav becomes an iOS grouped list: tap a row to push its page, then tap the back button, press Escape or swipe the page to the right to go back; or sticky scrolling pill tabs, or a select whose bottom sheet shows the same groups. On a phone the bar pins above the tab bar and the safe area, lifts over the on-screen keyboard and never covers the last row. Rows bind their label to the control, so tapping "Daily digest" flips its switch.

## Installation

```bash
npx wingo-ui@latest add settings-layout
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/settings-layout` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/settings-layout.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Alert Dialog](https://wingo-ui.com/components/alert-dialog) ([Markdown](https://wingo-ui.com/components/alert-dialog.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Kbd](https://wingo-ui.com/components/kbd) ([Markdown](https://wingo-ui.com/components/kbd.md))
- [Select](https://wingo-ui.com/components/select) ([Markdown](https://wingo-ui.com/components/select.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [Split View](https://wingo-ui.com/components/split-view) ([Markdown](https://wingo-ui.com/components/split-view.md))
- [Tabs](https://wingo-ui.com/components/tabs) ([Markdown](https://wingo-ui.com/components/tabs.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) ([Markdown](https://wingo-ui.com/components/use-keyboard-inset.md))
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))
- [Navigation](https://wingo-ui.com/components/navigation) ([Markdown](https://wingo-ui.com/components/navigation.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { Bell, 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"

export function AccountSettings() {
  const [saved, setSaved] = useState({ name: "Emma Carter", digest: true })
  const [draft, setDraft] = useState(saved)
  const dirty = draft.name !== saved.name || draft.digest !== saved.digest

  return (
    <SettingsLayout
      title="Settings"
      dirty={dirty}
      onSave={async () => {
        await fetch("/api/settings", { method: "PUT", body: JSON.stringify(draft) })
        setSaved(draft)
      }}
      onDiscard={() => setDraft(saved)}
      labels={{ unsaved: "You have unsaved changes", save: "Save changes" }}
      sections={[
        {
          id: "profile",
          label: "Profile",
          icon: <User />,
          content: (
            <SettingsSection title="Public profile">
              <SettingsRow label="Name" controlWidth="md">
                <Input value={draft.name} onValueChange={(name) => setDraft({ ...draft, name })} autoComplete="name" />
              </SettingsRow>
            </SettingsSection>
          ),
        },
        {
          id: "notifications",
          label: "Notifications",
          icon: <Bell />,
          content: (
            <SettingsSection title="Email">
              <SettingsRow label="Daily digest" description="Sales and overdue invoices, at 8 AM.">
                <Switch checked={draft.digest} onCheckedChange={(digest) => setDraft({ ...draft, digest })} />
              </SettingsRow>
            </SettingsSection>
          ),
        },
      ]}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `sections` | `SettingsNavItem[]` |  | The sections: { id, label, icon?, description? (the phone list subtitle), group? (eyebrow label), badge? (a number is a count), href? (a route, wins over content), disabled?, danger?, tone? (the phone icon tile), content? }. |
| `defaultValue?` | `string` | `"profile"` | The section open at first, uncontrolled. Pair value / onValueChange to drive it from the URL. The code default is the first enabled section; an unknown id falls back to it too. |
| `value?` | `string` |  | The active section id, controlled. |
| `onValueChange?` | `(id: string) => void` |  | Every switch: a nav click (after the leave confirm), a phone row, the tabs or select, and the scroll spy. |
| `title?` | `ReactNode` | `""` | The page heading (an h1, see headingAs). On phones it becomes the large title of the grouped list. |
| `headingAs?` | `"h1" \| "h2" \| "h3" \| "div"` | `"h1"` | The element of the title and of the section title on phones. Keep h1 for a settings page; use h2, h3 or div when the layout sits inside a page that already has its h1. |
| `description?` | `ReactNode` | `""` | A line under the title. |
| `children?` | `ReactNode \| ((activeId: string) => ReactNode)` |  | The content when sections[].content is not used: a function gets the active id (in scroll mode, each id in turn). Sections with an href always render children. |
| `mode?` | `"pages" \| "scroll"` | `"pages"` | pages: only the active section shows and swaps with a fade. scroll: every section stacked on one page, the nav follows the scroll (an IntersectionObserver spy on the page's scroll container) and a click scrolls there. |
| `nav?` | `"sidebar" \| "tabs" \| "none"` | `"sidebar"` | Desktop navigation: a sticky left column, underline tabs above the content, or none. One section never shows a nav. |
| `navWidth?` | `number` | `240` | The sidebar column in px. |
| `mobileNav?` | `"list" \| "tabs" \| "select"` | `"list"` | Below 768px of its own width: an iOS grouped list that pushes the section (split-view stack with swipe back), scrolling pill tabs, or a select (a bottom sheet). Scroll mode uses tabs for list. |
| `dirty?` | `boolean` | `false` | Something changed: shows the save bar, binds saveShortcut and arms the leave guards. The demo also turns it on as soon as you edit a field. |
| `saving?` | `boolean` | `false` | The save button's loading state, for a save you track yourself. It also loads while an onSave promise runs; turning false with dirty off flashes the saved message. |
| `onSave?` | `(() => void \| Promise<unknown>)` |  | Runs from the button or the shortcut. A promise keeps the button loading; resolved flashes labels.saved for 1.2s, rejected keeps the bar, shakes it and shows the error's message (else labels.saveError). |
| `onDiscard?` | `() => void` |  | The Discard button, and a confirmed leave. Put the saved values back here. |
| `saveShortcut?` | `string` | `"mod+s"` | Saves while dirty (it replaces the browser's Save Page only then). Shown as a key hint on the button, hidden on touch. "" disables. |
| `confirmLeave?` | `boolean` | `true` | Pages mode: switching sections while dirty asks "Discard changes?" (Discard / Keep editing) and calls onDiscard when confirmed. Links (href items) are the router's to guard. |
| `warnBeforeUnload?` | `boolean` | `true` | The browser's leave-page warning while dirty. |
| `saveBarPosition?` | `"sticky" \| "fixed"` | `"sticky"` | sticky: floats at the bottom of the content column while you scroll; fixed: pinned to the viewport. Phones always pin it above --bottom-bar-offset and the safe area, lifted over the keyboard. |
| `size?` | `"sm" \| "md"` | `"md"` | Nav rows 32 / 36px (44px on touch) and the row padding (48 / 56px rows). |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The nav indicator (a soft tint of the tone) and the save button. Neutral is a gray pill and an inverted button. |
| `linkComponent?` | `(props: LinkComponentProps) => ReactNode` |  | Renders nav items that have an href (settings as routes). Defaults to the LinkProvider's, else a plain <a>. |
| `labels?` | `Partial<SettingsLayoutLabels>` |  | Every string: nav "Settings", unsaved "You have unsaved changes", save "Save", saving "Saving…", discard "Discard", saved "Changes saved", saveError "Could not save. Try again.", leaveTitle "Discard changes?", leaveDescription "Your changes in this section will be lost.", leaveConfirm "Discard", leaveCancel "Keep editing", back "Settings", select "Section". Exported as DEFAULT_SETTINGS_LAYOUT_LABELS. |
| `classNames?` | `Partial<Record<"frame" \| "header" \| "title" \| "description" \| "nav" \| "group" \| "item" \| "indicator" \| "content" \| "mobileNav" \| "mobileList" \| "mobileItem" \| "mobileDetail" \| "saveBar", string>>` |  | Extra classes for the parts. The phone stack's height is the --settings-mobile-height variable (default 100dvh minus --topbar-offset). |

## Parts

### SettingsSection (component)

A group of rows: <section aria-labelledby> with an h2 (headingLevel), description, actions and a footer. variant "grouped" (an iOS inset group, the default) or "plain" (hairlines on the page); danger adds a red ring and title. Reads useDefaults("settings-section"). Props: SettingsSectionProps.

### SettingsRow (component)

One setting: label, description, badge ("Pro"), error and the control on the right. A single child control (an input, switch, select or any component) gets the label's htmlFor, aria-labelledby (so a segmented control or radio group is named too) and aria-describedby automatically; buttons and links keep their own name inside a group named by the label, and a <div> wrapper keeps a plain label (wire it with htmlFor or useSettingsRow). layout "auto" | "inline" | "stacked", controlWidth "auto" | "sm" (200px) | "md" (320px) | "full", align, danger, disabled. Rows go inline once their section is 560px wide. Reads useDefaults("settings-row"). Props: SettingsRowProps.

### SettingsSaveBar (component)

The floating save bar on its own, for any form: dirty, saving, onSave, onDiscard, saveShortcut, position (sticky | fixed), message, tone, labels, classNames. Props: SettingsSaveBarProps.

### useSettingsLayout (hook)

Inside the layout: the active section and a stable setter that goes through the unsaved-changes guard (a link row that jumps to another section). Signature: () => { activeId: string; setActiveId: (id: string) => void; dirty: boolean }.

### useSettingsRow (hook)

Inside a row: the ids to wire a custom control by hand (a radio group takes aria-labelledby={labelId}). Signature: () => { controlId: string; labelId: string; describedBy: string | undefined } | null.

### DEFAULT_SETTINGS_LAYOUT_LABELS (constant)

The English strings, to spread under a translation. Type: SettingsLayoutLabels.

## Examples

- **Scroll mode**: mode="scroll": every section on one page; the sidebar (pill tabs when narrow) follows the scroll and a click glides there.
- **Tabs on top**: nav="tabs" for a short settings page (mobileNav="tabs" when narrow); switching with unsaved changes asks first.
- **Row layouts**: Inline switches, sized fields that stack on narrow sections, a full-width textarea, a Pro badge and an error.
- **The save bar alone**: SettingsSaveBar on any form, fixed: edit the name, then save with the button or mod+s.
- **On a phone**: The grouped list pushes a section; swipe from the left or tap Settings to go back.
- **Phone with a select**: mobileNav="select": one bottom-sheet picker instead of the list.
- **Brand tone, small**: tone="brand" colors the nav indicator (pill tabs when narrow) and the save button; size="sm" tightens nav and rows.

## See also

- [Settings Page](https://wingo-ui.com/components/settings-page) ([Markdown](https://wingo-ui.com/components/settings-page.md))
- [Split View](https://wingo-ui.com/components/split-view) ([Markdown](https://wingo-ui.com/components/split-view.md))
- [Sidebar](https://wingo-ui.com/components/sidebar) ([Markdown](https://wingo-ui.com/components/sidebar.md))
- [Members table](https://wingo-ui.com/components/members-table) ([Markdown](https://wingo-ui.com/components/members-table.md))
- [Permissions Matrix](https://wingo-ui.com/components/permissions-matrix) ([Markdown](https://wingo-ui.com/components/permissions-matrix.md))
- [Form Wizard](https://wingo-ui.com/components/form-wizard) ([Markdown](https://wingo-ui.com/components/form-wizard.md))

---

Canonical: https://wingo-ui.com/components/settings-layout
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
