f-ui
Design

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
Email
mei.chen@example.com

Fulfillment

Carrier
SF Express
Ship by
2026-03-08

Right

Order details

Contact

Owner
Mei Chen
Email
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.

ShellUse for
SectionCardAny page-body section — title, description, count, tools, actions
FormPanel variant="card"A section whose body is a Form
RelatedListA 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.

RoleStyleTag
Page titletext-xl font-semibold tracking-tighth1PageHeader only
Section card titletext-base font-semibold leading-6 text-foregroundh2
In-card group headingtext-sm font-medium text-muted-foregroundh3
Body / valuetext-sm
Descriptions labeltext-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

SurfaceToken
Page content insetp-6 (24px)
Page child → child (both axes)gap-4 (16px)
Section card paddingp-6 (24px)
Tile padding (Statistic)p-4 (16px)
Card title → body, dense contentgap-4 (16px)
Card title → body, Form contentvar(--fui-form-field-gap) — 24 comfortable / 16 compact
Group heading → that group's fieldsgap-3 (12px)
Group → Separator → groupgap-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

RegionWrap?Title
Attachments, Comments, Line items, Timeline, Related ListYes — each owns its own async / CRUD boundaryCard title
Two related upload buckets that are one job (invoices + orders as proofs)One cardCard 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 listsTwo peer cardsEach card's title. No parent heading on the canvas.
Short, strongly related read groups (Contact + Fulfillment + Payment)One shared cardNo card title; muted group headings + Separator
Long text (Notes, Markdown, Rich Text display)Its own cardCard title
Lifecycle StepperOwn card, or share with the summaryCard title if it has its own shell
SettingsUnder 7 fields: one card. 7–15: one card per group. Over 15: TabsCard 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.

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.pdfUploaded

Orders

po-2401.pdfUploaded

Right

Supporting documents

Invoices

invoice-2401.pdfUploaded

Orders

po-2401.pdfUploaded
"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>
  );
}

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 ChenBuyer

Opportunities

Q1 renewalUSD 12,400

Right

Contacts

Mei ChenBuyer

Opportunities

Q1 renewalUSD 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 / h3 rendered as a sibling above a card — the shell's title owns it.
  • Exactly one semibold 16px title per card; group headings muted at 14px.
  • No RelatedList / FormPanel / SectionCard nested inside another card.
  • Section shells are p-6; p-4 is only for Statistic tiles. Do not pass className="p-6" on the shell.
  • No mb-* on a PageContainer flex child; no gap-6 wrapper around FormPanel siblings — Form already stacks them.
  • No gap-* override on a section shell; group rhythm goes on bodyClassName.
  • 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

On this page