Sections

Settings Page

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.

Install

npx shadcn@latest add @paragon/settings-page

Also installs: button, confirm-hold-button

settings-page.tsx

"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>
  );
}