Skip to content

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:

PieceScrolls?Owns
PageContentNo — fixed-height column (flex-1 min-h-0), no padding of its ownThe vertical stack: header, optional toolbars, body
PageHeaderNo — pinned sibling above the bodyThe title band + bottom divider
toolbar (custom div)No — any sibling between header and body stays putA pinned filter/action band
PageBodyYes — 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
ComponentRole
PageContentThe page column. Drop a PageHeader and a PageBody inside it. (Shim: PageLayout.)
PageHeaderPinned band at the top with a bottom divider. Holds the title + actions.
PageHeaderTitleThe heading. Default slot = title text; #suffix, #actions, #trailing slots.
PageHeaderActionsRight-aligned controls (buttons, menus).
PageBodyThe scrolling body. Carries the gutter and rhythm; bleed drops the gutter for a flush work surface. The piece most pages forget.
FocusPanelWidth-bounds a body section (sm/md/lg/xl). Centered by default.
PageToolbarA 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.
PageHeaderBackBack navigation for a PageHeader — chevron plus the name of the place you return to.
PageActionBarPinned action bar at the foot of the column, below PageBody. Commit and discard live here, not in the header.
PageOverlayA card covering the content area, for opening a record from a list without leaving it — see PageOverlay. Needs relative on its PageContent.

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 ​

SlotContent
defaultThe title text
suffixInline content right after the title (same line) — typically the entity name
actionsInline controls next to the title (favorite toggle, health dot)
trailingContent 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 ​

PropTypeDefaultDescription
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 PageToolbar inside 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-4 on the Tabs, or on the PageToolbar itself on a tabless page) — on a padded page PageBody's p-4 did 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 ​

PropTypeDefaultDescription
bleedbooleanfalseDrop the gutter so children run card-edge to card-edge.
containerbooleanfalseMake 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 takes bleed (gutter off, scroll kept) and pairs with pinned PageToolbar rows, per the flush-page shape above.
  • Put the title in PageHeaderTitle, the entity name in #suffix, status in #actions. Right-aligned buttons go in PageHeaderActions.
  • Keep the page full-width; narrow per section with FocusPanel. Pick the size by content measure.
  • Re-add px-4 on a hand-built pinned toolbar. PageBody owns 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 in PageHeaderActions and 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 default p-4 for content cards.
  • Don't scroll PageContent or the card. Only PageBody scrolls — that's what keeps the header and shell pinned without sticky.
  • 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 outside PageBody already pins it.

Gotchas ​

  • PageBody is the piece pages forget. A page that imports PageLayout/PageHeader but predates PageBody is 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 inside PageBody.
  • The px-0 "bleed" override is a bridge, not the ideal. Some legacy content already carries its own internal horizontal padding; wrapping it in PageBody then doubles the gutter, so you'll see <PageBody class="px-0">. That's a temporary accommodation for un-migrated internals — new content should rely on PageBody's gutter.
  • A pinned toolbar needs its own px-4. Because the gutter lives on PageBody, a toolbar placed above it starts flush against the edge. Add px-4 py-2 border-b border-secondary so it lines up and reads as a band.
  • PageBody container changes what "absolute" means inside it. container-type: inline-size also 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-rolled position: fixed content inside an opted-in body is not.
  • PageContent has zero padding. It's a bare column. Content placed directly in it (not inside PageBody) is flush and won't scroll — the symptom of a missing PageBody.
  • PageOverlay — opening a record over the page, plus PageHeaderBack and PageActionBar
  • 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