Page
Ant-style page frame with a pinned header, scroll body, and in-flow footer row for docked actions.
Plus Registry
Page ships on the authenticated @f-ui-plus registry. Configure FUI_PLUS_REGISTRY_TOKEN as described in Installation — Plus Registry.
Page is the Ant Design Pro PageContainer frame: a pinned Page Header, a scroll body, and an in-flow footer row. Recipes such as Query List and Form Page teleport docked actions into that footer with PageFooter. Which page type to pick (list vs detail vs create vs edit) lives on CRUD Page Patterns. Body card rhythm lives on Page Sections.
When To Use
- Every list, Object Page, and form route should sit in this frame — not a hand-rolled
flexcolumn with a sticky header. - Put resource-index Create (and list-wide Import/Export) in
extra. Find/view chrome stays on the list toolbar — see CRUD Page Patterns — Action Placement. - Page-channel slices (Open / Shipped) use header
tabs. Dataset pill tabs belong on Data List Chrome toolbar tabs. - Dock submit / batch / primary detail actions with
PageFooter+FooterToolbar. Use thefooterprop only for a persistent non-docked band. - Do not wrap Section Card or Related List in extra page padding — the scroll body already applies
p-6andgap-4.
Features
| Area | Behavior |
|---|---|
| Frame | Three-row flex: header (shrink-0) | scroll body (flex-1) | footer row (shrink-0) |
| Surface | Default muted lifts cards on bg-muted/40. Header and footer stay bg-background |
| Header | Breadcrumb, back, title (h1), tags, subtitle, extra, summary content / extraContent, underline tabs |
| Back | backHref renders a Link; onBack is a button (or a click side effect with backHref) |
| Footer | footer for a persistent band; <PageFooter> teleports recipe bars into the same row |
| Body | p-6 + gap-4. Host layouts should not add another page pad around the container |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/page.jsonnpx shadcn@latest add https://ui.isaacfei.com/api/plus/r/page.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/page.jsonbunx shadcn@latest add https://ui.isaacfei.com/api/plus/r/page.jsonWith the @f-ui-plus namespace configured in components.json, shadcn add @f-ui-plus/page also works.
Runtime npm dep: lucide-react. registryDependencies: shadcn utils, https://ui.isaacfei.com/r/link.json.
Usage
import { PageContainer } from "@/components/f-ui/page/page-container";
<PageContainer title="Orders" extra={<Button type="button">Create</Button>}>
{/* list or detail sections */}
</PageContainer>Examples
Resource Index Frame
Pinned header, muted body, and Create in extra. The section card is a body child — not a second page shell.
Orders
Open purchase orders for this workspace
Recent orders
List, detail, and form pages all share this header | body | footer frame.
"use client";
import { PlusIcon } from "lucide-react";
import { PageContainer } from "@/components/f-ui/page/page-container";
import { SectionCard } from "@/components/f-ui/section-card/section-card";
import { Button } from "@/components/ui/button";
/** Default page frame: pinned header, muted scroll body, Create in `extra`. */
export function PageDemo() {
return (
<PageContainer
className="h-88 rounded-xl border"
title="Orders"
subTitle="Open purchase orders for this workspace"
extra={
<Button type="button">
<PlusIcon className="size-4" />
Create
</Button>
}
>
<SectionCard title="Recent orders">
<p className="text-sm text-muted-foreground">
List, detail, and form pages all share this header | body | footer frame.
</p>
</SectionCard>
</PageContainer>
);
}Page Channel Tabs
Underline tabs on the header switch page channels. Do not put dataset segments (All / Unshipped) here — those are Data List Toolbar Tabs.
Orders
Open orders
Switch the header tabs. Dataset segments belong on Data List Toolbar Tabs, not here.
"use client";
import { useState } from "react";
import { PageContainer } from "@/components/f-ui/page/page-container";
import { SectionCard } from "@/components/f-ui/section-card/section-card";
const TABS = [
{ key: "open", label: "Open" },
{ key: "shipped", label: "Shipped" },
{ key: "closed", label: "Closed" },
];
/** Page-channel underline tabs on the header — not dataset pill tabs on the list toolbar. */
export function PageTabsDemo() {
const [activeKey, setActiveKey] = useState("open");
return (
<PageContainer
className="h-80 rounded-xl border"
title="Orders"
tabs={{ items: TABS, activeKey, onChange: setActiveKey }}
>
<SectionCard title={`${TABS.find((tab) => tab.key === activeKey)?.label} orders`}>
<p className="text-sm text-muted-foreground">
Switch the header tabs. Dataset segments belong on Data List Toolbar Tabs, not here.
</p>
</SectionCard>
</PageContainer>
);
}Header Summary And Kpis
content holds a compact Descriptions grid. extraContent holds Statistic KPIs — never hero type on Descriptions values. See CRUD Page Patterns — Descriptions Layout And Header KPIs.
ORD-1001
Acme Corp · 2026-08-01
- Customer:
- Acme Corp
- Owner:
- Ada Lovelace
Fulfillment
Header KPIs are Statistic. Do not scale Descriptions values to hero type.
"use client";
import { Descriptions } from "@/components/f-ui/descriptions/descriptions";
import { PageContainer } from "@/components/f-ui/page/page-container";
import { SectionCard } from "@/components/f-ui/section-card/section-card";
import { Statistic } from "@/components/f-ui/statistic/statistic";
import { StatusTag } from "@/components/f-ui/status-tag";
import { Button } from "@/components/ui/button";
const RECORD = {
orderNumber: "ORD-1001",
customer: "Acme Corp",
owner: "Ada Lovelace",
amount: 1280,
};
const HEADER_FIELDS = [
{ key: "customer", label: "Customer", kind: "text" as const },
{ key: "owner", label: "Owner", kind: "text" as const },
];
/** Header summary: Descriptions in `content`, Statistic KPIs in `extraContent`. */
export function PageHeaderSummaryDemo() {
return (
<PageContainer
className="h-96 rounded-xl border"
breadcrumb={
<nav aria-label="breadcrumb" className="text-sm text-muted-foreground">
Orders / ORD-1001
</nav>
}
title={RECORD.orderNumber}
tags={
<StatusTag tone="success" icon="preset">
Confirmed
</StatusTag>
}
subTitle="Acme Corp · 2026-08-01"
extra={
<Button type="button" variant="outline">
Edit
</Button>
}
content={
<Descriptions
record={RECORD}
fields={HEADER_FIELDS}
layout="horizontal"
column={{ base: 1, md: 2 }}
size="small"
/>
}
extraContent={
<Statistic title="Amount" value={RECORD.amount} suffix="USD" variant="plain" />
}
>
<SectionCard title="Fulfillment">
<p className="text-sm text-muted-foreground">
Header KPIs are Statistic. Do not scale Descriptions values to hero type.
</p>
</SectionCard>
</PageContainer>
);
}Docked Footer
<PageFooter> portals FooterToolbar into the in-flow footer row so Submit sizes with the content column. No position: fixed and no CSS inset var.
New order
Footer stays pinned while the body scrolls
Order details
Scroll the body. Submit stays in the page footer row — not `position: fixed`.
Line 1
Line 2
Line 3
Line 4
Line 5
"use client";
import { FooterToolbar } from "@/components/f-ui/page/footer-toolbar";
import { PageContainer } from "@/components/f-ui/page/page-container";
import { PageFooter } from "@/components/f-ui/page/page-footer-slot";
import { SectionCard } from "@/components/f-ui/section-card/section-card";
import { Button } from "@/components/ui/button";
/** Docked actions teleport into the in-flow footer row via `PageFooter`. */
export function PageFooterDemo() {
return (
<PageContainer
className="h-88 rounded-xl border"
title="New order"
subTitle="Footer stays pinned while the body scrolls"
>
<SectionCard title="Order details">
<p className="text-sm text-muted-foreground">
Scroll the body. Submit stays in the page footer row — not `position: fixed`.
</p>
<div className="text-muted-foreground space-y-2 text-sm">
<p>Line 1</p>
<p>Line 2</p>
<p>Line 3</p>
<p>Line 4</p>
<p>Line 5</p>
</div>
</SectionCard>
<PageFooter>
<FooterToolbar extra={<span className="text-sm text-muted-foreground">Unsaved</span>}>
<Button type="button" variant="outline">
Cancel
</Button>
<Button type="button">Submit</Button>
</FooterToolbar>
</PageFooter>
</PageContainer>
);
}Composition
PageContainer
├─ PageHeader (breadcrumb, back, title, tags, extra, content, extraContent, tabs)
├─ scroll body (p-6, gap-4, surface)
└─ footer row
├─ footer? (persistent band)
└─ PageFooter slot ← FooterToolbar / FormPage / DataListFooterToolbarAPI Reference
PageContainer Props
Extends Page Header. Body and frame only:
| Prop | Type | Default | Description |
|---|---|---|---|
surface | "muted" | "plain" | "muted" | Muted lifts cards; plain is flat (Result / exception pages). |
fixedHeader | boolean | true | Pin the header; the body is the only scroll container. |
footer | ReactNode | — | Persistent (non-docked) footer band. |
children | ReactNode | — | Scroll body. |
className | string | — | Root frame. |
contentClassName | string | — | Scroll body. |
PageHeader Props
| Prop | Type | Default | Description |
|---|---|---|---|
breadcrumb | ReactNode | — | Trail above the title row. |
onBack | () => void | — | Back as a button, or a click side effect when backHref is set. |
backHref | string | — | Back as a resolved Link. |
linkComponent | LinkComponent | — | Local Link override for backHref. |
backLabel | string | "Back" | Tooltip and aria-label on the back control. |
title | ReactNode | — | Page h1. |
tags | ReactNode | — | Status / label chips beside the title. |
subTitle | ReactNode | — | Equal-weight metadata under the title. Middle-dot (·) is allowed here. |
extra | ReactNode | — | Page-level actions. Resource index: Create. Object Page: Edit / Share. |
content | ReactNode | — | Header summary (Descriptions). |
extraContent | ReactNode | — | Right-side header KPIs (Statistic). |
tabs | { items, activeKey?, onChange?, label? } | — | Page-channel underline tabs. label defaults to "Page sections". |
FooterToolbar Props
Presentational extra \| children row. Place it inside <PageFooter> to dock.
| Prop | Type | Default | Description |
|---|---|---|---|
visible | boolean | true | Render the bar only while true. |
extra | ReactNode | — | Left: status, selection count. |
children | ReactNode | — | Right: action buttons. |
renderContent | (dom) => ReactNode | — | Replace the inner row. |
maxWidth | string | — | Constrain the inner row. |
aria-label | string | — | Accessible name. |
PageFooter
PageFooter teleports children into the PageContainer footer slot. Renders nothing until the slot has mounted. usePageFooterSlot() returns the slot element (or null) so a recipe can fall back inline when there is no page ancestor.