A preferences card for an account or app settings page. Two groups of rows, Notifications and Privacy, each pair a label and a one-line explanation with a toggle switch. A third row uses a three-way segmented control for how often a digest email is sent. The bar at the bottom counts how many settings differ from the saved state and offers Discard and Save changes. The preview opens with two unsaved changes so the bar is active. On phones the switches stay on the right of each row and the save buttons spread to full width.
import { useState } from "react";
const groups = [
{
title: "Notifications",
text: "Choose what reaches your inbox and your phone.",
items: [
{ id: "mentions", label: "Mentions and replies", text: "When someone tags you or answers your comment." },
{ id: "assignments", label: "New assignments", text: "When a task is handed to you or its due date moves." },
{ id: "push", label: "Push on mobile", text: "Send the alerts above to the phone app as well." },
{ id: "product", label: "Product news", text: "A short note when we ship something worth knowing." },
],
},
{
title: "Privacy",
text: "Control what teammates can see about you.",
items: [
{ id: "status", label: "Show when I am online", text: "A green dot next to your name while you are active." },
{ id: "receipts", label: "Read receipts", text: "Let others see that you opened their message." },
],
},
];
const digests = ["Off", "Daily", "Weekly"];
const defaults: { on: Record<string, boolean>; digest: string } = {
on: { mentions: true, assignments: true, push: false, product: false, status: true, receipts: false },
digest: "Weekly",
};
const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 focus-visible:ring-offset-2";
export default function SettingsPanelWithToggleSwitches() {
const [saved, setSaved] = useState(defaults);
const [draft, setDraft] = useState({ on: { ...defaults.on, push: true, receipts: true }, digest: "Weekly" });
const changes = Object.keys(draft.on).filter((id) => draft.on[id] !== saved.on[id]).length + (draft.digest !== saved.digest ? 1 : 0);
return (
<section className="bg-slate-100 px-4 py-12 text-slate-900 sm:py-16">
<div className="mx-auto max-w-2xl overflow-hidden rounded-xl border border-slate-200 bg-white">
<div className="border-b border-slate-200 px-5 py-5 sm:px-8 sm:py-6">
<h2 className="text-lg font-semibold tracking-tight">Preferences</h2>
<p className="mt-1 text-sm text-slate-600">Changes apply to your account only, on every device.</p>
</div>
{groups.map((g) => (
<div key={g.title} className="border-b border-slate-200 px-5 py-6 sm:px-8">
<h3 className="text-sm font-semibold">{g.title}</h3>
<p className="mt-1 text-sm text-slate-600">{g.text}</p>
<ul className="mt-3 divide-y divide-slate-100">
{g.items.map((item) => {
const on = draft.on[item.id];
return (
<li key={item.id} className="flex items-center justify-between gap-6 py-3">
<div className="min-w-0">
<p id={`setting-${item.id}`} className="text-sm font-medium">{item.label}</p>
<p id={`setting-${item.id}-text`} className="mt-0.5 text-sm text-slate-600">{item.text}</p>
</div>
<button
type="button"
role="switch"
aria-checked={on}
aria-labelledby={`setting-${item.id}`}
aria-describedby={`setting-${item.id}-text`}
onClick={() => setDraft({ ...draft, on: { ...draft.on, [item.id]: !on } })}
className={`group flex h-10 flex-none items-center rounded-full ${ring}`}
>
<span className={`flex h-6 w-11 items-center rounded-full p-0.5 transition-colors ${on ? "bg-sky-600" : "bg-slate-300 group-hover:bg-slate-400"}`}>
<span className={`h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${on ? "translate-x-5" : "translate-x-0"}`} />
</span>
</button>
</li>
);
})}
</ul>
</div>
))}
<div className="flex flex-col gap-4 border-b border-slate-200 px-5 py-6 sm:flex-row sm:items-center sm:justify-between sm:px-8">
<div>
<h3 className="text-sm font-semibold">Email digest</h3>
<p className="mt-1 text-sm text-slate-600">A summary of what you missed, sent at 8am.</p>
</div>
<div role="group" aria-label="Email digest frequency" className="inline-flex flex-none self-start rounded-lg bg-slate-100 p-1 sm:self-auto">
{digests.map((d) => (
<button
key={d}
type="button"
aria-pressed={draft.digest === d}
onClick={() => setDraft({ ...draft, digest: d })}
className={`h-10 rounded-md px-4 text-sm font-medium transition ${ring} ${draft.digest === d ? "bg-white text-slate-900 shadow-sm" : "text-slate-600 hover:text-slate-900"}`}
>
{d}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-4 bg-slate-50 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-8">
<p role="status" className="flex items-center gap-2 text-sm text-slate-700">
<span aria-hidden="true" className={`h-2 w-2 rounded-full ${changes ? "bg-amber-500" : "bg-emerald-500"}`} />
{changes ? `${changes} unsaved ${changes === 1 ? "change" : "changes"}` : "Everything is saved"}
</p>
<div className="flex gap-3">
<button type="button" disabled={!changes} onClick={() => setDraft(saved)} className={`h-10 flex-1 rounded-lg border border-slate-300 bg-white px-4 text-sm font-semibold transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none ${ring}`}>
Discard
</button>
<button type="button" disabled={!changes} onClick={() => setSaved(draft)} className={`h-10 flex-1 rounded-lg bg-slate-900 px-4 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none ${ring}`}>
Save changes
</button>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/settings-panel-with-toggle-switches.json
Use a button with a rounded track and a round thumb inside it. Hold a boolean in state, set the track colour from it and move the thumb with `translate-x-5`. Add `transition-transform` so it slides.
Both are valid. A button with `role="switch"` and `aria-checked` is announced as a switch with on and off, which matches what people see. Use a checkbox when the choice is submitted with a normal form.
There are two copies of the settings in state: `saved` and `draft`. The count is the number of values that differ. Save copies the draft into saved, and Discard copies it back.
Use a switch for a setting that reads as on or off. Use checkboxes for picking several items from a list, or for agreeing to something in a form.
Related: Search Field with Grouped Results Dropdown, Dark Sign-In Form with Show Password Toggle, Button Set: Primary, Secondary, Ghost, Icon and Loading.
More inputs or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.