Section header
A section title on the left with a quiet action on the right, on one baseline.
Test scenarios
Runs before every publish of Where is my payment.
- Greet and verify the supplierPassed
- Look up the invoicePassed
- Read the payment statusPassed
import { Button } from "@oration/canon/components/button";import { SectionHeader } from "@oration/canon/components/section-header";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import { PlusIcon } from "lucide-react";import * as React from "react";export function Hero() { const id = React.useId(); const steps = [ { name: "Greet and verify the supplier", status: "Passed" }, { name: "Look up the invoice", status: "Passed" }, { name: "Read the payment status", status: "Passed" }, ]; return ( <section aria-labelledby={id} className="flex w-full max-w-lg flex-col gap-3" > <SectionHeader id={id} title="Test scenarios" description="Runs before every publish of Where is my payment." action={ <Button type="button" variant="outline" size="sm" onClick={() => toast.add({ title: "New scenario", description: "Opens the scenario editor.", }) } > <PlusIcon data-icon="inline-start" aria-hidden="true" /> Add scenario </Button> } className="flex items-end justify-between gap-4" titleClassName="min-w-0" /> <ul className="flex flex-col rounded-xl bg-card px-2 py-1 shadow-border"> {steps.map((step) => ( <li key={step.name} className="flex items-center justify-between gap-3 border-b border-border px-2 py-2.5 text-13 last:border-b-0" > <span className="text-foreground">{step.name}</span> <StatusLabel tone="success" className="text-xs"> {step.status} </StatusLabel> </li> ))} </ul> </section> );}Usage#
Section header titles a block of a page or a card: a 14px semibold heading, an optional one-line description, and one quiet action on the right. It is how agent detail sections, sequence steps and report panels open. It ships with no layout of its own, so every call site passes the flex row in className; forgetting it is the common mistake, and the action drops under the title.
When to use
- To open a section of a document page: Test scenarios, Payment runs, Caller ID lookup.
- To title a card or report panel, with a one-line takeaway as the description.
- When the section has one action that belongs to it: Add scenario, Export CSV, or a destination link such as All disputes.
- For a sub-section inside a titled section, with
as="h3".
When not to use
- For the page's own title and description. Use Page title
- For a settings group with labelled rows under it. Use Settings section
- For a block that shows and hides behind its heading. Use Collapsible
- For the title of a dialog or sheet, which has its own title and description parts. Use Dialog
The One Filled Button Rule
Sentence case, no eyebrows
Anatomy#
Remittance emails
Sent after every payment run.
- Container. The outer
<div>. It has no layout; passflex items-end justify-between gap-4(oritems-start,items-baseline) inclassName. - Title. An
h2(orh3withas="h3") at 14px, weight 600. Passidso the section can bearia-labelledbyit. - Description. Optional. A
<p>in 13px Slate Meta, 2px under the title. One sentence. - Action. Optional. One outline or ghost button, or a 12px Slate Meta link, at the right.
Examples#
Title and link
The card header pattern: a 14px Title and a 12px Slate Meta link that names its destination, on one baseline with items-baseline.
Open disputes
Four suppliers are waiting on a reply, the oldest from Sep 21.
import { SectionHeader } from "@oration/canon/components/section-header";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function TitleAndLink() { const id = React.useId(); return ( <section aria-labelledby={id} className="w-full max-w-md rounded-xl bg-card p-4 shadow-border" > <SectionHeader id={id} title="Open disputes" action={ <button type="button" onClick={() => toast.add({ title: "All disputes", description: "Opens Ticketing filtered to disputes.", }) } className="rounded-sm text-xs text-muted-foreground hover:text-foreground" > All disputes </button> } className="flex items-baseline justify-between gap-4" /> <p className="mt-2 text-13 text-muted-foreground"> Four suppliers are waiting on a reply, the oldest from Sep 21. </p> </section> );}With a description and action
A one-line description under the title and an outline button aligned to it with items-end.
Payment runs
Runs approved in the last 30 days. Amounts are in USD.
import { Button } from "@oration/canon/components/button";import { SectionHeader } from "@oration/canon/components/section-header";import { toast } from "@oration/canon/components/toast";import { DownloadIcon } from "lucide-react";import * as React from "react";export function WithDescription() { const id = React.useId(); return ( <section aria-labelledby={id} className="w-full max-w-lg"> <SectionHeader id={id} title="Payment runs" description="Runs approved in the last 30 days. Amounts are in USD." action={ <Button type="button" variant="outline" size="sm" onClick={() => toast.add({ title: "Payment runs exported as CSV" }) } > <DownloadIcon data-icon="inline-start" aria-hidden="true" /> Export CSV </Button> } className="flex items-end justify-between gap-4" titleClassName="min-w-0" /> </section> );}Heading levels
An h2 for the page section and as="h3" for the one inside it. The size stays the same; the outline changes.
Caller ID lookup
Match inbound numbers to suppliers before the agent answers.
When the lookup fails
The agent greets the caller without a name.
import { SectionHeader } from "@oration/canon/components/section-header";import * as React from "react";export function Levels() { const pageId = React.useId(); const subId = React.useId(); return ( <section aria-labelledby={pageId} className="flex w-full max-w-lg flex-col gap-4" > <SectionHeader id={pageId} title="Caller ID lookup" description="Match inbound numbers to suppliers before the agent answers." className="flex items-end justify-between gap-4" /> <section aria-labelledby={subId} className="rounded-xl bg-card p-4 shadow-border" > <SectionHeader as="h3" id={subId} title="When the lookup fails" description="The agent greets the caller without a name." className="flex items-start justify-between gap-4" titleClassName="min-w-0" /> </section> </section> );}Report panel
The reports pattern: the takeaway is the description, with text-pretty added through descriptionClassName and the link aligned to the title with items-start.
Days to pay
Down to 23 days from 31 in June, mostly from ACH moving to same-day.
- September
- 23 days
- August
- 26 days
- June
- 31 days
import { InlineStatStrip } from "@oration/canon/components/inline-stat-strip";import { SectionHeader } from "@oration/canon/components/section-header";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function ReportPanel() { const id = React.useId(); return ( <section aria-labelledby={id} className="w-full max-w-lg min-w-0 rounded-xl bg-card p-4 shadow-border" > <SectionHeader id={id} title="Days to pay" description="Down to 23 days from 31 in June, mostly from ACH moving to same-day." action={ <button type="button" onClick={() => toast.add({ title: "Days to pay", description: "Opens the full report.", }) } className="shrink-0 rounded-sm text-xs text-muted-foreground hover:text-foreground" > Open report </button> } className="flex items-start justify-between gap-4" titleClassName="min-w-0" descriptionClassName="mt-0.5 text-pretty text-13 text-muted-foreground" /> <div className="mt-4"> <InlineStatStrip stats={[ { label: "September", value: "23 days" }, { label: "August", value: "26 days" }, { label: "June", value: "31 days" }, ]} /> </div> </section> );}States#
| State | Treatment |
|---|---|
| Title only | The heading and, if needed, an action, aligned on a shared baseline with items-baseline. |
| With description | Align the action to the description line with items-end, or to the title with items-start when the description wraps. |
| Narrow | Pass titleClassName="min-w-0" so a long description wraps instead of pushing the action out of the row. |
Behavior#
- Section header is static: it renders a heading, a paragraph and whatever you pass as
action. It holds no state. classNamegoes on the outer<div>andtitleClassNameon the wrapper around the heading and description, so layout lives on one and truncation on the other.descriptionClassNamereplaces the default (mt-0.5 text-13 text-muted-foreground) rather than merging with it. Repeat those classes when you only want to add one, such astext-pretty.- The heading's classes are fixed at
text-sm font-semiboldand can't be overridden from props.
Do and don't#
Suppliers
flex items-end justify-between gap-4, so the action sits at the right.Suppliers
Webhooks
Deliveries for invoice and payment events.
Webhooks
Deliveries for invoice and payment events.
Content#
- Titles name the contents in sentence case, one to four words: Payment runs, When the lookup fails.
- Descriptions are one sentence that says what the section is for or what changed: Down to 23 days from 31 in June.
- Action labels are verb first (Add scenario, Export CSV) or name the destination (All disputes, Open report).
- Don't repeat the title in the action: Add under Scenarios is enough context only when nothing else on the page adds.
Accessibility#
- Pass
idand label the wrapping<section aria-labelledby={id}>with it, so the section is a named region. The agent detailSectionwrapper does exactly this withReact.useId(). - Pick the heading level from the page outline:
h2for sections of a page,as="h3"for sections inside them. Don't skip levels to get a smaller look; the size is the same at both levels. - The description is not linked to the heading. If a control in the section needs it, point that control's
aria-describedbyat the description yourself. - An icon-only action needs an
aria-labeland a tooltip, like any icon button.
Design tokens#
| Token | Used for |
|---|---|
--foreground | Heading text, inherited |
--muted-foreground | Description and link actions |
text-sm | 14px heading at weight 600 |
text-13 | 13px description |
API reference#
SectionHeader
A heading, an optional description and an optional action. Takes only the props below; nothing else is spread.
| Prop | Type | Default | Description |
|---|---|---|---|
titleRequired | React.ReactNode | No default | The heading text. |
description | React.ReactNode | No default | One line under the heading, rendered in a <p>, so pass inline content only. |
action | React.ReactNode | No default | Rendered after the title block, inside the container. |
id | string | No default | Set on the heading, for aria-labelledby. |
as | "h2" | "h3" | "h2" | Heading level. The visual size doesn't change. |
className | string | No default | Classes for the outer <div>. Pass the flex row here. |
titleClassName | string | No default | Classes for the wrapper around the heading and description, usually min-w-0. |
descriptionClassName | string | "mt-0.5 text-13 text-muted-foreground" | Replaces the description's classes. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The container has no default layout. All three product call sites (agent detail sections, sequence steps, report panels) pass their own flex … justify-between gap-* with different items-* values, where DESIGN.md asks for a title and link on a shared baseline.
The heading doesn't set text-foreground, so it inherits whatever color its parent has.
The heading's classes can't be changed; there is no headingClassName.
Home rail cards and most panels don't use Section header at all; they hand-roll flex items-baseline justify-between with an h2 at text-sm font-semibold.