# Settings Panel

> Grouped settings with switches, selects and segmented options, plus a save/reset bar for unsaved changes.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/settings-panel
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/settings-panel.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/settings-panel-demo.tsx"
"use client"

import { SettingsPanel, type SettingSection } from "@/components/settings-panel"

const sections: SettingSection[] = [
  {
    title: "Appearance",
    description: "How the workspace looks on this device.",
    fields: [
      {
        type: "segmented",
        key: "theme",
        label: "Theme",
        options: [
          { value: "light", label: "Light" },
          { value: "dark", label: "Dark" },
          { value: "system", label: "System" },
        ],
      },
      { type: "switch", key: "compact", label: "Compact mode", description: "Tighter spacing in lists and tables." },
    ],
  },
  {
    title: "Notifications",
    fields: [
      { type: "switch", key: "email", label: "Email notifications", description: "Mentions and replies." },
      { type: "switch", key: "push", label: "Push notifications" },
      {
        type: "select",
        key: "digest",
        label: "Digest frequency",
        options: [
          { value: "daily", label: "Daily" },
          { value: "weekly", label: "Weekly" },
          { value: "never", label: "Never" },
        ],
      },
    ],
  },
]

export default function SettingsPanelDemo() {
  return <SettingsPanel sections={sections} initialValues={{ theme: "system", compact: false, email: true, push: false, digest: "weekly" }} />
}
```

## Source

```tsx title="components/settings-panel.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type SettingField =
  | { type: "switch"; key: string; label: string; description?: string }
  | { type: "select"; key: string; label: string; description?: string; options: { value: string; label: string }[] }
  | { type: "segmented"; key: string; label: string; description?: string; options: { value: string; label: string }[] }

export interface SettingSection {
  title: string
  description?: string
  fields: SettingField[]
}

export type SettingsValues = Record<string, boolean | string>

export interface SettingsPanelProps extends Omit<React.ComponentProps<"div">, "onChange"> {
  sections: SettingSection[]
  initialValues: SettingsValues
  onSave?: (values: SettingsValues) => void
}

function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (next: boolean) => void; label: string }) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      aria-label={label}
      onClick={() => onChange(!checked)}
      className={cn(
        "relative inline-flex h-6 w-11 shrink-0 items-center rounded-full outline-none transition-colors focus-visible:ring-4 focus-visible:ring-ring/40",
        checked ? "bg-primary" : "bg-input",
      )}
    >
      <span aria-hidden className={cn("block size-5 rounded-full bg-background shadow transition-transform", checked ? "translate-x-5.5" : "translate-x-0.5")} />
    </button>
  )
}

export function SettingsPanel({ sections, initialValues, onSave, className, ...props }: SettingsPanelProps) {
  const [saved, setSaved] = React.useState(initialValues)
  const [draft, setDraft] = React.useState(initialValues)
  const dirtyKeys = Object.keys(draft).filter((k) => draft[k] !== saved[k])
  const dirty = dirtyKeys.length > 0
  const set = (key: string, value: boolean | string) => setDraft((prev) => ({ ...prev, [key]: value }))

  return (
    <div {...props} className={cn("relative w-full max-w-2xl overflow-hidden rounded-2xl border bg-card", className)}>
      {sections.map((section) => (
        <section key={section.title} className="border-b p-5 last:border-b-0">
          <h3 className="text-sm font-semibold">{section.title}</h3>
          {section.description && <p className="mt-0.5 text-sm text-muted-foreground">{section.description}</p>}
          <div className="mt-4 grid gap-4">
            {section.fields.map((field) => {
              const id = `setting-${field.key}`
              return (
                <div key={field.key} className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
                  <div className="min-w-0 flex-1 basis-48">
                    <label htmlFor={id} className="text-sm font-medium">
                      {field.label}
                    </label>
                    {field.description && <p className="text-sm text-muted-foreground">{field.description}</p>}
                  </div>
                  {field.type === "switch" && <Toggle label={field.label} checked={!!draft[field.key]} onChange={(v) => set(field.key, v)} />}
                  {field.type === "select" && (
                    <select
                      id={id}
                      value={String(draft[field.key])}
                      onChange={(event) => set(field.key, event.target.value)}
                      className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus-visible:ring-4 focus-visible:ring-ring/30"
                    >
                      {field.options.map((option) => (
                        <option key={option.value} value={option.value}>
                          {option.label}
                        </option>
                      ))}
                    </select>
                  )}
                  {field.type === "segmented" && (
                    <div id={id} role="radiogroup" aria-label={field.label} className="inline-flex rounded-lg bg-muted p-0.5">
                      {field.options.map((option) => (
                        <button
                          key={option.value}
                          type="button"
                          role="radio"
                          aria-checked={draft[field.key] === option.value}
                          onClick={() => set(field.key, option.value)}
                          className={cn(
                            "rounded-md px-3 py-1 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                            draft[field.key] === option.value ? "bg-background font-medium shadow-sm" : "text-muted-foreground hover:text-foreground",
                          )}
                        >
                          {option.label}
                        </button>
                      ))}
                    </div>
                  )}
                </div>
              )
            })}
          </div>
        </section>
      ))}

      <div
        role="status"
        className={cn(
          "flex items-center justify-between gap-3 border-t bg-muted/50 px-5 py-3 text-sm transition-all",
          !dirty && "text-muted-foreground",
        )}
      >
        <span>{dirty ? `${dirtyKeys.length} unsaved ${dirtyKeys.length === 1 ? "change" : "changes"}` : "All changes saved"}</span>
        <div className="flex gap-2">
          <button
            type="button"
            disabled={!dirty}
            onClick={() => setDraft(saved)}
            className="rounded-md px-3 py-1.5 transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40"
          >
            Reset
          </button>
          <button
            type="button"
            disabled={!dirty}
            onClick={() => {
              setSaved(draft)
              onSave?.(draft)
            }}
            className="rounded-md bg-primary px-3 py-1.5 font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-40"
          >
            Save changes
          </button>
        </div>
      </div>
    </div>
  )
}
```
