Components / page
PageShell
A nav part was rejected. Navigation belongs to the page, in whichever region it sits, and a part would have implied there is one place for it.
Summary
The page's regions, and nothing that goes in them.
| APG pattern | none |
| Built on | native elements |
| Status | stable |
| Since | 0.2.0 |
The first component in the package to carry landmark roles.
<PageShell>
<PageShell.Banner><nav aria-label="Site">…</nav></PageShell.Banner>
<PageShell.Main><h1 data-se-text="display">Maya Okonjo</h1></PageShell.Main>
<PageShell.Contentinfo>© 2026</PageShell.Contentinfo>
</PageShell>
Anatomy
A <div data-se="page-shell" data-se-layout="cover"> holding <header>, <main> and <footer>. The layout is composed rather than restated — Cover is already header, flexible middle, footer.
| Part | Element | Required |
|---|---|---|
banner | <header> | yes |
contentinfo | <footer> | yes |
main | <main> | yes |
skip | <a> | yes |
No role attribute is written. <header>, <main> and <footer> at page level already mean banner, main and contentinfo, and a plain div wrapper does not strip them. data-part is for the stylesheet and the role is for the reader; this is the component where the two land on the same element.
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
skipLabel | string | Skip to main content | The skip link's text. It is the first thing a keyboard reader hears on the page. |
mainId | string | main | The id the skip link targets. Must match the id given to PageShell.Main. |
children | React.ReactNode | none | The three regions. Order matters — the shell places them as header, flexible middle, footer, and does not sort them. |
States
| State | Expressed as | |
|---|---|---|
| Focused | :focus-visible |
Behavior
The skip link jumps to main. That is all it does.
Accessibility
Three landmarks, no attributes. Verified by role rather than by markup, because these roles are computed.
Two landmarks of the same type need distinct names, and the shell cannot supply them. Site navigation in the banner and section navigation in main are two <nav> elements, indistinguishable to a screen reader without an aria-label on each. It does not know what your navigation is for.
The skip link is the first tab stop. It is not data-part="sr", which hides a thing permanently — this one returns on focus. The shell is position: relative so the link is absolute against the shell rather than the page; without that it anchors to the document and looks correct only while the shell happens to start at the top of it.
Main carries tabindex="-1" so the jump lands. Without it a browser moves focus inconsistently after a same-page jump and the reader arrives having skipped nothing. It draws no focus ring: the reader asked for the jump, and the jump is the feedback.
One main per page. More than one is a page that has not decided what it is about.
Keyboard
| Key | Result |
|---|---|
Tab | Reaches the skip link first, before anything in the banner |
Enter | Follows it, moving focus to main |
Roles, states and properties
_None written. The elements carry their own._
Focus
The skip link is the first stop on the page and returns out of the flow when it loses focus. main carries tabindex="-1" so the jump lands on it, and draws no ring — the reader asked for the jump and the jump is the feedback.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It sets no target size of its own; anything interactive inside it brings its own.
Tested
test/browser/page-shell.spec.mjs
Known gaps
_No upstream requirement is recorded as not applicable._
Appearance
| Token | Used for |
|---|---|
--se-banner-h | min-block-size |
--se-border | border-block-end, border-block-start |
--se-border-strong | border |
--se-border-width | border, border-block-end, border-block-start |
--se-container-max | max-inline-size |
--se-duration-fast | transition |
--se-ease-standard | transition |
--se-layer-overlay | z-index |
--se-leading-ui | line-height |
--se-page-gutter | inset-inline-start, padding-inline |
--se-radius-md | border-end-end-radius, border-end-start-radius |
--se-space-2 | padding |
--se-space-3 | padding, padding-block |
--se-space-4 | gap |
--se-space-5 | padding-block |
--se-surface | background |
--se-text | color |
--se-text-muted | color |
--se-text-sm | font-size |
--se-weight-medium | font-weight |
The measure sits on main rather than the shell, so a banner and a footer run the full width while the reading column stays bounded.
Motion
The skip link slides into place over --se-duration-fast. Nothing else moves.
Content
The skip link says where it goes: Skip to main content, not Skip. It is heard out of context, before anything else on the page.
Usage
It ships the slots, not the content. A shell with an opinion about what belongs in its banner has stopped being a shell.
Regions nest. Site navigation usually sits inside the banner rather than beside it.
A sticky banner declares its height instead of measuring it. The shell never sticks the banner; a page that does sets position: sticky on it and puts the height in --se-banner-h. The banner takes the dial as a min-block-size, and the document's scroll-padding-block-start reads the same dial, so anchor jumps and the skip link land clear of the bar rather than under it. Measuring the rendered header — a ResizeObserver re-running on every font swap — is the dial's job done at runtime; declare the height and the offset is true from the first paint. The dial ships as 0px, which is the shell saying nothing sticks until the page says otherwise.
When not to use it. The builder's editor chrome is not this. Two sidebars around a canvas is a screen, not a page, and it has no landmarks to place.
The banner is a slot and the shell only arranges it. Links, a menubar, buttons, a brand — the page decides. What the shell supplies is the row: one line, wrapping, with the first child pushed against the start and everything else against the end. A shell with an opinion about what belongs in its banner has stopped being a shell.
The two regions pad differently, on purpose. A banner holds controls that bring their own height; a footer holds text that does not. Both had inline padding and none in the block direction until 0.2, so a footer's text sat on its own rule and on the bottom of the page.
Notes
The layout is composed rather than restated, and that composition found a load-order trap: a layout attribute and a component attribute are both single attribute selectors, so styles.css now loads layout/ and type/ before components/.
A nav part was rejected. Navigation belongs to the page, in whichever region it sits, and a part would have implied there is one place for it.