Skip to content
txbrendan/ui

Settings Panel

beta

A 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

Email notifications

Choose what we email you about.

When someone replies to you or mentions you.

A short email with what changed.

title outside

Appearance

How the app looks on this device.

Fewer animations as you move around.

Fit more rows on the screen.

actions outside

Workspace

Guests can view projects you share with them.

title outside, loading

Account

Loading settings

actions outside, narrow container

Contact details

Only when you open a ticket.

saving, title outside

Projects

Moves a project out of the list 30 days after it ends.

Saving…

saved

Privacy

Changes saved

save failed, with a textarea

Billing

Where we send receipts.

Printed at the bottom of every invoice.

Could not save. Try again.

loading

Security

Loading settings

disabled, tone neutral

Team defaults

Only workspace admins can change these.

no fields

Integrations

No integrations to set up yet.

narrow container, one locked field

Profile

Ask an admin to change it.

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the block. It also adds every component it uses. Its own files go in your components folder, 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

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