Settings Panel
betaA card of labeled settings rows, with switches, text fields, and selects, and a Save button that shows saving, saved, and errors.
Built from Card, Button, Input, Label, Select, Skeleton, Switch, and Textarea.
Preview
title outside
actions outside
title outside, loading
actions outside, narrow container
saving, title outside
saved
save failed, with a textarea
loading
disabled, tone neutral
no fields
narrow container, one locked field
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the block. It also adds every component it uses. Its own files go in your
componentsfolder, and they are yours to edit.npx shadcn@latest add @txbrendan/settings-panel
Use it for
- A group of related settings saved together, like notifications, a profile, or a workspace.
- Switches for on or off choices, inputs for short text, textareas for longer text, and selects for one option from a short list.
- Know the cost: the panel always brings in Select, about 41 KB gzip, even with no select field. An app that already uses Select, a popover, a menu, or a combobox shares most of it.
- Return a promise from onSave. The panel shows saving until it settles, then saved, or the error if it rejects.
- Pass loading while the values load, and the fields with no defaults if you know them. The placeholder rows take their shapes.
- Pass the saved values as each field's defaultValue. The panel reads a default until people change that field or save.
- Set layout to "title-outside" or "actions-outside" for a nested card: the title or the Save bar sits on a muted shell around a white card of rows. Only one part goes outside.
Avoid it for
- Settings that should apply the moment people flip them. Use a Switch on its own and save on change.
- Picking from a long list people would rather search, like every city. The panel's select has no search.
- Long forms with validation messages under each field. Build those with your form library.
- Many groups in one card. Use one panel per group, each with its own title.
Props
- fields
The rows, in order. name is the key for the field's value in onSave, so each name must be unique. A select gives the picked option's value, or "" while nothing is picked.
({ type: "switch"; defaultValue?: boolean } | { type: "input"; defaultValue?: string; inputType?: "text" | "email" | "url" | "tel" | "number"; placeholder?: string; autoComplete?: string } | { type: "textarea"; defaultValue?: string; placeholder?: string } | { type: "select"; options: { value: string; label: ReactNode }[]; defaultValue?: string; placeholder?: ReactNode })[], each with { name: string; label: ReactNode; description?: ReactNode; disabled?: boolean }
- onSave
Called with every field's value, keyed by name, when people click Save or press Enter in an input. Return a promise to show saving until it settles.
(values: Record<string, string | boolean>) => void | Promise<void>
- saveStatus
Sets the save state yourself, like from a mutation hook. The panel still calls onSave. Unsaved changes still show over "saved".
"idle" | "saving" | "saved" | "error"
- title
The card's heading. It also names the form.
ReactNode · default "Settings"
- description
One line under the title: what these settings change.
ReactNode
- headingLevel
The title's heading level, so it fits under the heading above it.
1 | 2 | 3 | 4 | 5 | 6 · default 2
- layout
"title-outside" puts the title and description on a muted shell, above a white card of rows. The title still names the form. "actions-outside" puts the status line and Save button on the shell, below the rows.
"flat" | "title-outside" | "actions-outside" · default "flat"
- tone
Color of the Save button and the switches.
"neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"
- loading
Shows placeholder rows while the values load: one per field, or three when there are no fields yet.
boolean · default false
- disabled
Locks every field and the Save button, like for people who can only view.
boolean · default false
- saveLabel
The Save button's text.
ReactNode · default "Save changes"
- savingText
The status line while saving.
ReactNode · default "Saving…"
- savedText
The status line after a save.
ReactNode · default "Changes saved"
- unsavedText
The status line once people change something.
ReactNode · default "Unsaved changes"
- errorText
The status line when onSave throws or rejects.
ReactNode · default "Could not save. Try again."
- loadingText
What the status line tells screen readers while loading.
string · default "Loading settings"
- emptyText
Shown when there are no fields and nothing is loading.
ReactNode · default "No settings here yet."
- className
Extra classes on the form, which holds the container query.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| fields | ({ type: "switch"; defaultValue?: boolean } | { type: "input"; defaultValue?: string; inputType?: "text" | "email" | "url" | "tel" | "number"; placeholder?: string; autoComplete?: string } | { type: "textarea"; defaultValue?: string; placeholder?: string } | { type: "select"; options: { value: string; label: ReactNode }[]; defaultValue?: string; placeholder?: ReactNode })[], each with { name: string; label: ReactNode; description?: ReactNode; disabled?: boolean } | - | The rows, in order. name is the key for the field's value in onSave, so each name must be unique. A select gives the picked option's value, or "" while nothing is picked. |
| onSave | (values: Record<string, string | boolean>) => void | Promise<void> | - | Called with every field's value, keyed by name, when people click Save or press Enter in an input. Return a promise to show saving until it settles. |
| saveStatus | "idle" | "saving" | "saved" | "error" | - | Sets the save state yourself, like from a mutation hook. The panel still calls onSave. Unsaved changes still show over "saved". |
| title | ReactNode | "Settings" | The card's heading. It also names the form. |
| description | ReactNode | - | One line under the title: what these settings change. |
| headingLevel | 1 | 2 | 3 | 4 | 5 | 6 | 2 | The title's heading level, so it fits under the heading above it. |
| layout | "flat" | "title-outside" | "actions-outside" | "flat" | "title-outside" puts the title and description on a muted shell, above a white card of rows. The title still names the form. "actions-outside" puts the status line and Save button on the shell, below the rows. |
| tone | "neutral" | "brand" | "danger" | "success" | "warning" | "neutral" | Color of the Save button and the switches. |
| loading | boolean | false | Shows placeholder rows while the values load: one per field, or three when there are no fields yet. |
| disabled | boolean | false | Locks every field and the Save button, like for people who can only view. |
| saveLabel | ReactNode | "Save changes" | The Save button's text. |
| savingText | ReactNode | "Saving…" | The status line while saving. |
| savedText | ReactNode | "Changes saved" | The status line after a save. |
| unsavedText | ReactNode | "Unsaved changes" | The status line once people change something. |
| errorText | ReactNode | "Could not save. Try again." | The status line when onSave throws or rejects. |
| loadingText | string | "Loading settings" | What the status line tells screen readers while loading. |
| emptyText | ReactNode | "No settings here yet." | Shown when there are no fields and nothing is loading. |
| className | string | - | Extra classes on the form, which holds the container query. |
Accessibility
- The panel is a form named by its title, so screen readers can jump to it.
- Each label is linked to its control, and each description is read with it. Clicking a switch's label flips it, and clicking a select's label opens its list.
- Every control is named by its label in server HTML too, before the page hydrates. A select shows its picked option's label there, not its value.
- One status line stays on the page under the rows. It reads out unsaved changes, saving, saved, and errors.
- While saving, the Save button keeps focus and shows a spinner. A second click or Enter does not save twice.
- While loading, the placeholder rows are hidden from screen readers, the form has aria-busy, and the status line says loadingText.
- Forced colors mode drops fills, so the placeholder rows do not show there. The status line shows loadingText as words instead.
- disabled sets disabled on every control and its label. A field's own disabled locks only that field.
Examples
Email settings
<SettingsPanel
title="Email notifications"
description="Choose what we email you about."
tone="brand"
fields={[
{ type: "input", name: "email", label: "Send to", inputType: "email", autoComplete: "email", defaultValue: user.email },
{ type: "switch", name: "comments", label: "Comments", description: "When someone replies to you.", defaultValue: prefs.comments },
{
type: "select",
name: "summary",
label: "Summary",
options: [
{ value: "daily", label: "Every day" },
{ value: "weekly", label: "Every Monday" },
{ value: "never", label: "Never" },
],
defaultValue: prefs.summary,
},
]}
onSave={(values) => saveEmailSettings(values)}
/>Nested card
<SettingsPanel
title="Team settings"
description="These apply to everyone in the workspace."
layout="title-outside"
fields={[
{ type: "switch", name: "private", label: "New projects are private", defaultValue: true },
{ type: "input", name: "session", label: "Sign-in length in days", inputType: "number", defaultValue: "30" },
]}
onSave={saveTeamSettings}
/>While the values load
<SettingsPanel
title="Profile"
loading={profile === undefined}
fields={[
{ type: "input", name: "name", label: "Display name", defaultValue: profile?.name },
{ type: "textarea", name: "bio", label: "Bio", defaultValue: profile?.bio },
]}
onSave={updateProfile}
/>