Skip to content

Section header

A section title on the left with a quiet action on the right, on one baseline.

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

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

A section header's action is outline, ghost or a Slate Meta link. The filled button belongs to the view's one main action, which is rarely a section's.

Sentence case, no eyebrows

Headings are sentence case with nothing above them: no kicker, no uppercase label, no letter-spaced caption.

Anatomy#

Remittance emails

Sent after every payment run.

Edit template
  1. Container. The outer <div>. It has no layout; pass flex items-end justify-between gap-4 (or items-start, items-baseline) in className.
  2. Title. An h2 (or h3 with as="h3") at 14px, weight 600. Pass id so the section can be aria-labelledby it.
  3. Description. Optional. A <p> in 13px Slate Meta, 2px under the title. One sentence.
  4. Action. Optional. One outline or ghost button, or a 12px Slate Meta link, at the right.

Examples#

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#

States
StateTreatment
Title onlyThe heading and, if needed, an action, aligned on a shared baseline with items-baseline.
With descriptionAlign the action to the description line with items-end, or to the title with items-start when the description wraps.
NarrowPass 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.
  • className goes on the outer <div> and titleClassName on the wrapper around the heading and description, so layout lives on one and truncation on the other.
  • descriptionClassName replaces 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 as text-pretty.
  • The heading's classes are fixed at text-sm font-semibold and can't be overridden from props.

Do and don't#

Suppliers

Do. Give the header its row: flex items-end justify-between gap-4, so the action sits at the right.

Suppliers

Don't. Render it without a layout class. The action falls under the title and the section reads as two stacked blocks.

Webhooks

Deliveries for invoice and payment events.

Do. Keep the action quiet: outline, ghost or a Slate Meta link, with the heading alone at the top.
Developers

Webhooks

Deliveries for invoice and payment events.

Don't. Put an uppercase eyebrow above the heading and a filled button beside it. Both compete with the page's real primary action.

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 id and label the wrapping <section aria-labelledby={id}> with it, so the section is a named region. The agent detail Section wrapper does exactly this with React.useId().
  • Pick the heading level from the page outline: h2 for 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-describedby at the description yourself.
  • An icon-only action needs an aria-label and a tooltip, like any icon button.

Design tokens#

Design tokens
TokenUsed for
--foregroundHeading text, inherited
--muted-foregroundDescription and link actions
text-sm14px heading at weight 600
text-1313px description

API reference#

SectionHeader

A heading, an optional description and an optional action. Takes only the props below; nothing else is spread.

Props of SectionHeader
PropTypeDefaultDescription
titleRequiredReact.ReactNodeNo defaultThe heading text.
descriptionReact.ReactNodeNo defaultOne line under the heading, rendered in a <p>, so pass inline content only.
actionReact.ReactNodeNo defaultRendered after the title block, inside the container.
idstringNo defaultSet on the heading, for aria-labelledby.
as"h2" | "h3""h2"Heading level. The visual size doesn't change.
classNamestringNo defaultClasses for the outer <div>. Pass the flex row here.
titleClassNamestringNo defaultClasses for the wrapper around the heading and description, usually min-w-0.
descriptionClassNamestring"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.