A sectioned settings card with label-and-control rows and a save bar that slides in when something changes, with a status announced to screen readers.
"use client";
import * as React from "react";
import { SettingsPanel, SettingsRow, SettingsSection, type SettingsStatus } from "@/components/ballmac/settings-panel";
import { Switch } from "@/components/ballmac/switch";
const defaults = { name: "Acme Inc.", digest: true, mentions: true, frequency: "daily" };
export default function SettingsPanelDemo() {
const [saved, setSaved] = React.useState(defaults);
const [values, setValues] = React.useState(defaults);
const [status, setStatus] = React.useState<SettingsStatus>("idle");
const dirty = JSON.stringify(values) !== JSON.stringify(saved);
const shown: SettingsStatus = status === "saving" || status === "saved" ? status : dirty ? "dirty" : "idle";
const set = (patch: Partial<typeof defaults>) => {
setStatus("idle");
setValues((v) => ({ ...v, ...patch }));
};
return (
<SettingsPanel
status={shown}
className="max-w-3xl"
label="Workspace settings"
onDiscard={() => setValues(saved)}
onSave={() => {
setStatus("saving");
setTimeout(() => {
setSaved(values);
setStatus("saved");
setTimeout(() => setStatus("idle"), 1800);
}, 900);
}}
>
<SettingsSection title="General" description="How your workspace appears to everyone.">
<SettingsRow label="Workspace name" htmlFor="sp-name" stacked>
<input
id="sp-name"
value={values.name}
onChange={(e) => set({ name: e.target.value })}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Notifications" description="Choose what reaches your inbox.">
<SettingsRow label="Weekly digest" description="A summary of activity every Monday." htmlFor="sp-digest">
<Switch id="sp-digest" checked={values.digest} onCheckedChange={(v) => set({ digest: v })} />
</SettingsRow>
<SettingsRow label="Mentions" description="Email me when someone mentions me." htmlFor="sp-mentions">
<Switch id="sp-mentions" checked={values.mentions} onCheckedChange={(v) => set({ mentions: v })} />
</SettingsRow>
<SettingsRow label="Frequency" description="How often to group emails." htmlFor="sp-frequency">
<select
id="sp-frequency"
value={values.frequency}
onChange={(e) => set({ frequency: e.target.value })}
className="h-9 rounded-md border border-input bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
>
<option value="instant">Instantly</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</SettingsRow>
</SettingsSection>
</SettingsPanel>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/settings-panelInstall the dependencies.
$ pnpm add motion@^12 lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/motion-presets @ballmac/i18nCopy the source into your project.
components/ballmac/settings-panel.tsx// Ballmac UI: Settings Panel. https://ui.ballmac.com/components/settings-panel "use client"; import * as React from "react"; import { Check, LoaderCircle } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { spring } from "@/lib/ballmac/motion"; import { cn } from "@/lib/utils"; import { useMessages } from "@/lib/ballmac/i18n"; type SettingsStatus = "idle" | "dirty" | "saving" | "saved"; type SettingsPanelProps = Omit<React.ComponentProps<"form">, "onSubmit" | "onReset"> & { /** Save state. `dirty` shows the save bar; `saving` disables it and shows a spinner; `saved` confirms briefly. */ status?: SettingsStatus; /** Called when the form is submitted (Save button or Enter in a field). */ onSave?: () => void; /** Called when Discard is pressed. Reset your own state here. */ onDiscard?: () => void; /** Text next to the buttons while `dirty`. */ dirtyMessage?: string; /** Label of the save button. */ saveLabel?: string; /** Accessible name of the form. */ label?: string; }; /** * A card of grouped settings with a save bar that slides in when something changes. The bar's status is announced to * screen readers. Compose with SettingsSection and SettingsRow, and drive `status` from your form state. */ function SettingsPanel({ status = "idle", onSave, onDiscard, dirtyMessage, saveLabel, label, className, children, ...props }: SettingsPanelProps) { const msg = useMessages() dirtyMessage ??= msg("settings-panel.dirtyMessage", "You have unsaved changes") saveLabel ??= msg("settings-panel.saveLabel", "Save changes") label ??= msg("settings-panel.label", "Settings") const reduce = useReducedMotion(); const showBar = status !== "idle"; return ( <form data-slot="settings-panel" aria-label={label} onSubmit={(event) => { event.preventDefault(); if (status !== "saving") onSave?.(); }} className={cn( "relative w-full overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-16px_rgb(0_0_0/0.14)]", className, )} {...props} > <div className="divide-y">{children}</div> <div aria-live="polite" role="status" className="sr-only"> {status === "dirty" ? dirtyMessage : status === "saving" ? "Saving" : status === "saved" ? "Changes saved" : ""} </div> <AnimatePresence initial={false}> {showBar && ( <motion.div key="bar" data-slot="settings-panel-bar" initial={reduce ? { opacity: 0 } : { y: "100%", opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={reduce ? { opacity: 0 } : { y: "100%", opacity: 0 }} transition={reduce ? { duration: 0.1 } : spring.snappy} className="sticky bottom-0 flex flex-wrap items-center gap-3 border-t bg-card/90 px-5 py-3 backdrop-blur supports-[backdrop-filter]:bg-card/75" > <p aria-hidden="true" className={cn( "flex items-center gap-2 text-sm", status === "saved" ? "text-foreground" : "text-muted-foreground", )} > {status === "saved" ? ( <> <Check className="size-4 text-chart-2" /> Changes saved </> ) : status === "saving" ? ( <> <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" /> Saving… </> ) : ( <> <span className="size-1.5 rounded-full bg-chart-3" /> {dirtyMessage} </> )} </p> <div className="ms-auto flex items-center gap-2"> <button type="button" onClick={onDiscard} disabled={status !== "dirty"} className="inline-flex h-8 items-center rounded-md px-3 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50" > {msg("settings-panel.discard", "Discard")} </button> <button type="submit" disabled={status !== "dirty"} className="inline-flex h-8 items-center rounded-md bg-primary px-3.5 text-[13px] font-medium text-primary-foreground shadow-xs outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50" > {saveLabel} </button> </div> </motion.div> )} </AnimatePresence> </form> ); } type SettingsSectionProps = Omit<React.ComponentProps<"section">, "title"> & { /** Section heading. */ title: string; /** One line under the heading. */ description?: string; }; /** A titled group of rows. The title names the section for assistive technology. */ function SettingsSection({ title, description, className, children, ...props }: SettingsSectionProps) { const id = React.useId(); return ( <section data-slot="settings-section" aria-labelledby={id} className={cn("grid gap-1 px-5 py-5 md:grid-cols-[minmax(0,14rem)_1fr] md:gap-x-10", className)} {...props} > <div className="pb-3 md:pb-0"> <h3 id={id} className="text-sm font-semibold tracking-tight"> {title} </h3> {description && <p className="mt-1 text-sm leading-snug text-muted-foreground">{description}</p>} </div> <div className="grid min-w-0 divide-y rounded-xl border bg-background/60">{children}</div> </section> ); } type SettingsRowProps = Omit<React.ComponentProps<"div">, "title"> & { /** Setting name. */ label: string; /** Explains what the setting does. */ description?: string; /** The id of the control, so the label is clickable. */ htmlFor?: string; /** Stack the control under the label instead of beside it. Good for wide inputs. */ stacked?: boolean; }; /** One setting: label and help text on the left, the control (switch, select, input) as children on the right. */ function SettingsRow({ label, description, htmlFor, stacked = false, className, children, ...props }: SettingsRowProps) { const descId = React.useId(); return ( <div data-slot="settings-row" className={cn( "flex gap-x-6 gap-y-3 px-4 py-3.5", stacked ? "flex-col" : "flex-col sm:flex-row sm:items-center sm:justify-between", className, )} {...props} > <div className="min-w-0"> <label htmlFor={htmlFor} className="text-sm font-medium"> {label} </label> {description && ( <p id={descId} className="mt-0.5 text-[13px] leading-snug text-muted-foreground"> {description} </p> )} </div> <div className={cn("shrink-0", stacked && "w-full")}>{children}</div> </div> ); } export { SettingsPanel, SettingsSection, SettingsRow, type SettingsPanelProps, type SettingsSectionProps, type SettingsRowProps, type SettingsStatus, };Update the import paths to match your project setup.
Usage
import { SettingsPanel, SettingsSection, SettingsRow } from "@/components/ballmac/settings-panel"The full example is in the Code tab above.
Examples
Unsaved changes
"use client";
import { SettingsPanel, SettingsRow, SettingsSection } from "@/components/ballmac/settings-panel";
import { Switch } from "@/components/ballmac/switch";
export default function SettingsPanelStates() {
return (
<SettingsPanel status="dirty" label="Privacy settings" className="max-w-lg">
<SettingsSection title="Privacy" description="Control what others can see.">
<SettingsRow label="Show activity status" description="Let teammates see when you are online." htmlFor="sps-a">
<Switch id="sps-a" defaultChecked />
</SettingsRow>
<SettingsRow label="Searchable profile" htmlFor="sps-b">
<Switch id="sps-b" />
</SettingsRow>
</SettingsSection>
</SettingsPanel>
);
}API reference
<SettingsPanel>
| Prop | Type | Default |
|---|---|---|
statusSave state. `dirty` shows the save bar; `saving` disables it and shows a spinner; `saved` confirms briefly. | SettingsStatus | "idle" |
onSaveCalled when the form is submitted (Save button or Enter in a field). | () => void | — |
onDiscardCalled when Discard is pressed. Reset your own state here. | () => void | — |
dirtyMessageText next to the buttons while `dirty`. | string | — |
saveLabelLabel of the save button. | string | — |
labelAccessible name of the form. | string | — |
<SettingsSection>
| Prop | Type | Default |
|---|---|---|
title*Section heading. | string | — |
descriptionOne line under the heading. | string | — |
<SettingsRow>
| Prop | Type | Default |
|---|---|---|
label*Setting name. | string | — |
descriptionExplains what the setting does. | string | — |
htmlForThe id of the control, so the label is clickable. | string | — |
stackedStack the control under the label instead of beside it. Good for wide inputs. | boolean | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter | Submits the form from any field |
| Tab | Save bar buttons are in the tab order after the fields |
| Screen readers | Sections are labelled groups; save status is a polite live region |
Use with AI
<SettingsPanel status onSave onDiscard><SettingsSection title><SettingsRow label htmlFor>{control}</SettingsRow></SettingsSection></SettingsPanel>. Drive status: idle | dirty | saving | saved. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Settings Panel (@ballmac/settings-panel) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Account, workspace and notification settings
- Any form where people expect Save and Discard
Not for
- Single-field inline edits
- Wizards; use stepper-form
Registry JSON: https://ui.ballmac.com/r/settings-panel.json
Credits
Free to use in personal and commercial projects.
Pairs well with
Switch
A Radix toggle switch in two sizes with a CSS-animated thumb, for settings that take effect immediately. Stops animating under reduced motion.
Input
A text input in three heights that match Button, plus InputGroup and InputGroupAddon for leading or trailing icons, units and domains.
Select
A Radix select with a sized trigger, popper-positioned menu, scroll buttons, groups, labels, separators and a check indicator on the chosen item.
Field
Form field layout with label, description and error that wire their ids to the control automatically, plus fieldset, legend, orientation and invalid/disabled state.