Skip to content

Settings section

The rhythm of every settings page: sections, groups and label-left rows.

Status
Stable
Category
Layout
Adoption
Not used yet
import { SettingsSection } from "@oration/canon/components/settings-section";
packages/canon/src/components/settings-section.tsx

Remittance

Where remittance advice goes after each payment run and what it includes.

Supplier replies to remittance emails land here.
Suppliers on EDI get an 820 file whatever you pick.
Adds each paid invoice as a PDF, up to 20 per email.
import { InfoTip } from "@oration/canon/components/info-tip";import { Input } from "@oration/canon/components/input";import { SettingsSelect } from "@oration/canon/components/select-field";import { descriptionId, SettingsGroup, SettingsRow, SettingsSection } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import * as React from "react";export function Hero() {    const id = React.useId();    const [email, setEmail] = React.useState("remittance@cedarline.com");    const [format, setFormat] = React.useState("pdf");    const [attach, setAttach] = React.useState(true);    return (        <div className="w-full max-w-2xl text-left">            <SettingsSection                title="Remittance"                description="Where remittance advice goes after each payment run and what it includes."                info={                    <InfoTip                        title="Remittance advice"                        description="The note a supplier gets when you pay them: which invoices the payment covers, any deductions and the payment date."                    />                }            >                <SettingsGroup>                    <SettingsRow                        label="Reply-to address"                        htmlFor={`${id}-email`}                        description="Supplier replies to remittance emails land here."                    >                        <Input                            id={`${id}-email`}                            type="email"                            value={email}                            onChange={(event) => setEmail(event.target.value)}                            aria-describedby={descriptionId(`${id}-email`)}                            className="sm:w-64"                        />                    </SettingsRow>                    <SettingsRow                        label="Advice format"                        htmlFor={`${id}-format`}                        description="Suppliers on EDI get an 820 file whatever you pick."                    >                        <SettingsSelect                            id={`${id}-format`}                            value={format}                            onValueChange={setFormat}                            describedBy={descriptionId(`${id}-format`)}                            options={[                                { value: "pdf", label: "PDF attachment" },                                { value: "csv", label: "CSV attachment" },                                { value: "inline", label: "In the email body" },                            ]}                        />                    </SettingsRow>                    <SettingsRow                        inline                        label="Attach invoice copies"                        htmlFor={`${id}-attach`}                        description="Adds each paid invoice as a PDF, up to 20 per email."                    >                        <Switch                            id={`${id}-attach`}                            checked={attach}                            onCheckedChange={setAttach}                            aria-describedby={descriptionId(`${id}-attach`)}                        />                    </SettingsRow>                </SettingsGroup>            </SettingsSection>        </div>    );}

Usage#

Settings section is the rhythm of every settings page. SettingsSection titles a block of settings and separates it from the next with a hairline and 40px of space, SettingsGroup is the card that holds its rows, and SettingsRow puts the label and description on the leading side and the control on the trailing side. The thing people get wrong is the description: the row gives it an id, but it's up to you to point the control's aria-describedby at it with descriptionId.

When to use

  • For any settings or configuration page: workspace, payments, remittance, an agent's details.
  • To pair a named setting with its control and one sentence about its effect, in a row that stacks on narrow screens.
  • To group related settings in one card under a section title, with section-level actions such as Add approver.
  • With data-settings-mode="read" around it, to show the same page to people who can view but not change settings.

When not to use

  • For a form people fill in once, such as adding a supplier. Labels sit above controls there. Use Field
  • For a list of records with columns, such as API keys or approvers with roles. Put a table in the section instead. Use Card table
  • For the heading of a content block on a dashboard or record page. Use Section header
  • For a read-only list of facts about a record. Use Meta line
  • For the page itself. Settings pages start with one page title. Use Page title

The Hairline-and-Lift Rule

The group card takes its edge from shadow-border, never a border plus a shadow. Rows are split by CSS hairlines, the structural dividers the rule allows, and sections by a hairline on top.

The Tint Well Rule

A sub-region inside a group, such as a preview or a code sample, is a 70% Well Gray tint with 10px corners, not a second card.

The Thirteen-Fourteen Rule

Labels and section titles are 14px; descriptions are 13px muted. Don't shrink descriptions further to fit more in a row.

The One Filled Button Rule

Section actions are outline or ghost. The page's one filled button is the save bar's Save, or the page title's main action.

Anatomy#

Approvals

Who signs off before a payment run goes out.

Require two approvers
For runs over $250,000.
  1. Section title. An h2 at 14px semibold that names the section. The section is labelled by it.
  2. Info tip. Optional InfoTip beside the title or a row label, for jargon and deeper help.
  3. Section description. One 13px muted sentence on what these settings affect, up to 36rem wide.
  4. Section actions. Optional buttons on the trailing side, bottom-aligned with the description. They wrap below on narrow screens.
  5. Group. A Card White surface with 12px corners and shadow-border, with a hairline between rows.
  6. Row label and description. 14px medium label (a real <label> when htmlFor is set) and a 13px muted description up to 28rem wide.
  7. Control. Whatever the row sets, on the trailing side. Full width below 640px unless the row is inline.

Examples#

Row layouts

The default row stacks under 640px. inline keeps a switch beside its label at every width, and align='start' pins the label to the top of a tall control.

Default row. Stacks under 640px with a full-width control.
Inline row. The switch stays beside its label at every width.
import { Input } from "@oration/canon/components/input";import { descriptionId, SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function RowLayouts() {    const id = React.useId();    const [prefix, setPrefix] = React.useState("CDL-");    const [weekends, setWeekends] = React.useState(false);    const [footer, setFooter] = React.useState(        "Questions about this payment? Reply to this email or call (312) 555-0148.",    );    return (        <div className="w-full max-w-2xl">            <SettingsGroup>                <SettingsRow                    label="Invoice number prefix"                    htmlFor={`${id}-prefix`}                    description="Default row. Stacks under 640px with a full-width control."                >                    <Input                        id={`${id}-prefix`}                        value={prefix}                        onChange={(event) => setPrefix(event.target.value)}                        aria-describedby={descriptionId(`${id}-prefix`)}                        className="font-mono sm:w-40"                    />                </SettingsRow>                <SettingsRow                    inline                    label="Pay on weekends"                    htmlFor={`${id}-weekends`}                    description="Inline row. The switch stays beside its label at every width."                >                    <Switch                        id={`${id}-weekends`}                        checked={weekends}                        onCheckedChange={setWeekends}                        aria-describedby={descriptionId(`${id}-weekends`)}                    />                </SettingsRow>                <SettingsRow                    align="start"                    label="Remittance footer"                    htmlFor={`${id}-footer`}                    description="Top-aligned row, for a control taller than its label."                >                    <Textarea                        id={`${id}-footer`}                        value={footer}                        onChange={(event) => setFooter(event.target.value)}                        aria-describedby={descriptionId(`${id}-footer`)}                        rows={3}                        className="sm:w-72"                    />                </SettingsRow>            </SettingsGroup>        </div>    );}

Info tips and badges

info takes an InfoTip for jargon, beside a section title or a row label. badge takes a gray tag for Beta or Admin only.

Invoice capture

Beta

How invoices that arrive by email are read and matched.

Hold invoices that don't match the PO and receipt.
Admin only
Slower, and flagged for review when unsure.
import { InfoTip } from "@oration/canon/components/info-tip";import { descriptionId, SettingsGroup, SettingsRow, SettingsSection } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { Tag } from "@oration/canon/components/tag";import * as React from "react";export function InfoAndBadges() {    const id = React.useId();    const [matching, setMatching] = React.useState(true);    const [ocr, setOcr] = React.useState(false);    return (        <div className="w-full max-w-2xl">            <SettingsSection                title="Invoice capture"                badge={<Tag color="gray">Beta</Tag>}                description="How invoices that arrive by email are read and matched."            >                <SettingsGroup>                    <SettingsRow                        inline                        label="Three-way matching"                        htmlFor={`${id}-match`}                        description="Hold invoices that don't match the PO and receipt."                        info={                            <InfoTip                                title="Three-way matching"                                description="Compares the invoice with its purchase order and goods receipt. Quantities and prices must agree within your tolerance before it can be paid."                            />                        }                    >                        <Switch                            id={`${id}-match`}                            checked={matching}                            onCheckedChange={setMatching}                            aria-describedby={descriptionId(`${id}-match`)}                        />                    </SettingsRow>                    <SettingsRow                        inline                        label="Read handwritten totals"                        htmlFor={`${id}-ocr`}                        badge={<Tag color="gray">Admin only</Tag>}                        description="Slower, and flagged for review when unsure."                    >                        <Switch                            id={`${id}-ocr`}                            checked={ocr}                            onCheckedChange={setOcr}                            aria-describedby={descriptionId(`${id}-ocr`)}                        />                    </SettingsRow>                </SettingsGroup>            </SettingsSection>        </div>    );}

Section actions

Actions that add to or act on the whole section sit in actions, outline or ghost. Row-level actions go in the row's control slot.

Approvers

Asked in this order before a payment run goes out.

1. Maya Okafor
VP of Revenue
2. Priya Raman
Controller
import { Button } from "@oration/canon/components/button";import { SettingsGroup, SettingsRow, SettingsSection } from "@oration/canon/components/settings-section";import { toast } from "@oration/canon/components/toast";import { PlusIcon } from "lucide-react";import * as React from "react";export function SectionActions() {    const pool = [        { key: "maya", name: "Maya Okafor", role: "VP of Revenue" },        { key: "priya", name: "Priya Raman", role: "Controller" },        { key: "tomas", name: "Tomás Ferreira", role: "Treasury" },        { key: "aisha", name: "Aisha Bello", role: "AP lead" },    ];    const [approvers, setApprovers] = React.useState(pool.slice(0, 2));    const next = pool.find(        (person) => !approvers.some((a) => a.key === person.key),    );    return (        <div className="w-full max-w-2xl">            <SettingsSection                title="Approvers"                description="Asked in this order before a payment run goes out."                actions={                    <Button                        type="button"                        variant="outline"                        size="sm"                        disabled={!next}                        onClick={() => {                            if (!next) return;                            setApprovers((current) => [...current, next]);                            toast.add({                                title: `${next.name} added as an approver`,                            });                        }}                    >                        <PlusIcon data-icon="inline-start" aria-hidden="true" />                        Add approver                    </Button>                }            >                <SettingsGroup>                    {approvers.map((approver, index) => (                        <SettingsRow                            key={approver.key}                            inline                            label={`${index + 1}. ${approver.name}`}                            description={approver.role}                        >                            <Button                                type="button"                                variant="ghost"                                size="sm"                                disabled={approvers.length === 1}                                onClick={() =>                                    setApprovers((current) =>                                        current.filter(                                            (a) => a.key !== approver.key,                                        ),                                    )                                }                            >                                Remove                            </Button>                        </SettingsRow>                    ))}                </SettingsGroup>            </SettingsSection>        </div>    );}

Sections on a page

Sections are plain siblings. Each one after the first gets a hairline and 40px above it; give them ids so side tabs and links can jump to them.

General

The basics every payment run starts from.

Notifications

What the AP team hears about, and when.

Sent at 8:00 AM with invoices on hold.

Close workspace

Stops payment runs and removes access for everyone.

import { Button } from "@oration/canon/components/button";import { SettingsSelect } from "@oration/canon/components/select-field";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 * as React from "react";export function PageRhythm() {    const id = React.useId();    const [timezone, setTimezone] = React.useState("America/Chicago");    const [digest, setDigest] = React.useState(true);    return (        <div className="w-full max-w-2xl">            <SettingsSection                id={`${id}-general`}                title="General"                description="The basics every payment run starts from."            >                <SettingsGroup>                    <SettingsRow label="Time zone" htmlFor={`${id}-tz`}>                        <SettingsSelect                            id={`${id}-tz`}                            value={timezone}                            onValueChange={setTimezone}                            options={[                                {                                    value: "America/Chicago",                                    label: "Central time (Chicago)",                                },                                {                                    value: "America/New_York",                                    label: "Eastern time (New York)",                                },                                {                                    value: "America/Los_Angeles",                                    label: "Pacific time (Los Angeles)",                                },                            ]}                        />                    </SettingsRow>                </SettingsGroup>            </SettingsSection>            <SettingsSection                id={`${id}-notifications`}                title="Notifications"                description="What the AP team hears about, and when."            >                <SettingsGroup>                    <SettingsRow                        inline                        label="Daily exceptions digest"                        htmlFor={`${id}-digest`}                        description="Sent at 8:00 AM with invoices on hold."                    >                        <Switch                            id={`${id}-digest`}                            checked={digest}                            onCheckedChange={setDigest}                            aria-describedby={descriptionId(`${id}-digest`)}                        />                    </SettingsRow>                </SettingsGroup>            </SettingsSection>            <SettingsSection                id={`${id}-danger`}                title="Close workspace"                description="Stops payment runs and removes access for everyone."                actions={                    <Button                        type="button"                        variant="destructive"                        size="sm"                        onClick={() =>                            toast.add({                                title: "Closing needs a confirmation",                                description:                                    "This opens a dialog in the product.",                            })                        }                    >                        Close workspace                    </Button>                }            />        </div>    );}

Read-only for viewers

Wrap the page in `data-settings-mode='read' and a disabled fieldset, with a status note on top. Values read as plain text and row buttons disappear. Switch between Admin and Viewer.

You can view these settings. Only admins can change them.

Remittance

Where remittance advice goes after each payment run.

Signing key
Suppliers verify remittance files with it.
import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { SettingsSelect } from "@oration/canon/components/select-field";import { SettingsGroup, SettingsRow, SettingsSection } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { EyeIcon } from "lucide-react";import * as React from "react";export function ReadOnly() {    const id = React.useId();    const [role, setRole] = React.useState<"admin" | "viewer">("viewer");    const [email, setEmail] = React.useState("remittance@cedarline.com");    const [format, setFormat] = React.useState("pdf");    const [attach, setAttach] = React.useState(true);    const readOnly = role === "viewer";    const section = (        <SettingsSection            title="Remittance"            description="Where remittance advice goes after each payment run."        >            <SettingsGroup>                <SettingsRow label="Reply-to address" htmlFor={`${id}-email`}>                    <Input                        id={`${id}-email`}                        value={email}                        onChange={(event) => setEmail(event.target.value)}                        className="sm:w-64"                    />                </SettingsRow>                <SettingsRow label="Advice format" htmlFor={`${id}-format`}>                    <SettingsSelect                        id={`${id}-format`}                        value={format}                        onValueChange={setFormat}                        options={[                            { value: "pdf", label: "PDF attachment" },                            { value: "csv", label: "CSV attachment" },                        ]}                    />                </SettingsRow>                <SettingsRow                    inline                    label="Attach invoice copies"                    htmlFor={`${id}-attach`}                >                    <Switch                        id={`${id}-attach`}                        checked={attach}                        onCheckedChange={setAttach}                        disabled={readOnly}                    />                </SettingsRow>                <SettingsRow                    label="Signing key"                    description="Suppliers verify remittance files with it."                >                    <Button                        type="button"                        variant="outline"                        size="sm"                        onClick={() =>                            toast.add({ title: "Signing key rotated" })                        }                    >                        Rotate key                    </Button>                </SettingsRow>            </SettingsGroup>        </SettingsSection>    );    return (        <div className="flex w-full max-w-2xl flex-col gap-4">            <SegmentedControl                label="View as"                value={role}                onValueChange={setRole}                options={[                    { value: "admin", label: "Admin" },                    { value: "viewer", label: "Viewer" },                ]}            />            {readOnly ? (                <div data-settings-mode="read">                    <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>                    <fieldset disabled className="contents">                        {section}                    </fieldset>                </div>            ) : (                section            )}        </div>    );}

States#

Editable

Reply-to address
Advice format

Read-only

Reply-to address
Advice format
import { Input } from "@oration/canon/components/input";import { SettingsSelect } from "@oration/canon/components/select-field";import { SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import * as React from "react";export function EditableVsReadOnly() {    const [email, setEmail] = React.useState("ap@cedarline.com");    const [format, setFormat] = React.useState("pdf");    const group = (        <SettingsGroup>            <SettingsRow label="Reply-to address">                <Input                    aria-label="Reply-to address"                    value={email}                    onChange={(event) => setEmail(event.target.value)}                    className="sm:w-44"                />            </SettingsRow>            <SettingsRow label="Advice format">                <SettingsSelect                    label="Advice format"                    value={format}                    onValueChange={setFormat}                    options={[                        { value: "pdf", label: "PDF attachment" },                        { value: "csv", label: "CSV attachment" },                    ]}                    className="sm:w-44"                />            </SettingsRow>        </SettingsGroup>    );    return (        <div className="grid w-full gap-6 md:grid-cols-2">            <div className="flex min-w-0 flex-col gap-2">                <p className="text-13 text-muted-foreground">Editable</p>                {group}            </div>            <div className="flex min-w-0 flex-col gap-2">                <p className="text-13 text-muted-foreground">Read-only</p>                <div data-settings-mode="read">                    <fieldset disabled className="contents">                        {group}                    </fieldset>                </div>            </div>        </div>    );}
States
StateTreatment
RestLabel and description on the left, control on the right, vertically centered from 640px up.
StackedBelow 640px a default row stacks: label, description, then a full-width control.
Inlineinline keeps the control beside the label at every width, for switches and checkboxes.
Top-alignedalign="start" pins the label to the top of a tall control such as a textarea.
First sectionThe first section on a page drops its top hairline and top padding.
Read-onlyInside [data-settings-mode="read"], inputs and selects lose their border, fill and padding and read as plain text, select chevrons and row buttons disappear, and disabled controls stay at full opacity.

Behavior#

  • Sections are 40px apart: py-10 with a hairline on top. first: removes both on the first section, so a page's sections can be plain siblings.
  • Give a section an id and its heading gets <id>-heading, and the section becomes a jump target with an 80px scroll margin for the sticky header. Side tabs and deep links use this.
  • With htmlFor, the row label renders as <label for> with id <htmlFor>-label, and the description gets id descriptionId(htmlFor). Without it both are plain <div>s.
  • Rows stack below 640px unless inline. The control slot is full width when stacked, then shrinks to its content and right-aligns from 640px up.
  • Section actions wrap under the title block when there isn't room beside it.
  • Read-only mode is a CSS rule in apps/web/src/app/globals.css. The settings shell wraps a page in <div data-settings-mode="read">, shows a status note and puts the page in <fieldset disabled className="contents">, which disables native inputs and buttons. Pass disabled to Base UI controls such as Switch too.

Do and don't#

Remittance emails
Approval reminders
Do. Keep labels on the leading side and controls on the trailing side, so every control in a group lines up on one edge.
Remittance emails
Approval reminders
Don't. Put the control straight after the label text. The controls land at a different x on every row and the group reads ragged.
Daily digest
Failed payment alerts
Do. Put related rows in one group card, split by hairlines.
Daily digest
Failed payment alerts
Don't. Give every row its own card. It stacks edges on edges and breaks the section into unrelated pieces.
Do. Keep the row description to one sentence about the effect, and move the explanation of jargon into an InfoTip.
Don't. Write a paragraph of help under the label. The control drifts away from its name and nobody reads it.

Content#

  • Section titles are short nouns in sentence case: Remittance, Approvals, API keys.
  • Section descriptions say what the settings affect, in one sentence: Where remittance advice goes and what it includes.
  • Row labels name the setting, not the action: Remittance email, not Set your remittance email. No colons.
  • Row descriptions state the consequence: Invoices approved after this go in tomorrow's run.
  • Switch labels describe the on state, so the switch reads as yes or no: Email suppliers when paid. Skip Enable and Toggle.
  • Badges are single words in a tag, such as Beta or Admin only. Don't put status in the label text.

Accessibility#

  • Each section is a <section aria-labelledby> pointing at its h2, so it is a named region. Section titles sit under the page's one h1.
  • Set htmlFor to the control's id so the label is a real <label>; clicking it focuses or toggles the control.
  • Wire the description yourself: aria-describedby={descriptionId(id)} on the control. The row only sets the id.
  • Rows without htmlFor render their label as text, so give the control its own name with aria-label.
  • InfoTip is a button named About {title} that opens on click, Enter or Space, or after 350ms of hover.
  • In read-only mode the note above the page is role="status" and the disabled fieldset removes native controls from the tab order.
Keyboard interactions
KeysAction
TabMoves through info tips and controls in row order. Rows themselves are not focusable.
EnterOpens a focused info tip.
EscCloses an open info tip.

Design tokens#

Design tokens
TokenUsed for
--cardGroup surface
shadow-borderGroup edge
--radius-xl12px group corners
--borderHairline above each section and between rows
--foregroundSection titles and row labels
--muted-foregroundDescriptions
text-13Description size
[data-settings-mode="read"]The read-only rule in apps/web globals.css

API reference#

SettingsSection

A titled block of settings. Takes no other props; className lands on the <section>.

Props of SettingsSection
PropTypeDefaultDescription
titleRequiredReact.ReactNodeNo defaultThe h2.
descriptionReact.ReactNodeNo defaultOne sentence under the title. Renders in a <p>.
actionsReact.ReactNodeNo defaultButtons on the trailing side of the header.
infoReact.ReactNodeNo defaultAn InfoTip beside the title.
badgeReact.ReactNodeNo defaultA tag beside the title, such as Beta.
idstringNo defaultThe section's id. The heading becomes <id>-heading. Use it for side tabs and deep links.
childrenReact.ReactNodeNo defaultUsually one SettingsGroup, or a table.
classNamestringNo defaultClasses for the <section>.

SettingsGroup

The card that holds rows, with a hairline between them.

Other props spread onto <div>.

Props of SettingsGroup
PropTypeDefaultDescription
classNamestringNo defaultMerged after the card classes.

SettingsRow

One setting. Takes no other props; className lands on the row.

Props of SettingsRow
PropTypeDefaultDescription
labelRequiredReact.ReactNodeNo defaultThe setting's name.
descriptionReact.ReactNodeNo defaultOne sentence on the effect. Renders in a <div>.
htmlForstringNo defaultThe control's id. Makes the label a <label> and gives the description descriptionId(htmlFor).
infoReact.ReactNodeNo defaultAn InfoTip beside the label.
badgeReact.ReactNodeNo defaultA tag beside the label.
childrenReact.ReactNodeNo defaultThe control, on the trailing side.
inlinebooleanfalseKeeps the control beside the label below 640px. For switches and checkboxes.
align"center" | "start""center"Vertical alignment from 640px up. start for tall controls. Ignored when inline.
classNamestringNo defaultClasses for the row.

descriptionId

descriptionId(id) returns <id>-description, the id a row gives its description.

Props of descriptionId
PropTypeDefaultDescription
idRequiredstringNo defaultThe same id passed to htmlFor.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

SettingsRow sets the description's id but doesn't connect it to the control. Every call site has to add aria-describedby={descriptionId(id)}, and some don't.

The read-only rule lives in apps/web/src/app/globals.css, not in packages/canon, so these components get no read-only styling outside the web app.

Read-only mode hides every button inside a settings row, including harmless ones such as Copy or View logs, but leaves buttons in a section's actions visible (disabled by the fieldset). The settings page hides its page-level actions separately.

In read-only mode switches and checkboxes keep their full-opacity look, so only the status note says they can't be changed.

SettingsSection renders description inside a <p>, so block content there produces invalid HTML. SettingsRow uses a <div>.