Skip to content

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.

Routes built on the settings page
RouteWhat it holds
/settings/workspaceGeneral: name, URL, defaults and the workspace danger zone.
/settings/profileYour name, photo, email and your own danger zone.
/settings/notificationsSwitch rows for email and in-app notifications.
/settings/membersA members table inside the same column; read-only for members.
/settings/securityPassword, passkeys, two-factor and sessions.
/settings/billingPlan, 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.

General

Your workspace's name, address and defaults. Changes apply to every member.

Workspace

How Cedarline appears to members, in links and on invoices you send.

Changing it updates every link to your records. Old links redirect for 30 days.
oration.app/

Calendar

Used by forecasts, reports and payment run schedules.

Otherwise weeks start on Sunday.

Danger zone

These actions affect everyone in Cedarline. Only the owner can use them.

Transfer ownership
Make another member the owner. You'll become an admin and lose access to billing and deletion.
Export all workspace data
Every record, note, email and activity as CSV files in one zip.
Delete workspace
Permanently delete Cedarline for every member, with all records, sequences, agents and billing history.
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>    );}

View as is a demo control standing in for the workspace role. The settings nav is a list of links, not Side tabs; use Side tabs for switching panels inside one settings page, as Roles and permissions does. The product's mobile nav is a Select; the frame uses a native select.

Anatomy#

Eight parts. Sections repeat; everything else appears once.

  1. App header. Crumbs read Settings, then the page. Settings pages put no actions here.
  2. 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.
  3. Page title. Page title with the page's one h1 and a sentence on whose settings these are. Sections start 40px below.
  4. Settings section. Settings section: a Title heading, a 13px description and optional actions on the right. Sections after the first get a hairline top.
  5. Settings group. A card of rows separated by hairlines. One group per section is typical.
  6. Settings row. Label and description on the left, the control on the right; stacked below 640px unless inline. Connect the description with aria-describedby={descriptionId(id)}.
  7. 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.
  8. 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.

Settings page measurements
PartValueClasses
Settings nav14rem, from 1024pxsticky top-12 w-56 border-r lg:block
Nav items28px, 8px cornersh-7 rounded-md px-2 text-13
Column48remmx-auto max-w-3xl
Gutters16px, 24px from 640px, 40px from 1024pxpx-4 sm:px-6 lg:px-10
Top padding32px, 48px from 768pxpt-8 md:pt-12
Bottom padding64px, room for the save barpb-16
Title to first section40pxmt-10
Section padding40px above and below a hairlineborder-t py-10
Row padding16pxpx-4 py-4
Row description28rem maximum, 13pxmax-w-md text-13
Save bar16px above the safe areasticky 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.

General

Your workspace's name, address and defaults. Changes apply to every member.

Workspace

How Cedarline appears to members, in links and on invoices you send.

Changing it updates every link to your records. Old links redirect for 30 days.
oration.app/

Calendar

Used by forecasts, reports and payment run schedules.

Otherwise weeks start on Sunday.

Danger zone

These actions affect everyone in Cedarline. Only the owner can use them.

Transfer ownership
Make another member the owner. You'll become an admin and lose access to billing and deletion.
Export all workspace data
Every record, note, email and activity as CSV files in one zip.
Delete workspace
Permanently delete Cedarline for every member, with all records, sequences, agents and billing history.

From 640px gutters are 24px and the top padding grows to 48px from 768px. The nav is still a select.

Settings page at each width
WidthWhat changes
390pxThe 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.
768px24px gutters and 48px of top padding. Rows sit label-left from 640px.
1024pxThe 14rem nav appears and sticks; gutters grow to 40px.
1440pxThe 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.

States by region
RegionStates it owns
PageEditable; 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.
NavThe active page in Menu Hover with medium weight; hovered items fill Menu Hover at 60%.
RowsDefault, focused, invalid with a field error, disabled in read-only mode.
Save barHidden 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 zoneOwner 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#

Keyboard interactions
KeysAction
⌘SSave, while the save bar is showing.
TabMove through rows in reading order, label then control.
SpaceToggle a switch row.

Starter code#

The layout that every settings page shares, then one page.

A settings 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

Each view has at most one filled indigo button, the action the view exists for. Everything else is outline, secondary, ghost, link or a destructive tint. In a stack of proposals, only the expanded one shows its filled button.

The Sentence Case Rule

Everything is sentence case. No eyebrow or kicker label above a heading, no uppercase labels, no letter-spaced captions.

The Hairline-and-Lift Rule

A raised surface takes its edge and its lift from one composite shadow (shadow-border), never from a CSS border plus a shadow. CSS borders are for structural dividers only: table rules, header bottoms, section splits.

The Owned States Rule

Every component that shows data owns its loading, empty and error states, with a skeleton shaped like its final layout. A whole page is never gated on loading.

Do and don't#

2 unsaved changesDiscardSave changes
Do. Collect edits in the save bar and keep Save its only filled button.
WorkspaceSave
CalendarSave
Don't. Put a filled Save button at the end of every section. Four Saves, four meanings.
Delete workspace
Do. Draw Delete workspace as the destructive tint and confirm it by typing the slug.
Delete workspace
Don't. Fill it solid red, or delete on one click.
Do. Show members a read-only copy with one notice at the top.
Don't. Hide the whole page from members, so they can't check a setting before asking an admin to change it.