Settings page
Settings navigation, sections of label-left rows and the save bar.
What it's for#
Settings pages change how one person or the whole workspace works. They are narrow forms in a fixed rhythm, so every page under Settings reads the same way.
The layout adds a settings nav beside a 48rem column. Each page is a Page title, then sections; each section is a heading, a sentence and a card of label-left rows. Edits collect in a save bar instead of saving field by field. Who is looking changes the page: members see a read-only copy and the owner alone sees the danger zone.
| Route | What it holds |
|---|---|
| /settings/workspace | General: name, URL, defaults and the workspace danger zone. |
| /settings/profile | Your name, photo, email and your own danger zone. |
| /settings/notifications | Switch rows for email and in-app notifications. |
| /settings/members | A members table inside the same column; read-only for members. |
| /settings/security | Password, passkeys, two-factor and sessions. |
| /settings/billing | Plan, payment method and invoices. |
Live composition#
Cedarline's General settings at 1280px. Edit a field to raise the save bar, switch to Notifications, view the page as a member, or try deleting the workspace.
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { Input } from "@oration/canon/components/input";import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@oration/canon/components/input-group";import { NativeSelect, NativeSelectOptGroup, NativeSelectOption } from "@oration/canon/components/native-select";import { PageTitle } from "@oration/canon/components/page-title";import { SaveBar } from "@oration/canon/components/save-bar";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { descriptionId, SettingsGroup, SettingsRow, SettingsSection } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import { cn } from "@oration/canon/lib/utils";import { BellIcon, Building2Icon, ClockIcon, CoinsIcon, CreditCardIcon, EyeIcon, KeyRoundIcon, ShieldCheckIcon, UserIcon, UsersIcon, WebhookIcon,} from "lucide-react";import * as React from "react";import { AppShell } from "../document-page/frame";export function SettingsPage() { const groups = [ { label: "Account", items: [ { id: "profile", label: "Profile", icon: UserIcon }, { id: "notifications", label: "Notifications", icon: BellIcon }, { id: "security", label: "Account security", icon: KeyRoundIcon, }, ], }, { label: "Workspace", items: [ { id: "general", label: "General", icon: Building2Icon }, { id: "hours", label: "Business hours", icon: ClockIcon }, { id: "members", label: "Members", icon: UsersIcon }, { id: "roles", label: "Roles and permissions", icon: ShieldCheckIcon, }, { id: "billing", label: "Billing", icon: CreditCardIcon }, { id: "usage", label: "Usage and credits", icon: CoinsIcon }, { id: "webhooks", label: "Webhooks", icon: WebhookIcon }, ], }, ]; const items = groups.flatMap((group) => group.items); const [page, setPage] = React.useState<"general" | "notifications">( "general", ); const [role, setRole] = React.useState<"owner" | "member">("owner"); const [deleting, setDeleting] = React.useState(false); const readOnly = role === "member"; const form = useDirtyForm( { name: "Cedarline", slug: "cedarline", currency: "USD", fiscal: "January", monday: true, approvals: true, mentions: true, digest: false, }, { onSave: () => new Promise<void>((resolve) => { window.setTimeout(() => { toast.add({ type: "success", title: "Changes saved" }); resolve(); }, 600); }), }, ); const { values, set } = form; const open = (id: string) => { if (id === "general" || id === "notifications") setPage(id); else toast.add({ title: items.find((item) => item.id === id)?.label, description: "Every settings page has the same rhythm: a page title, then sections of label-left rows.", }); }; const current = items.find((item) => item.id === page); return ( <AppShell active="Settings" crumbs={[{ label: "Settings" }, { label: current?.label ?? "" }]} actions={ <SegmentedControl label="View as" value={role} onValueChange={(next) => { setRole(next); form.reset(); }} options={[ { value: "owner", label: "Owner" }, { value: "member", label: "Member" }, ]} /> } > <div className="flex min-h-0 flex-1"> <nav aria-label="Settings" className="hidden h-full w-56 shrink-0 overflow-y-auto border-r border-border @min-[1024px]:block" > <div className="px-3 py-6"> {groups.map((group) => ( <div key={group.label} className="mb-6 last:mb-0"> <p className="px-2 pb-1.5 text-xs font-medium text-muted-foreground"> {group.label} </p> <ul className="flex flex-col gap-px"> {group.items.map((item) => { const active = item.id === page; return ( <li key={item.id}> <button type="button" aria-current={ active ? "page" : undefined } onClick={() => open(item.id) } className={cn( "flex h-7 w-full items-center gap-2.5 rounded-md px-2 text-left text-13 outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40", active ? "bg-accent font-medium text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > <item.icon aria-hidden="true" className={cn( "size-4 shrink-0", active ? "text-foreground" : "text-subtle-foreground", )} /> <span className="truncate"> {item.label} </span> </button> </li> ); })} </ul> </div> ))} </div> </nav> <main className="min-w-0 flex-1 overflow-y-auto"> <div className="mx-auto w-full max-w-3xl px-4 pt-8 pb-16 @min-[640px]:px-6 @min-[768px]:pt-12 @min-[1024px]:px-10"> <div className="mb-8 @min-[1024px]:hidden"> <NativeSelect aria-label="Settings page" className="w-full" value={page} onChange={(event) => open(event.target.value)} > {groups.map((group) => ( <NativeSelectOptGroup key={group.label} label={group.label} > {group.items.map((item) => ( <NativeSelectOption key={item.id} value={item.id} > {item.label} </NativeSelectOption> ))} </NativeSelectOptGroup> ))} </NativeSelect> </div> <div data-settings-mode={readOnly ? "read" : undefined}> {readOnly ? ( <p role="status" className="mb-6 flex items-center gap-2 rounded-[10px] bg-muted/70 px-3 py-2 text-13 text-muted-foreground" > <EyeIcon aria-hidden="true" className="size-3.5 shrink-0" /> You can view these settings. Only admins can change them. </p> ) : null} <fieldset disabled={readOnly} className="contents"> {page === "general" ? ( <> <PageTitle title="General" description="Your workspace's name, address and defaults. Changes apply to every member." /> <div className="mt-10 flex flex-col"> <SettingsSection title="Workspace" description="How Cedarline appears to members, in links and on invoices you send." > <SettingsGroup> <SettingsRow label="Workspace name" htmlFor="ws-name" align="start" > <Input id="ws-name" className="w-full @min-[640px]:w-64" value={values.name} onChange={(event) => set( "name", event.target .value, ) } /> </SettingsRow> <SettingsRow label="Workspace URL" description="Changing it updates every link to your records. Old links redirect for 30 days." htmlFor="ws-slug" align="start" > <InputGroup className="w-full @min-[640px]:w-64"> <InputGroupAddon> <InputGroupText className="text-xs"> oration.app/ </InputGroupText> </InputGroupAddon> <InputGroupInput id="ws-slug" aria-describedby={descriptionId( "ws-slug", )} value={ values.slug } onChange={( event, ) => set( "slug", event .target .value, ) } /> </InputGroup> </SettingsRow> <SettingsRow label="Default currency" htmlFor="ws-currency" > <NativeSelect id="ws-currency" className="w-40" value={ values.currency } onChange={(event) => set( "currency", event.target .value, ) } > <NativeSelectOption value="USD"> US dollar </NativeSelectOption> <NativeSelectOption value="EUR"> Euro </NativeSelectOption> <NativeSelectOption value="GBP"> British pound </NativeSelectOption> </NativeSelect> </SettingsRow> </SettingsGroup> </SettingsSection> <SettingsSection title="Calendar" description="Used by forecasts, reports and payment run schedules." > <SettingsGroup> <SettingsRow label="Fiscal year starts" htmlFor="ws-fiscal" > <NativeSelect id="ws-fiscal" className="w-40" value={ values.fiscal } onChange={(event) => set( "fiscal", event.target .value, ) } > <NativeSelectOption value="January"> January </NativeSelectOption> <NativeSelectOption value="April"> April </NativeSelectOption> <NativeSelectOption value="July"> July </NativeSelectOption> </NativeSelect> </SettingsRow> <SettingsRow label="Weeks start on Monday" description="Otherwise weeks start on Sunday." htmlFor="ws-monday" inline > <Switch id="ws-monday" aria-describedby={descriptionId( "ws-monday", )} checked={ values.monday } onCheckedChange={( checked, ) => set( "monday", checked, ) } /> </SettingsRow> </SettingsGroup> </SettingsSection> {readOnly ? null : ( <SettingsSection title="Danger zone" description="These actions affect everyone in Cedarline. Only the owner can use them." > <SettingsGroup> <SettingsRow label="Transfer ownership" description="Make another member the owner. You'll become an admin and lose access to billing and deletion." > <Button variant="outline"> Transfer ownership </Button> </SettingsRow> <SettingsRow label="Export all workspace data" description="Every record, note, email and activity as CSV files in one zip." > <Button variant="outline" onClick={() => toast.add({ title: "Export started", description: "We'll email Maya a link when the zip is ready.", }) } > Export data </Button> </SettingsRow> <SettingsRow label="Delete workspace" description="Permanently delete Cedarline for every member, with all records, sequences, agents and billing history." > <Button variant="destructive" onClick={() => setDeleting( true, ) } > Delete workspace </Button> </SettingsRow> </SettingsGroup> </SettingsSection> )} </div> </> ) : ( <> <PageTitle title="Notifications" description="What Oration tells you about, and where. These settings are yours alone." /> <div className="mt-10 flex flex-col"> <SettingsSection title="Email" description="Sent to maya@cedarline.com." > <SettingsGroup> <SettingsRow label="Approvals waiting for you" description="When an agent proposes something only you can approve." htmlFor="n-approvals" inline > <Switch id="n-approvals" aria-describedby={descriptionId( "n-approvals", )} checked={ values.approvals } onCheckedChange={( checked, ) => set( "approvals", checked, ) } /> </SettingsRow> <SettingsRow label="Mentions" description="When someone mentions you in a note or a ticket." htmlFor="n-mentions" inline > <Switch id="n-mentions" aria-describedby={descriptionId( "n-mentions", )} checked={ values.mentions } onCheckedChange={( checked, ) => set( "mentions", checked, ) } /> </SettingsRow> <SettingsRow label="Weekly pipeline summary" description="Monday at 8:00 AM, with the forecast and deals that moved." htmlFor="n-digest" inline > <Switch id="n-digest" aria-describedby={descriptionId( "n-digest", )} checked={ values.digest } onCheckedChange={( checked, ) => set( "digest", checked, ) } /> </SettingsRow> </SettingsGroup> </SettingsSection> </div> </> )} <SaveBar dirty={form.isDirty} changes={form.dirtyCount} saving={form.saving} onDiscard={form.reset} onSave={form.save} warnOnLeave={false} /> </fieldset> </div> </div> </main> </div> <ConfirmDialog open={deleting} onOpenChange={setDeleting} title="Delete Cedarline?" description="Every member loses access right away. Records, sequences, agents and billing history are deleted after 30 days." confirmLabel="Delete workspace" confirmText="cedarline" onConfirm={() => { setDeleting(false); toast.add({ title: "Deletion scheduled", description: "Cedarline will be deleted on Oct 28. Restore it from Billing until then.", }); }} /> </AppShell> );}Anatomy#
Eight parts. Sections repeat; everything else appears once.
- App header. Crumbs read Settings, then the page. Settings pages put no actions here.
- Settings nav. 14rem, a hairline right edge, sticky under the header. Groups (Account, Workspace, Agents Platform, Contact Center, Tickets) with Caption labels and 28px items; the active item fills Menu Hover. Below 1024px it becomes a select.
- Page title. Page title with the page's one
h1and a sentence on whose settings these are. Sections start 40px below. - Settings section. Settings section: a Title heading, a 13px description and optional actions on the right. Sections after the first get a hairline top.
- Settings group. A card of rows separated by hairlines. One group per section is typical.
- Settings row. Label and description on the left, the control on the right; stacked below 640px unless
inline. Connect the description witharia-describedby={descriptionId(id)}. - Save bar. Save bar: sticky at the bottom, the unsaved count, Discard and the filled Save with ⌘S. It is the page's one filled button.
- Danger zone. Always the last section, owner only. Outline buttons for reversible jobs, the destructive tint for delete, and a typed confirmation. See Destructive actions.
Measurements#
From DESIGN.md's Layout section and the settings layout.
| Part | Value | Classes |
|---|---|---|
| Settings nav | 14rem, from 1024px | sticky top-12 w-56 border-r lg:block |
| Nav items | 28px, 8px corners | h-7 rounded-md px-2 text-13 |
| Column | 48rem | mx-auto max-w-3xl |
| Gutters | 16px, 24px from 640px, 40px from 1024px | px-4 sm:px-6 lg:px-10 |
| Top padding | 32px, 48px from 768px | pt-8 md:pt-12 |
| Bottom padding | 64px, room for the save bar | pb-16 |
| Title to first section | 40px | mt-10 |
| Section padding | 40px above and below a hairline | border-t py-10 |
| Row padding | 16px | px-4 py-4 |
| Row description | 28rem maximum, 13px | max-w-md text-13 |
| Save bar | 16px above the safe area | sticky bottom-[calc(1rem+env(safe-area-inset-bottom))] |
DESIGN.md spaces page sections 32px apart. Settings section pads 40px on each side of a hairline, so settings sections sit 80px apart. DESIGN.md is the spec; this is a gap in the component to fix there, not a gap to override page by page.
Responsive behavior#
The settings column is already narrow, so it barely changes. The nav is what moves.
From 640px gutters are 24px and the top padding grows to 48px from 768px. The nav is still a select.
| Width | What changes |
|---|---|
| 390px | The nav is a full-width select above the title. Rows stack label over control. 16px gutters; inputs grow to 16px text so iOS doesn't zoom. |
| 768px | 24px gutters and 48px of top padding. Rows sit label-left from 640px. |
| 1024px | The 14rem nav appears and sticks; gutters grow to 40px. |
| 1440px | The 48rem column centers right of the nav. |
States#
Settings pages have more page-level states than any other template, because access changes what can be done.
| Region | States it owns |
|---|---|
| Page | Editable; read-only (a Well Gray notice, You can view these settings. Only admins can change them, and a disabled fieldset); no access (an EmptyState naming the admins to ask); feature off. |
| Nav | The active page in Menu Hover with medium weight; hovered items fill Menu Hover at 60%. |
| Rows | Default, focused, invalid with a field error, disabled in read-only mode. |
| Save bar | Hidden when clean, the change count when dirty, a spinner while saving. A failed save keeps the bar up; show the error from onSave yourself. |
| Danger zone | Owner only. In the product, Delete workspace asks you to confirm it's you, then to type the workspace slug. |
In read-only mode switches and checkboxes keep their full-opacity look, so only the notice says they can't change. The read-only rule also lives in apps/web/src/app/globals.css rather thanpackages/canon. Both are recorded on Settings section.
Keyboard#
| Keys | Action |
|---|---|
| ⌘S | Save, while the save bar is showing. |
| Tab | Move through rows in reading order, label then control. |
| Space | Toggle a switch row. |
Starter code#
The layout that every settings page shares, then one page.
// app/(app)/settings/layout.tsxexport default function SettingsLayout({ children }: { children: React.ReactNode }) { return ( <> <SettingsHeader /> <div className="flex min-h-0 flex-1"> {/* sticky top-12 hidden h-[calc(100svh-3rem)] w-56 border-r lg:block */} <SettingsNav /> <div className="min-w-0 flex-1"> <div className="mx-auto w-full max-w-3xl px-4 pt-8 pb-16 sm:px-6 md:pt-12 lg:px-10"> {/* A select of every settings page, below 1024px */} <SettingsMobileNav /> {/* Read-only notice and a disabled fieldset, or no access */} <SettingsAccess>{children}</SettingsAccess> </div> </div> </div> </> );}// app/(app)/settings/workspace/page.tsxexport default function WorkspaceSettings() { const form = useSettingsForm(initial); return ( <SettingsPage title="General" description="Your workspace's name, address and defaults. Changes apply to every member."> <SettingsSection title="Workspace" description="How Cedarline appears to members and in links."> <SettingsGroup> <SettingsRow label="Workspace name" htmlFor="workspace-name" align="start"> <Input id="workspace-name" value={form.values.name} onChange={(e) => form.set("name", e.target.value)} /> </SettingsRow> <SettingsRow label="Weeks start on Monday" description="Otherwise weeks start on Sunday." htmlFor="week-start" inline> <Switch id="week-start" aria-describedby={descriptionId("week-start")} checked={form.values.monday} onCheckedChange={(on) => form.set("monday", on)} /> </SettingsRow> </SettingsGroup> </SettingsSection> {isOwner ? <DangerZoneSection /> : null} <SaveBar dirty={form.dirty} changes={form.dirtyCount} saving={form.saving} onDiscard={form.discard} onSave={form.save} /> </SettingsPage> );}- Keep one save bar per page: its ⌘S listener is on
window. - Hide the danger zone for anyone who can't use it. A disabled Delete button with no reason teaches nothing.
- Name the settings that change for everyone in the page description: Changes apply to every member.
Rules that apply#
The One Filled Button Rule
The Sentence Case Rule
The Hairline-and-Lift Rule
shadow-border), never from a CSS border plus a shadow. CSS borders are for structural dividers only: table rules, header bottoms, section splits.