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.

Rendered from the package · open on its own

Summary

The page's regions, and nothing that goes in them.

APG patternnone
Built onnative elements
Statusstable
Since0.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.

PartElementRequired
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

PropertyValuesDefaultWhat it does
skipLabelstringSkip to main contentThe skip link's text. It is the first thing a keyboard reader hears on the page.
mainIdstringmainThe id the skip link targets. Must match the id given to PageShell.Main.
childrenReact.ReactNodenoneThe three regions. Order matters — the shell places them as header, flexible middle, footer, and does not sort them.

States

StateExpressed 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

KeyResult
TabReaches the skip link first, before anything in the banner
EnterFollows 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

TokenUsed for
--se-banner-hmin-block-size
--se-borderborder-block-end, border-block-start
--se-border-strongborder
--se-border-widthborder, border-block-end, border-block-start
--se-container-maxmax-inline-size
--se-duration-fasttransition
--se-ease-standardtransition
--se-layer-overlayz-index
--se-leading-uiline-height
--se-page-gutterinset-inline-start, padding-inline
--se-radius-mdborder-end-end-radius, border-end-start-radius
--se-space-2padding
--se-space-3padding, padding-block
--se-space-4gap
--se-space-5padding-block
--se-surfacebackground
--se-textcolor
--se-text-mutedcolor
--se-text-smfont-size
--se-weight-mediumfont-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.