Page Sections
One rule set for every page body — when to wrap a region in a card, where its title lives, and how much air separates it from everything else.
Three questions get asked on every page and answered inconsistently: does this region get a card, where does its title go, and how much space sits around it? This page answers all three at once, because they are one decision — not three.
Object Page specifics (Descriptions layout, Related List body padding, toolbar control height) live in Object Page Cards. This page is the cross-page rule.
The Rule
A container and its title are one decision.
- Wrapped in a card ⇒ the title is the shell's
title. - Not wrapped ⇒ no section title at all.
There is no third option. A heading floating above an untitled card is the single most common defect, and the fix is never "style the outside heading better" — it is to move the heading into the shell that already exists.
Title Outside The Card
Left: the name sits on the canvas and a muted group heading does the card's job, on p-4 tile padding. Right: SectionCard owns Order details; Contact and Fulfillment stay muted groups.
Wrong
Order details
Contact
- Owner
- Mei Chen
- mei.chen@example.com
Fulfillment
- Carrier
- SF Express
- Ship by
- 2026-03-08
Right
Order details
Contact
- Owner
- Mei Chen
- mei.chen@example.com
Fulfillment
- Carrier
- SF Express
- Ship by
- 2026-03-08
"use client";
import {
SectionCard,
SectionGroupHeading,
} from "@/components/f-ui/section-card/section-card";
import { Separator } from "@/components/ui/separator";
import { DesignCompare } from "@/demos/_design/design-compare";
function ContactFields() {
return (
<dl className="grid grid-cols-[8rem_1fr] gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Owner</dt>
<dd>Mei Chen</dd>
<dt className="text-muted-foreground">Email</dt>
<dd>mei.chen@example.com</dd>
</dl>
);
}
function FulfillmentFields() {
return (
<dl className="grid grid-cols-[8rem_1fr] gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Carrier</dt>
<dd>SF Express</dd>
<dt className="text-muted-foreground">Ship by</dt>
<dd>2026-03-08</dd>
</dl>
);
}
/** Title outside the card, plus a muted group heading doing the card's job. */
export function PageSectionTitleDemo() {
return (
<DesignCompare
wrong={
<div className="flex flex-col gap-4">
<h2 className="text-muted-foreground text-sm font-medium">
Order details
</h2>
<div className="bg-card flex flex-col gap-2 rounded-xl border p-4">
<h3 className="text-muted-foreground text-sm font-medium">
Contact
</h3>
<ContactFields />
<h3 className="text-muted-foreground text-sm font-medium">
Fulfillment
</h3>
<FulfillmentFields />
</div>
</div>
}
right={
<SectionCard title="Order details">
<div className="flex flex-col gap-3">
<SectionGroupHeading>Contact</SectionGroupHeading>
<ContactFields />
</div>
<Separator />
<div className="flex flex-col gap-3">
<SectionGroupHeading>Fulfillment</SectionGroupHeading>
<FulfillmentFields />
</div>
</SectionCard>
}
/>
);
}Sections Own Their Titles
Every kit shell takes a title. If you are writing an h2 next to a card, you are working around the component.
| Shell | Use for |
|---|---|
SectionCard | Any page-body section — title, description, count, tools, actions |
FormPanel variant="card" | A section whose body is a Form |
RelatedList | A section whose body is a loose child collection, with Loading / Empty / Error built in |
<SectionCard
title="Line items"
tools={<Button variant="outline">Columns</Button>}
actions={<Button>Add line</Button>}
>
<LineItemsTable />
</SectionCard>Do not render RelatedList, FormPanel variant="card", or SectionCard inside another card. They are the card.
Type Ladder
The problem is three tiers landing on the same 14px, separated only by one weight step. Give the card title the empty 16px slot and every tier changes on at least one primary axis.
| Role | Style | Tag |
|---|---|---|
| Page title | text-xl font-semibold tracking-tight | h1 — PageHeader only |
| Section card title | text-base font-semibold leading-6 text-foreground | h2 |
| In-card group heading | text-sm font-medium text-muted-foreground | h3 |
| Body / value | text-sm | — |
| Descriptions label | text-xs | — |
Authority: Ant Card headerFontSize: 16 on a 14px body, SAP Fiori Header 5 = 16px, Cloudscape container title above body size. Fiori's 14px is its smallest headline and Cloudscape's 14px bold is "sections within a paragraph" — both are the group heading slot, never the card slot.
One semibold title per card. Group names stay muted. Comment and Timeline item titles are content, not section titles.
Heading level is automatic. A titled SectionCard renders its SectionGroupHeading children as h3. An untitled card — peer groups sharing one shell — renders them as h2, so h1 never skips to h3. You do not pass a level.
Copy is sentence case (Line items, not Line Items or LINE ITEMS) — Sentence Case.
Rhythm
| Surface | Token |
|---|---|
| Page content inset | p-6 (24px) |
| Page child → child (both axes) | gap-4 (16px) |
| Section card padding | p-6 (24px) |
Tile padding (Statistic) | p-4 (16px) |
| Card title → body, dense content | gap-4 (16px) |
| Card title → body, Form content | var(--fui-form-field-gap) — 24 comfortable / 16 compact |
| Group heading → that group's fields | gap-3 (12px) |
Group → Separator → group | gap-4 each side |
Authority: Ant bodyPadding: 24 / paddingLG: 24, Ant 8px grid, Ant Grid gutter (16 + 8n)px.
A tile is not a section. p-4 belongs to Statistic cards and similar small widgets. Using it for a page section shrinks the section to tile scale.
Form title air is inherited, not fixed. FormPanel reads --fui-form-field-gap from the enclosing Form, so the title sits exactly one field-gap above the fields. It can never end up tighter than the fields it introduces — which is what a hard-coded 20px did.
Do not add mb-* to a card that is already a flex child of PageContainer; the page stack owns that space. Do not override gap-* on a section shell — put group rhythm on bodyClassName instead. Do not wrap FormPanel siblings in flex flex-col gap-6 or pass className="p-6" — Form already stacks sections at the field gap, and the shell is already p-6.
When To Wrap
| Region | Wrap? | Title |
|---|---|---|
| Attachments, Comments, Line items, Timeline, Related List | Yes — each owns its own async / CRUD boundary | Card title |
| Two related upload buckets that are one job (invoices + orders as proofs) | One card | Card title names the job (Supporting documents). Buckets are muted SectionGroupHeading in a gap-4 two-column grid. Do not nest two cards, and do not put a sibling heading above the grid. |
| Two independent related lists | Two peer cards | Each card's title. No parent heading on the canvas. |
| Short, strongly related read groups (Contact + Fulfillment + Payment) | One shared card | No card title; muted group headings + Separator |
| Long text (Notes, Markdown, Rich Text display) | Its own card | Card title |
| Lifecycle Stepper | Own card, or share with the summary | Card title if it has its own shell |
| Settings | Under 7 fields: one card. 7–15: one card per group. Over 15: Tabs | Card title per group |
Feed containers wrap the whole track: one card around the entire Timeline or Comment thread (composer included), never one card per item.
Two Related Upload Buckets
Invoices and orders for the same attachments are one job. Side-by-side columns are fine; a sibling heading above two nested cards is not. Move Supporting documents into the shell. Invoices / Orders become muted SectionGroupHeadings. Do not nest SectionCard inside SectionCard.
Wrong
Supporting documents
Invoices
| invoice-2401.pdf | Uploaded |
Orders
| po-2401.pdf | Uploaded |
Right
Supporting documents
Invoices
| invoice-2401.pdf | Uploaded |
Orders
| po-2401.pdf | Uploaded |
"use client";
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { f } from "@/components/f-ui/field-types/catalog";
import {
SectionCard,
SectionGroupHeading,
} from "@/components/f-ui/section-card/section-card";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
type FileRow = {
id: string;
name: string;
status: "uploaded";
};
const INVOICES: FileRow[] = [
{ id: "inv-1", name: "invoice-2401.pdf", status: "uploaded" },
];
const ORDERS: FileRow[] = [
{ id: "ord-1", name: "po-2401.pdf", status: "uploaded" },
];
const fileSchema = defineDataListSchema<FileRow>({
name: f.text({ label: "File" }),
status: f.enum({
label: "Status",
render: "status",
variants: {
uploaded: { label: "Uploaded", tone: "success" },
},
}),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function FileTable({
rows,
listCode,
}: {
rows: readonly FileRow[];
listCode: string;
}) {
const dataList = useDataList({
data: rows,
schema: fileSchema,
listCode,
getRowId: (row) => row.id,
defaultPageSize: "all",
});
return (
<DataListProvider dataList={dataList}>
<Table dataList={dataList} />
</DataListProvider>
);
}
function UploadButton() {
return (
<Button type="button">Upload</Button>
);
}
function PageCanvas({ children }: { children: ReactNode }) {
return (
<div className="bg-muted/40 flex flex-col gap-4 rounded-lg p-4">
{children}
</div>
);
}
function WrongSketch() {
return (
<PageCanvas>
<h2 className="text-foreground text-base leading-6 font-semibold">
Supporting documents
</h2>
<div className="grid grid-cols-2 gap-4">
<SectionCard title="Invoices" actions={<UploadButton />}>
<FileTable rows={INVOICES} listCode="page-sections-uploads-wrong-invoices" />
</SectionCard>
<SectionCard title="Orders" actions={<UploadButton />}>
<FileTable rows={ORDERS} listCode="page-sections-uploads-wrong-orders" />
</SectionCard>
</div>
</PageCanvas>
);
}
function Bucket({
heading,
listCode,
rows,
}: {
heading: string;
listCode: string;
rows: readonly FileRow[];
}) {
return (
<div className="flex min-w-0 flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<SectionGroupHeading>{heading}</SectionGroupHeading>
<UploadButton />
</div>
<FileTable rows={rows} listCode={listCode} />
</div>
);
}
function RightSketch() {
return (
<PageCanvas>
<SectionCard title="Supporting documents">
<div className="grid grid-cols-2 gap-4">
<Bucket
heading="Invoices"
rows={INVOICES}
listCode="page-sections-uploads-right-invoices"
/>
<Bucket
heading="Orders"
rows={ORDERS}
listCode="page-sections-uploads-right-orders"
/>
</div>
</SectionCard>
</PageCanvas>
);
}
/** Sibling heading + two nested cards vs one titled card with muted groups. */
export function PageSectionRelatedUploadsDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare
layout="stack"
wrong={<WrongSketch />}
right={<RightSketch />}
/>
</QueryClientProvider>
);
}Two Independent Related Lists
Contacts and Opportunities are separate collections. Each card owns its title. There is no parent heading on the canvas — that heading has nowhere to live.
Wrong
Related
Contacts
| Mei Chen | Buyer |
Opportunities
| Q1 renewal | USD 12,400 |
Right
Contacts
| Mei Chen | Buyer |
Opportunities
| Q1 renewal | USD 12,400 |
"use client";
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { f } from "@/components/f-ui/field-types/catalog";
import { SectionCard } from "@/components/f-ui/section-card/section-card";
import { Table } from "@/components/f-ui/table/table";
import { DesignCompare } from "@/demos/_design/design-compare";
type ContactRow = {
id: string;
name: string;
role: string;
};
type OpportunityRow = {
id: string;
name: string;
amount: string;
};
const CONTACTS: ContactRow[] = [
{ id: "c1", name: "Mei Chen", role: "Buyer" },
];
const OPPORTUNITIES: OpportunityRow[] = [
{ id: "o1", name: "Q1 renewal", amount: "USD 12,400" },
];
const contactSchema = defineDataListSchema<ContactRow>({
name: f.text({ label: "Name" }),
role: f.text({ label: "Role" }),
});
const opportunitySchema = defineDataListSchema<OpportunityRow>({
name: f.text({ label: "Name" }),
amount: f.text({ label: "Amount" }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function ContactsTable({ listCode }: { listCode: string }) {
const dataList = useDataList({
data: CONTACTS,
schema: contactSchema,
listCode,
getRowId: (row) => row.id,
defaultPageSize: "all",
});
return (
<DataListProvider dataList={dataList}>
<Table dataList={dataList} />
</DataListProvider>
);
}
function OpportunitiesTable({ listCode }: { listCode: string }) {
const dataList = useDataList({
data: OPPORTUNITIES,
schema: opportunitySchema,
listCode,
getRowId: (row) => row.id,
defaultPageSize: "all",
});
return (
<DataListProvider dataList={dataList}>
<Table dataList={dataList} />
</DataListProvider>
);
}
function PageCanvas({ children }: { children: ReactNode }) {
return (
<div className="bg-muted/40 flex flex-col gap-4 rounded-lg p-4">
{children}
</div>
);
}
function PeerCards({ prefix }: { prefix: string }) {
return (
<div className="grid grid-cols-2 gap-4">
<SectionCard title="Contacts">
<ContactsTable listCode={`${prefix}-contacts`} />
</SectionCard>
<SectionCard title="Opportunities">
<OpportunitiesTable listCode={`${prefix}-opportunities`} />
</SectionCard>
</div>
);
}
function WrongSketch() {
return (
<PageCanvas>
<h2 className="text-foreground text-base leading-6 font-semibold">
Related
</h2>
<PeerCards prefix="page-sections-peer-wrong" />
</PageCanvas>
);
}
function RightSketch() {
return (
<PageCanvas>
<PeerCards prefix="page-sections-peer-right" />
</PageCanvas>
);
}
/** Independent related lists are peer cards — no parent heading on the canvas. */
export function PageSectionPeerRelatedListsDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare
layout="stack"
wrong={<WrongSketch />}
right={<RightSketch />}
/>
</QueryClientProvider>
);
}Not Sections
These are never body sections and never get wrapped in a topic card:
PageHeader · page-level Tabs · FooterToolbar · form error summaries and table rollups · Modal / Drawer / Sheet bodies (compact Form rhythm, not p-6) · message scrollers · resource-index tables, where the table is the page · page-level Alert and Result, which are direct PageContainer children.
Region Empty and Result render inside the existing region card and reuse its title — Page And Region Status. Do not give them a card of their own.
Checklist
- No
h2/h3rendered as a sibling above a card — the shell'stitleowns it. - Exactly one semibold 16px title per card; group headings muted at 14px.
- No
RelatedList/FormPanel/SectionCardnested inside another card. - Section shells are
p-6;p-4is only forStatistictiles. Do not passclassName="p-6"on the shell. - No
mb-*on aPageContainerflex child; nogap-6wrapper aroundFormPanelsiblings —Formalready stacks them. - No
gap-*override on a section shell; group rhythm goes onbodyClassName. - Regions with their own async boundary are separate cards.
- Two related upload buckets that are one job share one card; Invoices / Orders stay muted group headings, not nested cards.
- Region Empty / Error stay inside the region card.
See Also
- Object Page Cards — Descriptions layout, Related List body padding, region toolbar control height
- CRUD Page Patterns — Body Card Granularity, Action Placement
- Form Layout
- Page And Region Status
- Sentence Case