A settings layout with sliding anchor nav, switch rows, a hold-to-confirm danger zone, and a sticky save bar that appears on dirty state.
npx shadcn@latest add @paragon/settings-pageAlso installs: button, confirm-hold-button
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
import { ConfirmHoldButton } from "@/registry/paragon/ui/confirm-hold-button";
/* ------------------------------------------------------------------ */
const NAV = [
{ id: "profile", label: "Profile" },
{ id: "notifications", label: "Notifications" },
{ id: "danger", label: "Danger zone" },
] as const;
type SectionId = (typeof NAV)[number]["id"];
interface SettingsState {
name: string;
email: string;
deployAlerts: boolean;
weeklyDigest: boolean;
mentionEmails: boolean;
}
const INITIAL: SettingsState = {
name: "Priya Raman",
email: "priya@meridianlabs.io",
deployAlerts: true,
weeklyDigest: false,
mentionEmails: true,
};
/* ------------------------------------------------------------------ */
function Field({
label,
...props
}: { label: string } & React.ComponentProps<"input">) {
const id = React.useId();
return (
<div>
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
<input
id={id}
className="mt-1.5 h-9 w-full rounded-lg border border-input bg-transparent px-3 text-sm outline-none transition-[border-color,box-shadow] duration-150 ease-out placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
{...props}
/>
</div>
);
}
function SwitchRow({
label,
description,
checked,
onCheckedChange,
}: {
label: string;
description: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) {
const id = React.useId();
return (
<div className="flex items-center justify-between gap-6 py-3.5 first:pt-0 last:pb-0">
<div>
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
<p className="mt-0.5 text-[13px] text-pretty text-muted-foreground">
{description}
</p>
</div>
<SwitchPrimitive.Root
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full outline-none",
"transition-[background-color] duration-150 ease-out",
"bg-muted-foreground/30 data-[state=checked]:bg-primary",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
// 40px hit area on a 20px control.
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
)}
>
<SwitchPrimitive.Thumb className="pointer-events-none block size-4 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform duration-150 ease-out data-[state=checked]:translate-x-[18px] dark:data-[state=checked]:bg-primary-foreground" />
</SwitchPrimitive.Root>
</div>
);
}
function SectionCard({
title,
description,
children,
destructive = false,
...props
}: {
title: string;
description: string;
destructive?: boolean;
} & React.ComponentProps<"section">) {
return (
<section
className={cn(
"rounded-xl bg-card p-5 text-card-foreground sm:p-6",
destructive
? "border border-destructive/30"
: "shadow-border",
)}
{...props}
>
<h3
className={cn(
"text-sm font-semibold",
destructive && "text-destructive",
)}
>
{title}
</h3>
<p className="mt-0.5 text-[13px] text-pretty text-muted-foreground">
{description}
</p>
<div className="mt-5">{children}</div>
</section>
);
}
/* ------------------------------------------------------------------ */
export interface SettingsPageProps extends React.ComponentProps<"div"> {
/** Workspace name referenced in the danger zone. */
workspaceName?: string;
}
/**
* A settings layout: anchor nav with a sliding active indicator, profile and
* notification sections, a hold-to-confirm danger zone, and a sticky save bar
* that slides up whenever any control diverges from its saved state.
*/
export function SettingsPage({
workspaceName = "meridian-labs",
className,
...props
}: SettingsPageProps) {
const reducedMotion = useReducedMotion();
const layoutId = React.useId();
const [active, setActive] = React.useState<SectionId>("profile");
const [saved, setSaved] = React.useState<SettingsState>(INITIAL);
const [draft, setDraft] = React.useState<SettingsState>(INITIAL);
const [deleteRequested, setDeleteRequested] = React.useState(false);
const sectionRefs = React.useRef<Partial<Record<SectionId, HTMLElement>>>({});
const dirty = JSON.stringify(saved) !== JSON.stringify(draft);
const set = <K extends keyof SettingsState>(key: K, value: SettingsState[K]) =>
setDraft((d) => ({ ...d, [key]: value }));
const jumpTo = (id: SectionId) => {
setActive(id);
sectionRefs.current[id]?.scrollIntoView({
behavior: reducedMotion ? "auto" : "smooth",
block: "nearest",
});
};
return (
<div className={cn("relative w-full", className)} {...props}>
<header className="mb-6">
<h1 className="text-xl font-semibold tracking-tight">
Workspace settings
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Manage your profile, notifications, and workspace lifecycle.
</p>
</header>
<div className="flex flex-col gap-6 md:flex-row md:gap-10">
{/* Anchor nav */}
<nav aria-label="Settings sections" className="md:w-44 md:shrink-0">
<ul className="flex gap-1 overflow-x-auto md:flex-col">
{NAV.map((item) => {
const isActive = active === item.id;
return (
<li key={item.id} className="shrink-0">
<button
type="button"
aria-current={isActive ? "true" : undefined}
onClick={() => jumpTo(item.id)}
className={cn(
"relative w-full rounded-md px-3 py-1.5 text-left text-sm whitespace-nowrap outline-none",
"transition-[color] duration-150 ease-out",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
isActive
? "font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
item.id === "danger" &&
isActive &&
"text-destructive",
)}
>
{isActive && (
<motion.span
layoutId={layoutId}
aria-hidden
initial={false}
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", duration: 0.35, bounce: 0 }
}
className="pointer-events-none absolute inset-0 rounded-md bg-muted"
/>
)}
<span className="relative z-10">{item.label}</span>
</button>
</li>
);
})}
</ul>
</nav>
{/* Sections */}
<div className="min-w-0 flex-1 space-y-5 pb-20">
<SectionCard
title="Profile"
description="How you appear across deploy logs, reviews, and audit trails."
ref={(el: HTMLElement | null) => {
if (el) sectionRefs.current.profile = el;
}}
>
<div className="flex items-start gap-4">
<div
aria-hidden
className="flex size-12 shrink-0 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-secondary-foreground"
>
{draft.name
.split(" ")
.map((p) => p.charAt(0))
.slice(0, 2)
.join("")}
</div>
<div className="grid min-w-0 flex-1 gap-4 sm:grid-cols-2">
<Field
label="Full name"
value={draft.name}
autoComplete="name"
onChange={(e) => set("name", e.target.value)}
/>
<Field
label="Email"
type="email"
value={draft.email}
autoComplete="email"
onChange={(e) => set("email", e.target.value)}
/>
</div>
</div>
</SectionCard>
<SectionCard
title="Notifications"
description="Choose what lands in your inbox. Critical incident pages always send."
ref={(el: HTMLElement | null) => {
if (el) sectionRefs.current.notifications = el;
}}
>
<div className="divide-y divide-border">
<SwitchRow
label="Deploy alerts"
description="Failed or rolled-back deploys in workspaces you own."
checked={draft.deployAlerts}
onCheckedChange={(v) => set("deployAlerts", v)}
/>
<SwitchRow
label="Weekly digest"
description="A Monday summary of deploy volume, error budgets, and spend."
checked={draft.weeklyDigest}
onCheckedChange={(v) => set("weeklyDigest", v)}
/>
<SwitchRow
label="Mentions"
description="Email me when someone @mentions me in a review thread."
checked={draft.mentionEmails}
onCheckedChange={(v) => set("mentionEmails", v)}
/>
</div>
</SectionCard>
<SectionCard
title="Danger zone"
description={`Deleting ${workspaceName} removes all environments, deploy history, and API keys. This cannot be undone.`}
destructive
ref={(el: HTMLElement | null) => {
if (el) sectionRefs.current.danger = el;
}}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<ConfirmHoldButton
variant="destructive"
onConfirm={() => setDeleteRequested(true)}
>
Hold to delete workspace
</ConfirmHoldButton>
<div aria-live="polite" role="status">
{deleteRequested && (
<motion.p
initial={
reducedMotion ? { opacity: 0 } : { opacity: 0, y: 4 }
}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="text-[13px] text-muted-foreground"
>
Deletion requested — confirm via the email we just sent.
</motion.p>
)}
</div>
</div>
</SectionCard>
</div>
</div>
{/* Sticky dirty-state save bar */}
<AnimatePresence>
{dirty && (
<motion.div
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, y: 16, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reducedMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
opacity: 0,
y: 16,
filter: "blur(4px)",
transition: { duration: 0.15, ease: [0.4, 0, 1, 1] },
}
}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className="sticky inset-x-0 bottom-4 z-10 mt-4"
>
<div className="mx-auto flex w-fit max-w-full items-center gap-3 rounded-xl bg-popover py-2 pr-2 pl-4 text-popover-foreground shadow-overlay">
<p className="text-sm whitespace-nowrap text-muted-foreground">
Unsaved changes
</p>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setDraft(saved)}
>
Reset
</Button>
<Button size="sm" onClick={() => setSaved(draft)}>
Save changes
</Button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}