# Settings Panel

> A card of labeled settings rows, with switches, text fields, and selects, and a Save button that shows saving, saved, and errors.

- Name: `settings-panel`
- Status: beta
- Categories: settings, preferences, form, account
- Files: the install writes `settings-panel.tsx`, and any part files named `settings-panel-*.tsx`, to your `components` alias, `@/components` by default. Edit them freely. They are yours.
- npm packages: `lucide-react`. The install adds them.
- Other registry items: `@txbrendan/card`, `@txbrendan/button`, `@txbrendan/input`, `@txbrendan/label`, `@txbrendan/select`, `@txbrendan/skeleton`, `@txbrendan/switch`, `@txbrendan/textarea`. The install adds them.
- Gallery: https://txbrendan.dev/blocks/settings-panel
- Registry item: https://txbrendan.dev/r/settings-panel.json

## Install

1. Add the registry to `components.json`. Do this first, once:

   ```bash
   npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
   ```

   The command writes this entry. You can also add it by hand:

   ```json
   {
     "registries": {
       "@txbrendan": "https://txbrendan.dev/r/{name}.json"
     }
   }
   ```

   Without this step, the shadcn CLI looks up `@txbrendan` in a public list that txbrendan/ui does not control.

2. Add the theme once per app. It replaces your color tokens with ones checked for contrast:

   ```bash
   npx shadcn@latest add @txbrendan/theme
   ```

3. Add the block. It also adds every component it uses:

   ```bash
   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`: `({ 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`. Default `"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`. Default `2`. The title's heading level, so it fits under the heading above it.
- `layout`: `"flat" | "title-outside" | "actions-outside"`. Default `"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"`. Default `"neutral"`. Color of the Save button and the switches.
- `loading`: `boolean`. Default `false`. Shows placeholder rows while the values load: one per field, or three when there are no fields yet.
- `disabled`: `boolean`. Default `false`. Locks every field and the Save button, like for people who can only view.
- `saveLabel`: `ReactNode`. Default `"Save changes"`. The Save button's text.
- `savingText`: `ReactNode`. Default `"Saving…"`. The status line while saving.
- `savedText`: `ReactNode`. Default `"Changes saved"`. The status line after a save.
- `unsavedText`: `ReactNode`. Default `"Unsaved changes"`. The status line once people change something.
- `errorText`: `ReactNode`. Default `"Could not save. Try again."`. The status line when onSave throws or rejects.
- `loadingText`: `string`. Default `"Loading settings"`. What the status line tells screen readers while loading.
- `emptyText`: `ReactNode`. Default `"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

```tsx
<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

```tsx
<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

```tsx
<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}
/>
```
