# Settings Page

> A settings screen in three decisions: one page with one save bar, one section at a time beside a side nav, or each group as its own plate with its own Save.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/settings-page).

- Page, with a live preview: https://shadcn.exchange/r/miseui/settings-page
- Screenshot: https://shadcn.exchange/shot/miseui/settings-page/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/settings-page.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/miseui/settings-page.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://miseui.junsu4621.workers.dev/r/button.json`, `https://miseui.junsu4621.workers.dev/r/input.json`, `https://miseui.junsu4621.workers.dev/r/label.json`, `https://miseui.junsu4621.workers.dev/r/materialize.json`, `https://miseui.junsu4621.workers.dev/r/select.json`, `https://miseui.junsu4621.workers.dev/r/switch.json`

## Files

### `registry/miseui/blocks/settings-page.tsx`

```tsx
import { SettingsCardsView } from "./settings-page-cards"
import type { SettingsSection, SettingsValues, SettingsViewProps } from "./settings-page-form"
import { SettingsSectionsView } from "./settings-page-sections"
import { SettingsSidebarView } from "./settings-page-sidebar"

type SettingsPageVariant = "sections" | "sidebar" | "cards"

interface SettingsPageProps extends SettingsViewProps {
  /** `sections` one long page with one save bar, `sidebar` one section at a time beside a side nav, `cards` each group its own plate with its own Save. Anything else is `sections`. */
  variant?: SettingsPageVariant
}

function SettingsPage({ variant, ...props }: SettingsPageProps) {
  if (variant === "sidebar") return <SettingsSidebarView {...props} />
  if (variant === "cards") return <SettingsCardsView {...props} />
  return <SettingsSectionsView {...props} />
}

export { SettingsPage, type SettingsPageProps, type SettingsPageVariant, type SettingsSection, type SettingsValues }
```

### `registry/miseui/blocks/settings-page-sections.tsx`

```tsx
import type * as React from "react"
import { useId, useState } from "react"
import { useReducedMotion } from "motion/react"
import { cn } from "@/registry/miseui/lib/utils"
import { Materialize } from "@/registry/miseui/ui/materialize"
import { useSettingsForm, type SettingsSection, type SettingsViewProps } from "./settings-page-form"
import { SettingsRowView } from "./settings-page-parts"
import { SAMPLE_SECTIONS } from "./settings-page-sample"
import { SaveBar, SOFT_SHADOW_CLASS } from "./settings-page-savebar"

interface SectionNavProps {
  sections: readonly SettingsSection[]
  active: string
  onSelect: (id: string) => void
}

function SectionNav({ sections, active, onSelect }: SectionNavProps) {
  return (
    <nav aria-label="Settings sections" className="min-w-0 @3xl:sticky @3xl:top-8 @3xl:self-start">
      <ul className="flex gap-1 overflow-x-auto @3xl:flex-col @3xl:overflow-visible">
        {sections.map((section) => (
          <li key={section.id} className={cn("shrink-0", section.tone === "danger" && "@3xl:mt-4")}>
            <button
              type="button"
              aria-current={section.id === active ? "true" : undefined}
              onClick={() => onSelect(section.id)}
              className={cn(
                "inline-flex h-9 w-full items-center rounded-md px-3 text-[13px] whitespace-nowrap outline-none",
                "transition-colors duration-(--duration-fast) ease-(--ease-mise)",
                "focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50",
                section.id === active ? "bg-background dark:bg-card text-foreground shadow-xs" : "text-muted-foreground hover:bg-background/60",
              )}
            >
              {section.title}
            </button>
          </li>
        ))}
      </ul>
    </nav>
  )
}

function SectionBlock({ section, anchorId, children }: { section: SettingsSection; anchorId: string; children: React.ReactNode }) {
  const danger = section.tone === "danger"
  return (
    <section id={anchorId} aria-labelledby={`${anchorId}-title`} className={cn("flex scroll-mt-8 flex-col gap-4", danger && "mt-4 border-t pt-12")}>
      {/* Inline, so one tone-split sentence reads as a single thought while the heading stays a real heading. */}
      <div className="text-[15px] leading-relaxed text-pretty">
        <h3 id={`${anchorId}-title`} className="inline font-medium">
          {section.title}
        </h3>
        {section.description ? (
          <>
            <span aria-hidden="true">. </span>
            <p className="inline text-muted-foreground">{section.description}</p>
          </>
        ) : null}
      </div>
      <div className={cn("flex flex-col divide-y rounded-2xl border bg-background dark:bg-card", danger ? "border-destructive/40" : SOFT_SHADOW_CLASS)}>{children}</div>
    </section>
  )
}

interface SectionListProps {
  sections: readonly SettingsSection[]
  form: ReturnType<typeof useSettingsForm>
  fieldId: (id: string) => string
  anchorId: (id: string) => string
}

function SectionList({ sections, form, fieldId, anchorId }: SectionListProps) {
  return sections.map((section) => (
    <SectionBlock key={section.id} section={section} anchorId={anchorId(section.id)}>
      {section.rows.map((row) => (
        <SettingsRowView
          key={row.id}
          row={row}
          fieldId={fieldId(row.id)}
          value={form.values[row.id]}
          error={form.errors[row.id]}
          onChange={(value) => form.update(row.id, value)}
          onBlur={() => form.touch(row.id)}
        />
      ))}
    </SectionBlock>
  ))
}

function SettingsSectionsView({ title = "Settings.", titleQuiet = "Nothing changes until you save.", sections = SAMPLE_SECTIONS, onSave, className, ...props }: SettingsViewProps) {
  const baseId = useId()
  const reduceMotion = useReducedMotion()
  const [activeSection, setActiveSection] = useState(sections[0]?.id ?? "")
  const form = useSettingsForm(sections, onSave)
  const fieldId = (id: string) => `${baseId}-field-${id}`
  const anchorId = (id: string) => `${baseId}-section-${id}`
  const errorCount = Object.keys(form.errors).length
  const barVisible = form.changedIds.length > 0 || form.status === "saved" || form.status === "saving"

  const goToSection = (id: string) => {
    setActiveSection(id)
    document.getElementById(anchorId(id))?.scrollIntoView?.({ behavior: reduceMotion ? "auto" : "smooth", block: "start" })
  }
  const save = async () => {
    const firstInvalid = await form.save()
    if (firstInvalid) document.getElementById(fieldId(firstInvalid))?.focus()
  }

  return (
    <div data-slot="settings-page" data-variant="sections" className={cn("@container w-full bg-muted dark:bg-background", className)} {...props}>
      <div className="mx-auto flex max-w-5xl flex-col gap-8 px-4 py-8 @2xl:gap-12 @2xl:px-8 @2xl:py-12">
        <h2 className="text-display text-3xl text-balance @2xl:text-4xl">
          {title} {titleQuiet ? <span className="text-muted-foreground">{titleQuiet}</span> : null}
        </h2>
        <div className="grid min-w-0 gap-8 @3xl:grid-cols-[176px_minmax(0,1fr)] @3xl:gap-12">
          <SectionNav sections={sections} active={activeSection} onSelect={goToSection} />
          <div className="flex min-w-0 flex-col gap-12">
            <SectionList sections={sections} form={form} fieldId={fieldId} anchorId={anchorId} />
            {/* Stays in the flow and sticks: it never covers the last row, and it follows a long page. */}
            <Materialize show={barVisible} origin="50% 100%" className="sticky bottom-4 z-10">
              <SaveBar
                status={form.status}
                changedCount={form.changedIds.length}
                errorCount={errorCount}
                onDiscard={form.discard}
                onSave={save}
              />
            </Materialize>
          </div>
        </div>
      </div>
    </div>
  )
}

export { SettingsSectionsView }
```

### `registry/miseui/blocks/settings-page-sidebar.tsx`

```tsx
import { useId, useState } from "react"
import { flushSync } from "react-dom"
import { cn } from "@/registry/miseui/lib/utils"
import { Materialize } from "@/registry/miseui/ui/materialize"
import { useSettingsForm, type SettingsSection, type SettingsViewProps } from "./settings-page-form"
import { SETTINGS_TOUCH_CLASS, SettingsRowView } from "./settings-page-parts"
import { SAMPLE_SECTIONS } from "./settings-page-sample"
import { SaveBar, SOFT_SHADOW_CLASS } from "./settings-page-savebar"

interface SideNavProps {
  sections: readonly SettingsSection[]
  active: string
  changedIds: readonly string[]
  errorIds: readonly string[]
  onSelect: (id: string) => void
}

function countIn(section: SettingsSection, ids: readonly string[]) {
  return section.rows.filter((row) => ids.includes(row.id)).length
}

/** The count travels with the section, because an edit on a screen that is no longer showing is easy to forget. */
function SideNavItem({ section, active, changed, invalid, onSelect }: { section: SettingsSection; active: boolean; changed: number; invalid: boolean; onSelect: () => void }) {
  const Icon = section.icon
  return (
    <button
      type="button"
      aria-current={active ? "page" : undefined}
      aria-label={changed > 0 ? `${section.title}, ${changed} unsaved` : undefined}
      onClick={onSelect}
      className={cn(
        "inline-flex h-9 w-full items-center gap-3 rounded-md px-3 text-start text-[13px] whitespace-nowrap outline-none pointer-coarse:h-10",
        "transition-colors duration-(--duration-fast) ease-(--ease-mise)",
        "focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50",
        active ? "bg-background text-foreground shadow-xs dark:bg-card" : "text-muted-foreground hover:bg-background/60",
      )}
    >
      {Icon ? <Icon className="size-4 shrink-0" /> : null}
      <span className="min-w-0 flex-1">{section.title}</span>
      {changed > 0 ? (
        <span className={cn("shrink-0 tabular-nums", invalid ? "text-destructive" : "text-muted-foreground")}>{changed}</span>
      ) : null}
    </button>
  )
}

function SideNav({ sections, active, changedIds, errorIds, onSelect }: SideNavProps) {
  return (
    <nav aria-label="Settings sections" className="min-w-0">
      {/* A scroller of its own when narrow, so five labels never push the page sideways on a phone. */}
      <ul className="flex gap-1 overflow-x-auto @3xl:flex-col @3xl:overflow-visible">
        {sections.map((section) => (
          <li key={section.id} className={cn("shrink-0", section.tone === "danger" && "@3xl:mt-3 @3xl:border-t @3xl:pt-3")}>
            <SideNavItem
              section={section}
              active={section.id === active}
              changed={countIn(section, changedIds)}
              invalid={countIn(section, errorIds) > 0}
              onSelect={() => onSelect(section.id)}
            />
          </li>
        ))}
      </ul>
    </nav>
  )
}

function SectionPanel({ section, form, fieldId }: { section: SettingsSection; form: ReturnType<typeof useSettingsForm>; fieldId: (id: string) => string }) {
  const danger = section.tone === "danger"
  return (
    <section aria-label={section.title} className="flex flex-col gap-6">
      <h3 className="text-display text-2xl text-balance @2xl:text-3xl">
        {section.title}. {section.description ? <span className="text-muted-foreground">{section.description}</span> : null}
      </h3>
      <div className={cn("flex flex-col divide-y rounded-2xl border bg-background dark:bg-card", danger ? "border-destructive/40" : SOFT_SHADOW_CLASS)}>
        {section.rows.map((row) => (
          <SettingsRowView
            key={row.id}
            row={row}
            fieldId={fieldId(row.id)}
            value={form.values[row.id]}
            error={form.errors[row.id]}
            onChange={(value) => form.update(row.id, value)}
            onBlur={() => form.touch(row.id)}
          />
        ))}
      </div>
    </section>
  )
}

function SettingsSidebarView({ title = "Settings.", titleQuiet = "Nothing changes until you save.", sections = SAMPLE_SECTIONS, onSave, className, ...props }: SettingsViewProps) {
  const baseId = useId()
  const [activeId, setActiveId] = useState(sections[0]?.id ?? "")
  const form = useSettingsForm(sections, onSave)
  const fieldId = (id: string) => `${baseId}-field-${id}`
  const section = sections.find((item) => item.id === activeId) ?? sections[0]
  const errorIds = Object.keys(form.errors)
  const barVisible = form.changedIds.length > 0 || form.status === "saved" || form.status === "saving"

  const save = async () => {
    const firstInvalid = await form.save()
    if (!firstInvalid) return
    // The problem may sit in a section that is not showing, where nobody would ever find it.
    // flushSync: the field only exists once that section has rendered, and focus has to land on it now.
    const home = sections.find((item) => item.rows.some((row) => row.id === firstInvalid))
    flushSync(() => setActiveId(home?.id ?? activeId))
    document.getElementById(fieldId(firstInvalid))?.focus()
  }

  return (
    <div data-slot="settings-page" data-variant="sidebar" className={cn("@container w-full bg-muted dark:bg-background", SETTINGS_TOUCH_CLASS, className)} {...props}>
      <div className="grid min-h-[640px] grid-cols-[minmax(0,1fr)] grid-rows-[auto_1fr] @3xl:grid-cols-[240px_minmax(0,1fr)] @3xl:grid-rows-1">
        <aside className="min-w-0 border-b @3xl:border-r @3xl:border-b-0">
          <div className="flex min-w-0 flex-col gap-4 p-4 @3xl:sticky @3xl:top-0 @3xl:py-8">
            <div className="flex flex-col gap-1 px-3">
              <h2 className="text-title text-xl">{title}</h2>
              {titleQuiet ? <p className="text-[13px] leading-relaxed text-pretty text-muted-foreground">{titleQuiet}</p> : null}
            </div>
            <SideNav sections={sections} active={section?.id ?? ""} changedIds={form.changedIds} errorIds={errorIds} onSelect={setActiveId} />
          </div>
        </aside>
        {/* Its own container: the rows inside must answer to this column, not to the width of the whole block. */}
        <div className="@container min-w-0">
          <div className="flex flex-col gap-8 p-4 @3xl:p-8 @5xl:p-12">
            {section ? <SectionPanel key={section.id} section={section} form={form} fieldId={fieldId} /> : null}
            <Materialize show={barVisible} origin="50% 100%" className="sticky bottom-4 z-10">
              <SaveBar status={form.status} changedCount={form.changedIds.length} errorCount={errorIds.length} onDiscard={form.discard} onSave={save} />
            </Materialize>
          </div>
        </div>
      </div>
    </div>
  )
}

export { SettingsSidebarView }
```

### `registry/miseui/blocks/settings-page-cards.tsx`

```tsx
import { useId, useMemo } from "react"
import { Check, CircleAlert } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { isEditable, useSettingsForm, type OnSave, type SettingsSection, type SettingsViewProps } from "./settings-page-form"
import { SETTINGS_TOUCH_CLASS, SettingsRowView } from "./settings-page-parts"
import { SAMPLE_SECTIONS } from "./settings-page-sample"
import { saveBarMessage, SOFT_SHADOW_CLASS } from "./settings-page-savebar"

interface CardFooterProps {
  title: string
  form: ReturnType<typeof useSettingsForm>
  onSave: () => void
}

function CardFooter({ title, form, onSave }: CardFooterProps) {
  const changed = form.changedIds.length > 0
  const errorCount = Object.keys(form.errors).length
  const saving = form.status === "saving"
  const problem = form.status === "failed" || errorCount > 0
  const idle = !changed && form.status === "idle"
  const unavailable = !changed || saving
  return (
    <div className="flex flex-wrap items-center justify-between gap-3 rounded-b-2xl border-t bg-muted/40 p-3 pl-4 @2xl:pl-6 dark:bg-transparent">
      <p role="status" className={cn("flex items-center gap-2 text-[13px]", problem ? "text-destructive" : "text-muted-foreground")}>
        {form.status === "saved" ? <Check aria-hidden="true" className="size-4" /> : null}
        {problem ? <CircleAlert aria-hidden="true" className="size-4" /> : null}
        {idle ? "Up to date" : saveBarMessage({ status: form.status, changedCount: form.changedIds.length, errorCount })}
      </p>
      <div className="ml-auto flex gap-2">
        {changed && !saving ? (
          <Button variant="ghost" className="pointer-coarse:h-10" onClick={form.discard}>
            Discard
          </Button>
        ) : null}
        {/* aria-disabled, not disabled: the button stays in place after a save, and a disabled one would drop keyboard focus. */}
        <Button
          variant={changed ? "mise" : "outline"}
          aria-label={`Save ${title}`}
          aria-disabled={unavailable || undefined}
          effect={saving ? "shimmer" : undefined}
          onClick={unavailable ? undefined : onSave}
          className={cn("pointer-coarse:h-10", !changed && "opacity-50 hover:bg-background")}
        >
          {saving ? "Saving" : "Save"}
        </Button>
      </div>
    </div>
  )
}

function SettingsCard({ section, onSave }: { section: SettingsSection; onSave?: OnSave }) {
  const baseId = useId()
  const single = useMemo(() => [section], [section])
  const form = useSettingsForm(single, onSave)
  const fieldId = (id: string) => `${baseId}-field-${id}`
  const titleId = `${baseId}-title`
  const danger = section.tone === "danger"

  const save = async () => {
    const firstInvalid = await form.save()
    if (firstInvalid) document.getElementById(fieldId(firstInvalid))?.focus()
  }

  return (
    <section aria-labelledby={titleId} className={cn("@container rounded-2xl border bg-background dark:bg-card", danger ? "mt-4 border-destructive/40" : SOFT_SHADOW_CLASS)}>
      <header className="flex flex-col gap-1 p-4 @2xl:px-6 @2xl:pt-6">
        <h3 id={titleId} className="text-title text-lg">
          {section.title}
        </h3>
        {section.description ? <p className="text-[13px] leading-relaxed text-pretty text-muted-foreground">{section.description}</p> : null}
      </header>
      <div className="flex flex-col divide-y border-t">
        {section.rows.map((row) => (
          <SettingsRowView
            key={row.id}
            row={row}
            fieldId={fieldId(row.id)}
            value={form.values[row.id]}
            error={form.errors[row.id]}
            onChange={(value) => form.update(row.id, value)}
            onBlur={() => form.touch(row.id)}
          />
        ))}
      </div>
      {/* Read-only and destructive rows act on their own, so a group of only those has nothing to save. */}
      {section.rows.some(isEditable) ? <CardFooter title={section.title} form={form} onSave={save} /> : null}
    </section>
  )
}

function SettingsCardsView({ title = "Settings.", titleQuiet = "Each group saves on its own.", sections = SAMPLE_SECTIONS, onSave, className, ...props }: SettingsViewProps) {
  return (
    <div data-slot="settings-page" data-variant="cards" className={cn("@container w-full bg-muted dark:bg-background", SETTINGS_TOUCH_CLASS, className)} {...props}>
      <div className="mx-auto flex max-w-3xl flex-col gap-8 px-4 py-8 @2xl:gap-12 @2xl:px-8 @2xl:py-12">
        <h2 className="text-display text-3xl text-balance @2xl:text-4xl">
          {title} {titleQuiet ? <span className="text-muted-foreground">{titleQuiet}</span> : null}
        </h2>
        <div className="flex min-w-0 flex-col gap-6">
          {sections.map((section) => (
            <SettingsCard key={section.id} section={section} onSave={onSave} />
          ))}
        </div>
      </div>
    </div>
  )
}

export { SettingsCardsView }
```

### `registry/miseui/blocks/settings-page-savebar.tsx`

```tsx
import { Check, CircleAlert } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import type { SaveStatus } from "./settings-page-form"

/** Tinted from the ink, and off in dark mode, where a light-tinted shadow would read as a glow. */
const SOFT_SHADOW_CLASS =
  "shadow-[0_1px_2px_color-mix(in_oklab,var(--foreground)_6%,transparent),0_16px_32px_-16px_color-mix(in_oklab,var(--foreground)_16%,transparent)] dark:shadow-none"

interface SaveBarProps {
  status: SaveStatus
  changedCount: number
  errorCount: number
  onDiscard: () => void
  onSave: () => void
}

function saveBarMessage({ status, changedCount, errorCount }: Omit<SaveBarProps, "onDiscard" | "onSave">) {
  if (status === "saved") return "Changes saved"
  if (status === "failed") return "Could not save. Your changes are still here."
  if (errorCount > 0) return errorCount === 1 ? "Fix 1 field to save" : `Fix ${errorCount} fields to save`
  return changedCount === 1 ? "1 unsaved change" : `${changedCount} unsaved changes`
}

function SaveBar({ status, changedCount, errorCount, onDiscard, onSave }: SaveBarProps) {
  const saving = status === "saving"
  const problem = status === "failed" || errorCount > 0
  return (
    <div className="material flex flex-wrap items-center justify-between gap-3 rounded-2xl border p-3 pl-4 shadow-md">
      <p role="status" className={cn("flex items-center gap-2 text-[13px]", problem && "text-destructive")}>
        {status === "saved" ? <Check aria-hidden="true" className="size-4" /> : null}
        {problem ? <CircleAlert aria-hidden="true" className="size-4" /> : null}
        {saveBarMessage({ status, changedCount, errorCount })}
      </p>
      {status === "saved" ? null : (
        <div className="ml-auto flex gap-2">
          <Button variant="ghost" disabled={saving} onClick={onDiscard}>
            Discard
          </Button>
          <Button variant="mise" disabled={saving} effect={saving ? "shimmer" : undefined} onClick={onSave}>
            {saving ? "Saving" : "Save changes"}
          </Button>
        </div>
      )}
    </div>
  )
}

export { SaveBar, saveBarMessage, SOFT_SHADOW_CLASS }
```

### `registry/miseui/blocks/settings-page-parts.tsx`

```tsx
import { Button } from "@/registry/miseui/ui/button"
import { Input } from "@/registry/miseui/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/registry/miseui/ui/select"
import { Switch } from "@/registry/miseui/ui/switch"
import { DangerRow } from "./settings-page-danger"
import { descriptionId, RESPONSIVE_ROW_CLASS, RowText } from "./settings-page-row-text"
import type {
  SettingsInfoRow,
  SettingsRow,
  SettingsSelectRow,
  SettingsSwitchRow,
  SettingsTextRow,
  SettingsValue,
} from "./settings-page-form"

const ROW_CLASS = RESPONSIVE_ROW_CLASS

/** The new layouts only: the sections default keeps the sizes people already copied. */
const SETTINGS_TOUCH_CLASS = "pointer-coarse:[&_input]:h-10 pointer-coarse:[&_[data-slot=button]]:h-10 pointer-coarse:[&_[data-slot=select-trigger]]:h-10 pointer-coarse:[&_[data-slot=switch]]:relative pointer-coarse:[&_[data-slot=switch]]:after:absolute pointer-coarse:[&_[data-slot=switch]]:after:-inset-3"
const errorId = (id: string) => `${id}-error`

interface FieldProps<Row> {
  row: Row
  fieldId: string
  value: SettingsValue
  error?: string
  onChange: (value: SettingsValue) => void
  onBlur: () => void
}

function TextRow({ row, fieldId, value, error, onChange, onBlur }: FieldProps<SettingsTextRow>) {
  const described = [row.description && descriptionId(fieldId), error && errorId(fieldId)].filter(Boolean).join(" ")
  return (
    <div className={ROW_CLASS}>
      <RowText row={row} htmlFor={fieldId} fieldId={fieldId} />
      <div className="min-w-0">
        <Input
          id={fieldId}
          type={row.type ?? "text"}
          autoComplete={row.autoComplete}
          value={String(value)}
          aria-invalid={error ? true : undefined}
          aria-describedby={described || undefined}
          onChange={(event) => onChange(event.target.value)}
          onBlur={onBlur}
        />
        {error ? (
          <p id={errorId(fieldId)} className="mt-2 text-[13px] text-destructive">
            {error}
          </p>
        ) : null}
      </div>
    </div>
  )
}

function SelectRow({ row, fieldId, value, onChange }: FieldProps<SettingsSelectRow>) {
  return (
    <div className={ROW_CLASS}>
      <RowText row={row} htmlFor={fieldId} fieldId={fieldId} />
      <Select value={String(value)} onValueChange={onChange}>
        <SelectTrigger
          id={fieldId}
          aria-describedby={row.description ? descriptionId(fieldId) : undefined}
          className="w-full"
        >
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {row.options.map((option) => (
            <SelectItem key={option.value} value={option.value}>
              {option.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  )
}

/** The one row that stays side by side when narrow: a switch alone on its own line reads as detached from its label. */
function SwitchRow({ row, fieldId, value, onChange }: FieldProps<SettingsSwitchRow>) {
  return (
    <div className="flex items-center justify-between gap-4 p-4 @2xl:gap-8 @2xl:px-6">
      <RowText row={row} htmlFor={fieldId} fieldId={fieldId} />
      <Switch
        id={fieldId}
        checked={Boolean(value)}
        aria-describedby={row.description ? descriptionId(fieldId) : undefined}
        onCheckedChange={onChange}
      />
    </div>
  )
}

function InfoRow({ row, fieldId }: { row: SettingsInfoRow; fieldId: string }) {
  return (
    <div className="flex flex-wrap items-center justify-between gap-3 p-4 @2xl:gap-8 @2xl:px-6">
      <RowText row={row} fieldId={fieldId} />
      <div className="flex shrink-0 items-center gap-4">
        {row.value ? <span className="text-[13px] text-muted-foreground">{row.value}</span> : null}
        {row.actionLabel ? (
          <Button variant="outline" size="sm" onClick={row.onAction}>
            {row.actionLabel}
          </Button>
        ) : null}
      </div>
    </div>
  )
}

interface SettingsRowViewProps {
  row: SettingsRow
  fieldId: string
  value: SettingsValue | undefined
  error?: string
  onChange: (value: SettingsValue) => void
  onBlur: () => void
}

function SettingsRowView({ row, fieldId, value, error, onChange, onBlur }: SettingsRowViewProps) {
  if (row.kind === "info") return <InfoRow row={row} fieldId={fieldId} />
  if (row.kind === "danger") return <DangerRow row={row} fieldId={fieldId} />
  const field = { fieldId, value: value ?? row.value, error, onChange, onBlur }
  if (row.kind === "text") return <TextRow row={row} {...field} />
  if (row.kind === "select") return <SelectRow row={row} {...field} />
  return <SwitchRow row={row} {...field} />
}

export { SETTINGS_TOUCH_CLASS, SettingsRowView }
```

### `registry/miseui/blocks/settings-page-row-text.tsx`

```tsx
import { Label } from "@/registry/miseui/ui/label"
import type { SettingsRow } from "./settings-page-form"

const LABEL_CLASS = "text-[15px] leading-normal font-normal"
const DESCRIPTION_CLASS = "mt-1 text-[13px] leading-relaxed text-pretty text-muted-foreground"
/** Label and description on the left, the control on the right; one above the other when narrow. */
const RESPONSIVE_ROW_CLASS = "grid gap-3 p-4 @2xl:grid-cols-[minmax(0,1fr)_minmax(0,280px)] @2xl:gap-8 @2xl:px-6"

const descriptionId = (id: string) => `${id}-description`

function RowText({ row, htmlFor, fieldId }: { row: SettingsRow; htmlFor?: string; fieldId: string }) {
  return (
    <div className="min-w-0">
      {htmlFor ? (
        <Label htmlFor={htmlFor} className={LABEL_CLASS}>
          {row.label}
        </Label>
      ) : (
        <p className={LABEL_CLASS}>{row.label}</p>
      )}
      {row.description ? (
        <p id={descriptionId(fieldId)} className={DESCRIPTION_CLASS}>
          {row.description}
        </p>
      ) : null}
    </div>
  )
}

export { descriptionId, RESPONSIVE_ROW_CLASS, RowText }
```

### `registry/miseui/blocks/settings-page-danger.tsx`

```tsx
import { useState } from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import type { SettingsDangerRow } from "./settings-page-form"
import { RESPONSIVE_ROW_CLASS, RowText } from "./settings-page-row-text"

function DangerDone({ row, onUndo }: { row: SettingsDangerRow; onUndo: () => void }) {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <p role="status" className="text-[13px] text-pretty text-muted-foreground">
        {row.doneMessage}
      </p>
      <Button variant="outline" size="sm" onClick={onUndo}>
        Undo
      </Button>
    </div>
  )
}

function DangerControls({ row }: { row: SettingsDangerRow }) {
  const [step, setStep] = useState<"idle" | "confirm" | "done">("idle")
  if (step === "done") {
    const undo = () => {
      setStep("idle")
      row.onUndo?.()
    }
    return <DangerDone row={row} onUndo={undo} />
  }
  if (step === "confirm") {
    const confirm = () => {
      setStep("done")
      row.onConfirm?.()
    }
    return (
      <div className="flex flex-wrap gap-2">
        {/* Focus lands on the safe choice, so a second press of Enter cannot confirm by accident. */}
        <Button variant="outline" size="sm" autoFocus onClick={() => setStep("idle")}>
          {row.cancelLabel}
        </Button>
        <Button variant="destructive" size="sm" onClick={confirm}>
          {row.confirmLabel}
        </Button>
      </div>
    )
  }
  return (
    <Button
      variant="outline"
      size="sm"
      className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
      onClick={() => setStep("confirm")}
    >
      {row.actionLabel}
    </Button>
  )
}

function DangerRow({ row, fieldId }: { row: SettingsDangerRow; fieldId: string }) {
  return (
    <div className={cn(RESPONSIVE_ROW_CLASS, "@2xl:items-center")}>
      <RowText row={row} fieldId={fieldId} />
      <div className="flex min-w-0 @2xl:justify-end">
        <DangerControls row={row} />
      </div>
    </div>
  )
}

export { DangerRow }
```

### `registry/miseui/blocks/settings-page-form.ts`

```ts
import type * as React from "react"
import { useEffect, useMemo, useState } from "react"

type SettingsValue = string | boolean
type SettingsValues = Readonly<Record<string, SettingsValue>>

interface SettingsRowBase {
  id: string
  label: string
  description?: string
}

interface SettingsTextRow extends SettingsRowBase {
  kind: "text"
  value: string
  type?: "text" | "email"
  autoComplete?: string
  /** Return a message to block saving, or undefined when the value is fine. */
  validate?: (value: string) => string | undefined
}

interface SettingsSwitchRow extends SettingsRowBase {
  kind: "switch"
  value: boolean
}

interface SettingsSelectRow extends SettingsRowBase {
  kind: "select"
  value: string
  options: ReadonlyArray<{ value: string; label: string }>
}

/** Read-only, with an optional action that leaves this page: nothing here waits for Save. */
interface SettingsInfoRow extends SettingsRowBase {
  kind: "info"
  value?: string
  actionLabel?: string
  onAction?: () => void
}

/** Asks twice, and offers an undo after, instead of warning once and hoping. */
interface SettingsDangerRow extends SettingsRowBase {
  kind: "danger"
  actionLabel: string
  confirmLabel: string
  cancelLabel: string
  doneMessage: string
  onConfirm?: () => void
  onUndo?: () => void
}

type SettingsRow = SettingsTextRow | SettingsSwitchRow | SettingsSelectRow | SettingsInfoRow | SettingsDangerRow
type EditableRow = SettingsTextRow | SettingsSwitchRow | SettingsSelectRow

interface SettingsSection {
  id: string
  title: string
  description?: string
  /** Pushes the section away from the rest and marks its edge, so it cannot be hit on the way to something else. */
  tone?: "danger"
  /** Shown beside the title in the `sidebar` design. */
  icon?: React.ComponentType<{ className?: string }>
  rows: readonly SettingsRow[]
}

type SaveStatus = "idle" | "saving" | "saved" | "failed"
type OnSave = (values: SettingsValues, changedIds: readonly string[]) => void | Promise<void>

interface SettingsViewProps extends Omit<React.ComponentProps<"div">, "title"> {
  title?: string
  /** Continues the title in the same size, one tone quieter. */
  titleQuiet?: string
  sections?: readonly SettingsSection[]
  /** Receives the editable values and the ids that changed. Reject to keep the edits on screen and offer a retry. In the `cards` design it receives one group at a time. */
  onSave?: OnSave
}

/** Long enough to read "Changes saved" without looking for it, short enough not to linger. */
const SAVED_MESSAGE_MS = 2400

const isEditable = (row: SettingsRow): row is EditableRow =>
  row.kind === "text" || row.kind === "switch" || row.kind === "select"

function editableRows(sections: readonly SettingsSection[]) {
  return sections.flatMap((section) => section.rows.filter(isEditable))
}

function valuesOf(rows: readonly EditableRow[]): SettingsValues {
  return Object.fromEntries(rows.map((row) => [row.id, row.value]))
}

function errorsOf(rows: readonly EditableRow[], values: SettingsValues): Readonly<Record<string, string>> {
  const entries = rows.flatMap((row) => {
    if (row.kind !== "text" || !row.validate) return []
    const message = row.validate(String(values[row.id] ?? ""))
    return message ? [[row.id, message] as const] : []
  })
  return Object.fromEntries(entries)
}

function useSavedMessage(status: SaveStatus, clear: () => void) {
  useEffect(() => {
    if (status !== "saved") return
    const id = window.setTimeout(clear, SAVED_MESSAGE_MS)
    return () => window.clearTimeout(id)
  }, [status, clear])
}

/** Start values are read once. To reload them from the server, remount with a new `key`. */
function useSettingsForm(sections: readonly SettingsSection[], onSave?: OnSave) {
  const rows = useMemo(() => editableRows(sections), [sections])
  const [baseline, setBaseline] = useState(() => valuesOf(rows))
  const [values, setValues] = useState(baseline)
  const [touched, setTouched] = useState<ReadonlySet<string>>(new Set())
  const [status, setStatus] = useState<SaveStatus>("idle")
  const clearStatus = useMemo(() => () => setStatus("idle"), [])
  useSavedMessage(status, clearStatus)

  const changedIds = rows.filter((row) => values[row.id] !== baseline[row.id]).map((row) => row.id)
  const errors = errorsOf(rows, values)
  const invalidIds = rows.filter((row) => errors[row.id]).map((row) => row.id)

  const update = (id: string, value: SettingsValue) => {
    setValues((current) => ({ ...current, [id]: value }))
    setStatus("idle")
  }
  const touch = (id: string) => setTouched((current) => new Set(current).add(id))
  const discard = () => {
    setValues(baseline)
    setTouched(new Set())
    setStatus("idle")
  }
  /** Resolves to the first invalid row, so the page can move focus to it. */
  const save = async (): Promise<string | undefined> => {
    if (invalidIds.length > 0) {
      setTouched(new Set(invalidIds))
      return invalidIds[0]
    }
    setStatus("saving")
    try {
      await onSave?.(values, changedIds)
      setBaseline(values)
      setTouched(new Set())
      setStatus("saved")
    } catch {
      // Nothing is thrown away: the edits stay on screen, ready for another try.
      setStatus("failed")
    }
    return undefined
  }

  const visibleErrors = Object.fromEntries(Object.entries(errors).filter(([id]) => touched.has(id)))
  return { values, changedIds, errors: visibleErrors, status, update, touch, discard, save }
}

export {
  isEditable,
  useSettingsForm,
  type OnSave,
  type SaveStatus,
  type SettingsDangerRow,
  type SettingsInfoRow,
  type SettingsRow,
  type SettingsSection,
  type SettingsSelectRow,
  type SettingsSwitchRow,
  type SettingsTextRow,
  type SettingsValue,
  type SettingsValues,
  type SettingsViewProps,
}
```

### `registry/miseui/blocks/settings-page-sample.ts`

```ts
import { Bell, CreditCard, TriangleAlert, UserRound } from "lucide-react"
import type { SettingsSection } from "./settings-page-form"

/** Invented sample content. The person, the plan and every figure are made up. */

const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

const SAMPLE_SECTIONS: readonly SettingsSection[] = [
  {
    id: "profile",
    title: "Profile",
    description: "How you appear to the people in this workspace.",
    icon: UserRound,
    rows: [
      {
        kind: "text",
        id: "name",
        label: "Display name",
        description: "Shown next to your comments and invoices.",
        value: "Mina Okafor",
        autoComplete: "name",
        validate: (value) => (value.trim() ? undefined : "Add a name so your team knows who this is."),
      },
      {
        kind: "text",
        id: "email",
        label: "Email",
        description: "Where receipts and sign-in links go.",
        value: "mina@example.com",
        type: "email",
        autoComplete: "email",
        validate: (value) => (EMAIL_PATTERN.test(value.trim()) ? undefined : "That does not look like an email address."),
      },
      {
        kind: "select",
        id: "timezone",
        label: "Time zone",
        description: "Used for the weekly summary and report dates.",
        value: "seoul",
        options: [
          { value: "seoul", label: "Seoul (GMT+9)" },
          { value: "london", label: "London (GMT+0)" },
          { value: "new-york", label: "New York (GMT-5)" },
        ],
      },
    ],
  },
  {
    id: "notifications",
    title: "Notifications",
    description: "Only what you would want to be interrupted for.",
    icon: Bell,
    rows: [
      {
        kind: "switch",
        id: "failed-payments",
        label: "Failed payments",
        description: "The moment a charge fails, so you can follow up the same day.",
        value: true,
      },
      {
        kind: "switch",
        id: "weekly-summary",
        label: "Weekly summary",
        description: "Monday at 9:00, last week's numbers in one email.",
        value: true,
      },
      {
        kind: "switch",
        id: "product-news",
        label: "Product news",
        description: "A short note when something ships, about once a month.",
        value: false,
      },
    ],
  },
  {
    id: "plan",
    title: "Plan",
    icon: CreditCard,
    rows: [
      {
        kind: "info",
        id: "current-plan",
        label: "Team",
        description: "$84 per month, renews on December 14.",
        actionLabel: "Change plan",
      },
      { kind: "info", id: "seats", label: "Seats", description: "2 seats are free to invite into.", value: "4 of 6 used" },
    ],
  },
  {
    id: "danger",
    title: "Delete workspace",
    tone: "danger",
    icon: TriangleAlert,
    rows: [
      {
        kind: "danger",
        id: "delete-workspace",
        label: "Delete this workspace",
        description: "Removes 1,284 customer records and every invite. You get 14 days to change your mind.",
        actionLabel: "Delete workspace",
        confirmLabel: "Yes, delete it",
        cancelLabel: "Keep workspace",
        doneMessage: "Deletion scheduled for 14 days from now.",
      },
    ],
  },
]

export { SAMPLE_SECTIONS }
```
