Composing a Page
Default to one column inside a capped container, and make every additional column earn its place. A page is a vertical stack of full-width bands; each band holds one centred content well; a grid arranges items inside a band and never the page itself. That posture is correct more often than it feels, because a product page is read down one axis and every extra column is a decision you then owe at every width. The sibling spacing owns every gap, padding value and density mode — this skill decides which regions exist, how wide they are, and which one sits above which; the moment the question becomes how far apart, hand it over. Three neighbours take the rest: responsive owns what changes as the width changes, navigation owns movement between pages rather than composition within one, and a request that names a table or a dashboard is dense-ui by the named-noun rule.
Read the project's layout primitives before adding any of your own. Look for a <Container> / <Stack> / <PageShell> component, a max-w-* convention in existing pages, a grid-template-areas block in a layout stylesheet, and an existing z-index token set (--z-*, theme.zIndex, a zIndex.ts). Compose with what you find. Two containers with different caps, or a second z-index vocabulary alongside the first, is the failure this skill exists to prevent — a page that "looks fine" while every future page inherits the ambiguity.
Quick Reference
| Topic | File |
|---|---|
| The page's shape is a known type — split view, sidebar + detail, feed, settings, wizard | Open page-archetypes.md before writing markup, and read only the archetype that matches. |
Something is stacked wrongly, a popover is trapped inside a card, or you are about to type a z-index literal |
Open z-index.md — it holds the layer set and the stacking-context traps that make z-index appear not to work. |
Core Principles
Cap the container, and know it is not the same cap as the measure. An uncapped page produces unreadable line lengths and compositions that drift apart on wide monitors. The default content well is
max-width: 1200pxcentred, with full-bleed reserved for bands whose background spans the viewport while their contents still sit in the well. The reading measure (65ch) is a different, narrower cap and belongs totypography— a page routinely uses both, nested. Exception: canvases, editors, maps and data grids take the whole viewport and set their own bound; capping them wastes the screen the user opened them for.One column until a second one is justified by content, not by width available. The ladder is
1column for reading, forms, settings detail and wizards;2for sidebar + detail;8for marketing composition;12only where users arrange tiles themselves, because 12 divides evenly by 2, 3, 4 and 6. A twelve-column grid on a marketing page is configuration overhead nobody reads. Exception: an existing design file that already specifies 12 — match it rather than re-deriving.Make two-column splits asymmetric. Equal columns read as static and give the eye no entry point; unequal ones create a primary and a secondary. Write
grid-template-columns: 2fr 1fr(or a fixed sidebar,280px 1fr), never1fr 1fr. Exception: layouts where equality is the message — pricing tiers, before/after, diff views, side-by-side comparison — where symmetry is the correct signal.Treat z-index as a named layer set with six members and no others. Arbitrary values (
z-index: 9999) are a bidding war that the next component always wins. The set is--z-base: 0,--z-sticky: 50,--z-dropdown: 100,--z-modal: 200,--z-tooltip: 300,--z-toast: 400; popovers and menus share the dropdown layer, a modal's scrim shares the modal layer. Exception: inside a component that has already sealed itself withisolation: isolate, localz-index: 1/2are correct and must not be promoted to tokens.Seal a component's stacking before you raise anything inside it. Without a local stacking context, a raised child competes with the entire document and wins or loses by accident. Put
isolation: isolateon any component that overlaps its own children — a card with a floating badge, a media object with a play button. Exception: an element that must escape its ancestor's bounds (a popover anchored inside anoverflow: hiddencard) cannot be solved by layering at all; portal it to the layer root instead.Give the page exactly one focal point and verify it by squinting. The test is falsifiable: blur the screen or view it in greyscale at 10% size, and name the first thing still identifiable. If that is the nav bar, a cookie banner or a decorative image, the hierarchy is inverted — demote everything else rather than enlarging the hero. Hierarchy is subtraction. Exception: split views with two working panes (mail, IDE, inbox + thread) have their focal point inside the active pane, not on the page.
Keep the primary action inside the viewport on any page taller than one screen. Scroll-depth data will confirm most users never reach the bottom of a long form. If the region exceeds
100dvh, the primary action goes into a sticky footer bar or travels with a sticky sidebar. Exception: irreversible destructive confirmations, which should require deliberately reaching the end — never make "Delete account" sticky.Put destructive actions in a different region, not a wider gap. Proximity implies equivalence, and space alone does not undo it: a Delete sitting in the same button row as Save reads as a peer of Save. Move it to its own block (a bordered "Danger zone") or into an overflow menu. Exception: a bulk-action toolbar where every action is destructive — there the confirmation step carries the separation instead.
Declare the sticky header's height once and consume it everywhere. Hard-coding
80pxin three places guarantees that anchored links land under the header the first time the header changes. Define--header-hand use it fortop:on sub-navs,scroll-margin-top: var(--header-h)on every[id], andheight: calc(100dvh - var(--header-h))on full-height panes. Exception: short viewports — an80pxheader on a667pxlandscape phone eats an eighth of the screen, so release the sticky there; the query that does it belongs toresponsive.
Output Format
Before writing markup for a page, emit the region sketch and get it agreed. It is four columns and it prevents most of the rework:
band container columns sticky layer
─────────────────────────────────────────────────
header full-bleed 1 yes sticky
hero 1200 1 no base
features 1200 8-col no base
footer full-bleed 1 no base
Smells and Fixes
| Smell | Fix |
|---|---|
z-index: 9999, or two components at 999 |
Assign the nearest named layer; nothing outside the six-member set |
A z-index that visibly does nothing |
An ancestor created a stacking context — see references/z-index.md |
| Content stretching edge to edge at 1920px | Cap the well at 1200px; let only the background go full-bleed |
1fr 1fr on a page-level split |
2fr 1fr, or a fixed sidebar plus 1fr |
| Anchor links landing beneath the sticky header | scroll-margin-top: var(--header-h) on [id] |
| Delete and Save in the same button row | Separate region or overflow menu, not a bigger gap |
| Two elements competing to be the hero | Demote one; re-run the greyscale squint test |
Submit only reachable at the end of a 3 screen form |
Sticky action bar |
| A second container component with a different cap | Delete it and reuse the existing one |
Checklist
- Content well capped; full-bleed limited to backgrounds
- Column count justified by content, off the
1 / 2 / 8 / 12ladder - Page-level splits asymmetric unless equality is the message
- Every
z-indexis a named token, or local inside an isolated component -
isolation: isolateon components that overlap their own children - One focal point, confirmed by the greyscale squint test
- Primary action reachable without scrolling to the end
- Destructive actions in their own region
- Header height declared once as
--header-hand consumed everywhere