Page structure
Every page in the product is the same column: a header that stays put and one body that scrolls. The @scaler-tech/aurora/layout family ships that column — PageContent (the column), PageHeader (pinned), PageBody (the scroll region), and FocusPanel (width control). Get these right and the page scrolls correctly, the header never drifts, and the gutter matches every other screen.
In web-app code you import the app shims (PageLayout, PageBody, PageHeader, PortfolioPageTitle, …) from @/vue/components/layout/, which are thin re-exports of these primitives. The structure is identical either way.
Asset list
Row 1 — only this body scrolls. The header above stays pinned.
Row 2 — only this body scrolls. The header above stays pinned.
Row 3 — only this body scrolls. The header above stays pinned.
Row 4 — only this body scrolls. The header above stays pinned.
Row 5 — only this body scrolls. The header above stays pinned.
Row 6 — only this body scrolls. The header above stays pinned.
Row 7 — only this body scrolls. The header above stays pinned.
Row 8 — only this body scrolls. The header above stays pinned.
Row 9 — only this body scrolls. The header above stays pinned.
Row 10 — only this body scrolls. The header above stays pinned.
Row 11 — only this body scrolls. The header above stays pinned.
Row 12 — only this body scrolls. The header above stays pinned.
Row 13 — only this body scrolls. The header above stays pinned.
Row 14 — only this body scrolls. The header above stays pinned.
Row 15 — only this body scrolls. The header above stays pinned.
Row 16 — only this body scrolls. The header above stays pinned.
Row 17 — only this body scrolls. The header above stays pinned.
Row 18 — only this body scrolls. The header above stays pinned.
Row 19 — only this body scrolls. The header above stays pinned.
Row 20 — only this body scrolls. The header above stays pinned.
Row 21 — only this body scrolls. The header above stays pinned.
Row 22 — only this body scrolls. The header above stays pinned.
Row 23 — only this body scrolls. The header above stays pinned.
Row 24 — only this body scrolls. The header above stays pinned.
vue
<template>
<PageContent>
<PageHeader>
<PageHeaderTitle>Asset list</PageHeaderTitle>
</PageHeader>
<PageBody>
<!-- scrolling content; sections are spaced by PageBody's gap-4 -->
</PageBody>
</PageContent>
</template>
<script setup lang="ts">
import { PageContent, PageHeader, PageHeaderTitle, PageBody } from '@scaler-tech/aurora/layout'
</script>The scroll model
This is the one idea the whole family is built on:
| Piece | Scrolls? | Owns |
|---|---|---|
PageContent | No — fixed-height column (flex-1 min-h-0), no padding of its own | The vertical stack: header, optional toolbars, body |
PageHeader | No — pinned sibling above the body | The title band + bottom divider |
toolbar (custom div) | No — any sibling between header and body stays put | A pinned filter/action band |
PageBody | Yes — the one scroll region (overflow-y-auto) | The page gutter (p-4) and section rhythm (gap-4) |
Because the only scrolling box is PageBody, everything outside it — the app rail, the top bar, the section sidebar, the page header, a pinned toolbar — stays fixed by construction. No position: sticky, no negative margins, no -mx-4 breakout hacks anywhere.
Anatomy
PageContent ............ fixed-height column (never scrolls)
├── PageHeader ......... pinned title band
│ ├── PageHeaderTitle → page/section title (#suffix, #actions, #trailing slots)
│ └── PageHeaderActions → right-aligned buttons / menus
├── (optional toolbar) . a pinned filter/action band
└── PageBody ........... the single scroll region (owns p-4 + gap-4)
└── FocusPanel? ..... optional width-bounded block for centered content| Component | Role |
|---|---|
PageContent | The page column. Drop a PageHeader and a PageBody inside it. (Shim: PageLayout.) |
PageHeader | Pinned band at the top with a bottom divider. Holds the title + actions. |
PageHeaderTitle | The heading. Default slot = title text; #suffix, #actions, #trailing slots. |
PageHeaderActions | Right-aligned controls (buttons, menus). |
PageBody | The scrolling body. Carries the gutter and rhythm; bleed drops the gutter for a flush work surface. The piece most pages forget. |
FocusPanel | Width-bounds a body section (sm/md/lg/xl). Centered by default. |
PageToolbar | A pinned, gutter-aligned row group (tab strip, filter row, banner, action band) that stays put while the PageBody beside it scrolls — see below. Never inside a padded PageBody. |
PageHeaderBack | Back navigation for a PageHeader — chevron plus the name of the place you return to. |
PageActionBar | Pinned action bar at the foot of the column, below PageBody. Commit and discard live here, not in the header. |
PageOverlay | A card covering the content area, for opening a record from a list without leaving it — see PageOverlay. Needs relative on its PageContent. |
Header
PageHeaderTitle renders at paragraph-sm-medium — a quiet, medium-weight label, not a heavy heading-* style. The page title names where you are; it doesn't shout. Put the entity name in #suffix and any status affordance (favorite toggle, health dot) in #actions. Right-aligned actions go in PageHeaderActions.
PageHeaderActions is for page-level actions (download, upload, a primary button, an overflow menu) — not filters. The reporting-year selector and any other filter belong in the filter row, never in the header. See Filter.
Overview — Demo portfolio
Row 1
Row 2
Row 3
Row 4
Row 5
Row 6
Row 7
Row 8
Row 9
Row 10
Row 11
Row 12
Row 13
Row 14
Row 15
Row 16
Row 17
Row 18
Row 19
Row 20
Row 21
Row 22
Row 23
Row 24
vue
<PageHeader>
<PageHeaderTitle>
Overview
<template #suffix> — Demo portfolio</template>
<template #actions>
<FavoriteButton />
<PortfolioHealth />
</template>
</PageHeaderTitle>
<PageHeaderActions>
<Button size="sm" variant="secondary">Download</Button>
<Button size="sm">Upload</Button>
</PageHeaderActions>
</PageHeader>This is exactly what the PortfolioPageTitle shim does: it composes PageHeaderTitle with the portfolio name in #suffix and the favorite/health controls in #actions.
PageHeaderTitle — Slots
| Slot | Content |
|---|---|
| default | The title text |
suffix | Inline content right after the title (same line) — typically the entity name |
actions | Inline controls next to the title (favorite toggle, health dot) |
trailing | Content pushed to the far end of the row |
Width — full-width by default, FocusPanel to narrow
The page is always full-bleed. Width is declared per body section by its role, not by the page. A work surface (table, grid, list) runs edge-to-edge; a "read or act on one thing" block (empty state, summary, form, confirmation) wraps in a FocusPanel.
Report readiness
You're all setEvery required field is filled. Generate the report whenever you're ready.
vue
<PageBody>
<FocusPanel size="md">
<Card border> … centered "next steps" panel … </Card>
</FocusPanel>
</PageBody>When the content below is narrowed, the filter row above it does not narrow with it. Render the filter row as a full-width child of PageBody and give only the content beneath it a FocusPanel — don't wrap both in one width container.
vue
<PageBody>
<div class="flex items-center justify-between gap-2"> … filters … </div> <!-- full width -->
<FocusPanel size="xl"> … table … </FocusPanel> <!-- narrowed -->
</PageBody>FocusPanel — Props
| Prop | Type | Default | Description |
|---|---|---|---|
size | 'sm' | 'md' | 'lg' | 'xl' | 'xl' | Max width: sm = max-w-md, md = max-w-2xl, lg = max-w-4xl, xl = max-w-6xl. |
align | 'center' | 'left' | 'center' | center = mx-auto; left hugs the gutter. |
Flush pages — the full-width table shape (PageToolbar + PageBody bleed)
When a page's content is a full-size work surface (a table card-edge to card-edge, nothing after it), don't fight the gutter with negative margins — turn it off at the source. The work surface scrolls inside a PageBody bleed (same scroll region, gutter dropped), and every row that should align to the gutter (tab strip, banner, filter row) is a PageToolbar — a pinned padded row group (px-4, gap-4 rhythm, shrink-0) that stays put while the body scrolls. This is the industry-standard shape (Polaris Card padding="0" + IndexFilters, Nuxt UI DashboardToolbar + body).
vue
<PageContent>
<PageHeader> … </PageHeader>
<Tabs class="pt-4 flex-1 min-h-0">
<PageToolbar>
<TabsList> … </TabsList>
<Banner v-if="…" … /> <!-- shared rows, on the gutter -->
</PageToolbar>
<!-- `flex flex-col` makes the panel a column, so the PageBody inside owns
the scroll and the toolbar above it pins. -->
<TabsContent value="overview" fill class="flex flex-col">
<PageToolbar> … filter row … </PageToolbar> <!-- pinned -->
<PageBody bleed>
<Table :rounded-corners="false" outer-borders="t" … /> <!-- flush -->
</PageBody>
</TabsContent>
<TabsContent value="asset-groups" fill class="flex flex-col">
<PageBody class="pt-0"> … padded content region … </PageBody>
</TabsContent>
</Tabs>
</PageContent>The division of labour: PageBody is the scrolling content region — padded by default (the whole page, or one tab panel with pt-0, since the Tabs gap already provides the top rhythm), bleed when the content is the work surface itself. PageToolbar is the padded static row group pinned beside it. Two rules keep it sound:
- Never place a
PageToolbarinside a default (padded)PageBody— the body already pads, so it would double the gutter. - The first block under the header carries the top breathing room (
pt-4on theTabs, or on thePageToolbaritself on a tabless page) — on a padded pagePageBody'sp-4did this for you.
A flush work surface drops its card chrome: :rounded-corners="false" + outer-borders="t" on Table, so the table's edges are the card's edges and a single top border separates it from the row above. On this shape the filter row is pinned — the table scrolls under it, which is what you want on a full-height data table.
Pinned toolbar between header and body
Anything placed as a direct child of PageContent, between PageHeader and PageBody, stays pinned. Reserve it for an action / context band (bulk-selection actions, a context strip) that must not scroll away. Wrap it in PageToolbar, which carries the gutter (px-4) for you — because PageBody (not PageContent) owns the p-4 gutter, a bare div here would misalign with the content below.
On a padded page the filter row is not this band — it's the first child inside PageBody and scrolls with the content (see Filter). On a flush page it's a pinned PageToolbar instead (see above).
Asset list
3 selected
Row 1 — the header and the action band above both stay pinned.
Row 2 — the header and the action band above both stay pinned.
Row 3 — the header and the action band above both stay pinned.
Row 4 — the header and the action band above both stay pinned.
Row 5 — the header and the action band above both stay pinned.
Row 6 — the header and the action band above both stay pinned.
Row 7 — the header and the action band above both stay pinned.
Row 8 — the header and the action band above both stay pinned.
Row 9 — the header and the action band above both stay pinned.
Row 10 — the header and the action band above both stay pinned.
Row 11 — the header and the action band above both stay pinned.
Row 12 — the header and the action band above both stay pinned.
Row 13 — the header and the action band above both stay pinned.
Row 14 — the header and the action band above both stay pinned.
Row 15 — the header and the action band above both stay pinned.
Row 16 — the header and the action band above both stay pinned.
Row 17 — the header and the action band above both stay pinned.
Row 18 — the header and the action band above both stay pinned.
Row 19 — the header and the action band above both stay pinned.
Row 20 — the header and the action band above both stay pinned.
Row 21 — the header and the action band above both stay pinned.
Row 22 — the header and the action band above both stay pinned.
Row 23 — the header and the action band above both stay pinned.
Row 24 — the header and the action band above both stay pinned.
vue
<PageContent>
<PageHeader> … </PageHeader>
<!-- Pinned action band: sibling of PageHeader; PageToolbar carries the gutter -->
<PageToolbar class="py-2 border-b border-secondary">
<div class="flex items-center gap-2">
<span class="paragraph-sm text-secondary">3 selected</span>
<Button size="sm" variant="secondary">Export</Button>
</div>
</PageToolbar>
<PageBody>
<!-- the filter row lives here, scrolling with the content -->
<div class="flex flex-wrap items-center gap-2"> … filter pills … </div>
<!-- content -->
</PageBody>
</PageContent>On a tabbed page the shared filter row goes under the tablist instead — the Tabs + TabsContent fill pattern already pins the strip. See Tabs.
Card as a list shell
Card defaults to p-4. That's right for a content card, but wrong when the card is a shell around a list/table that draws its own row padding and dividers. For a flush list, override to p-0 overflow-hidden (overflow-hidden clips rows to the rounded corners).
vue
<!-- content card: keep the default padding -->
<Card border> … prose, fields, a small summary … </Card>
<!-- list/table shell: flush, clipped -->
<Card border class="p-0 overflow-hidden">
<Table … />
</Card>The full shell
The page column lives inside the persistent shell — the App* family: AppShell → Rail (the icon strip) + AppTopbar + PageFrame, where PageFrame carves the white card into an optional PageSidebar and the PageContent column. Drop the sidebar for a full-width page. See the live composition in the App layout showcase — hover the rail to watch it expand and the card crop its edge.
Section minimap on a long page
A page long enough to be read in sections can carry a PageMinimap — a thin column of ticks in the gutter that marks the reading position and expands on hover into a jump list. It goes first inside PageBody, that body takes container, and the sections narrow so there's a gutter to float in. See Page minimap.
PageBody — Props
| Prop | Type | Default | Description |
|---|---|---|---|
bleed | boolean | false | Drop the gutter so children run card-edge to card-edge. |
container | boolean | false | Make the body a container-query context, so children size against the content column instead of the viewport. Required by PageMinimap. |
Rules
- Always include a
PageBody— on a flush full-width work surface it takesbleed(gutter off, scroll kept) and pairs with pinnedPageToolbarrows, per the flush-page shape above. - Put the title in
PageHeaderTitle, the entity name in#suffix, status in#actions. Right-aligned buttons go inPageHeaderActions. - Keep the page full-width; narrow per section with
FocusPanel. Pick the size by content measure. - Re-add
px-4on a hand-built pinned toolbar.PageBodyowns the gutter, so a toolbar sibling needs its own. - Put the filter row — reporting-year selector included — as the first child inside
PageBody(non-tabbed), never inPageHeaderActionsand never a pinned/bordered band. On a tabbed page it goes under the tablist instead. See Filter. - Flush a list/table card with
p-0 overflow-hidden. Keep the defaultp-4for content cards.
- Don't scroll
PageContentor the card. OnlyPageBodyscrolls — that's what keeps the header and shell pinned withoutsticky. - Don't make the title bold. It's
paragraph-sm-medium, calm by design. - Don't wrap the filter row and the content in one width container. The row stays full-width; only the content below it narrows.
- Don't re-add
sticky,-mx-4, or negative margins to pin a header or toolbar. Placing it outsidePageBodyalready pins it.
Gotchas
PageBodyis the piece pages forget. A page that importsPageLayout/PageHeaderbut predatesPageBodyis only partially structured — its content isn't inside the single scroll boundary, so it either doesn't scroll or scrolls the wrong box. If a page behaves oddly, check that the scrolling content actually sits insidePageBody.- The
px-0"bleed" override is a bridge, not the ideal. Some legacy content already carries its own internal horizontal padding; wrapping it inPageBodythen doubles the gutter, so you'll see<PageBody class="px-0">. That's a temporary accommodation for un-migrated internals — new content should rely onPageBody's gutter. - A pinned toolbar needs its own
px-4. Because the gutter lives onPageBody, a toolbar placed above it starts flush against the edge. Addpx-4 py-2 border-b border-secondaryso it lines up and reads as a band. PageBody containerchanges what "absolute" means inside it.container-type: inline-sizealso applies layout containment, which makes the body the containing block for absolutely and fixed positioned descendants. That's why it's opt-in rather than always on. Aurora's overlays teleport out, so they're unaffected; hand-rolledposition: fixedcontent inside an opted-in body is not.PageContenthas zero padding. It's a bare column. Content placed directly in it (not insidePageBody) is flush and won't scroll — the symptom of a missingPageBody.
Related
- PageOverlay — opening a record over the page, plus
PageHeaderBackandPageActionBar - Tabs — top-level page tabs, pinned strip, URL persistence
- Page minimap — the gutter rail for a long sectioned page
- Filter — the filter pill family and where filters live
- App layout showcase — the live shell
Button·Tag