Settings section
The rhythm of every settings page: sections, groups and label-left rows.
Remittance
Where remittance advice goes after each payment run and what it includes.
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
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
The Thirteen-Fourteen Rule
The One Filled Button Rule
Anatomy#
Approvals
Who signs off before a payment run goes out.
- Section title. An
h2at 14px semibold that names the section. The section is labelled by it. - Info tip. Optional
InfoTipbeside the title or a row label, for jargon and deeper help. - Section description. One 13px muted sentence on what these settings affect, up to 36rem wide.
- Section actions. Optional buttons on the trailing side, bottom-aligned with the description. They wrap below on narrow screens.
- Group. A Card White surface with 12px corners and
shadow-border, with a hairline between rows. - Row label and description. 14px medium label (a real
<label>whenhtmlForis set) and a 13px muted description up to 28rem wide. - 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.
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
BetaHow invoices that arrive by email are read and matched.
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.
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.
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.
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
Read-only
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> );}| State | Treatment |
|---|---|
| Rest | Label and description on the left, control on the right, vertically centered from 640px up. |
| Stacked | Below 640px a default row stacks: label, description, then a full-width control. |
| Inline | inline keeps the control beside the label at every width, for switches and checkboxes. |
| Top-aligned | align="start" pins the label to the top of a tall control such as a textarea. |
| First section | The first section on a page drops its top hairline and top padding. |
| Read-only | Inside [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-10with a hairline on top.first:removes both on the first section, so a page's sections can be plain siblings. - Give a section an
idand 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 iddescriptionId(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. Passdisabledto Base UI controls such as Switch too.
Do and don't#
InfoTip.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 itsh2, so it is a named region. Section titles sit under the page's oneh1. - Set
htmlForto 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
htmlForrender their label as text, so give the control its own name witharia-label. InfoTipis 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.
| Keys | Action |
|---|---|
| Tab | Moves through info tips and controls in row order. Rows themselves are not focusable. |
| Enter | Opens a focused info tip. |
| Esc | Closes an open info tip. |
Design tokens#
| Token | Used for |
|---|---|
--card | Group surface |
shadow-border | Group edge |
--radius-xl | 12px group corners |
--border | Hairline above each section and between rows |
--foreground | Section titles and row labels |
--muted-foreground | Descriptions |
text-13 | Description 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>.
| Prop | Type | Default | Description |
|---|---|---|---|
titleRequired | React.ReactNode | No default | The h2. |
description | React.ReactNode | No default | One sentence under the title. Renders in a <p>. |
actions | React.ReactNode | No default | Buttons on the trailing side of the header. |
info | React.ReactNode | No default | An InfoTip beside the title. |
badge | React.ReactNode | No default | A tag beside the title, such as Beta. |
id | string | No default | The section's id. The heading becomes <id>-heading. Use it for side tabs and deep links. |
children | React.ReactNode | No default | Usually one SettingsGroup, or a table. |
className | string | No default | Classes for the <section>. |
SettingsGroup
The card that holds rows, with a hairline between them.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged after the card classes. |
SettingsRow
One setting. Takes no other props; className lands on the row.
| Prop | Type | Default | Description |
|---|---|---|---|
labelRequired | React.ReactNode | No default | The setting's name. |
description | React.ReactNode | No default | One sentence on the effect. Renders in a <div>. |
htmlFor | string | No default | The control's id. Makes the label a <label> and gives the description descriptionId(htmlFor). |
info | React.ReactNode | No default | An InfoTip beside the label. |
badge | React.ReactNode | No default | A tag beside the label. |
children | React.ReactNode | No default | The control, on the trailing side. |
inline | boolean | false | Keeps 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. |
className | string | No default | Classes for the row. |
descriptionId
descriptionId(id) returns <id>-description, the id a row gives its description.
| Prop | Type | Default | Description |
|---|---|---|---|
idRequired | string | No default | The 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>.