- Design system
- Layout
03 — Layout
Layout
Containers, section banding, the sticky header, the footer, grids and sidebars. Layout owns all vertical rhythm so components never have to.
Container
One class, four widths. The gutter is fluid — clamp(20px, 5vw, 40px) — so it never crowds on a phone or float unanchored on a wide desktop.
<div class="container">…</div> <!-- 1120px, the default --> <div class="container container--prose">… <!-- 720px, article bodies --> <div class="container container--mid">… <!-- 940px --> <div class="container container--wide">… <!-- 1320px, dense dashboards -->
Prose is 720 px because that is roughly 68 characters at 17 px — the comfortable reading measure. Do not run body copy at container width.
Sections and banding
A page is a stack of <section class="section">. The section owns its vertical padding via --section-y — a fluid clamp(72px, 9vw, 128px). Alternate .s-page and .s-sunken to band the scroll so the eye can find where one idea ends.
Hero, primary content.
A supporting band. Recedes, so what sits on it lifts.
Back to the page ground.
<section class="section s-page">
<div class="container">
<div class="sec-head">
<div>
<h2>Selected work</h2>
<p class="sec-head__intro">Five projects that shipped.</p>
</div>
<a class="link-more" href="/work">All work</a>
</div>
…
</div>
</section>
.section--tight halves the padding for a dense stack of sections. .section--flush removes it when the child supplies its own.
Selected work
Five projects, each with the constraint that made it hard.
Title on the left, an optional action on the right, aligned to the baseline and wrapping to two rows on a narrow screen.
Header
Sticky, translucent, and it blurs what scrolls beneath it. The @supports guard keeps a solid fallback where backdrop-filter is unavailable — without it those browsers get unreadable text over content.
<header class="site-head">
<div class="container head-in">
<a class="brand" href="/">
<svg class="brand__mark" aria-hidden="true">…</svg>
<span>Site name</span>
</a>
<nav class="head-nav" aria-label="Primary">
<a href="/work">Work</a>
<a href="/about">About</a>
</nav>
<!-- Two controls, always side by side. Nothing else belongs here. -->
<div class="head-end">
<button class="iconbtn" data-theme-toggle aria-label="Theme">…</button>
<button class="iconbtn navsheet__trigger" data-navsheet-open
aria-label="Menu" aria-haspopup="dialog">…</button>
</div>
</div>
</header>
<!-- 🔴 The sheet is a SIBLING of the header, never a child. The header sets
backdrop-filter, which traps position:fixed descendants inside it. -->
<dialog class="drawer navsheet" data-navsheet aria-label="Site menu">…</dialog>
The breakpoint is 900 px: below it .head-nav is display:none and the menu button appears; above it the nav returns and the button hides. The theme toggle is visible at every width. Keep the corner to these two — a call to action there competes with the one in the hero, and the corner loses. See Overlays for the sheet itself.
utilities.css sets scroll-margin-block-start on every element with an id. Without it, clicking an in-page link lands the target heading behind the header bar.
Footer
A three-column grid above a legal line: identity on the left in the wider track, then two link columns. Collapses to a single column below 640 px.
<footer class="site-foot">
<div class="container">
<div class="foot-grid">
<div>
<p class="foot-name">Site name</p>
<p class="foot-role">One line on what this is.</p>
</div>
<div>
<p class="foot-h">Sitemap</p>
<ul class="foot-list"><li><a href="/work">Work</a></li></ul>
</div>
<div>
<p class="foot-h">Elsewhere</p>
<ul class="foot-list"><li><a href="…">GitHub</a></li></ul>
</div>
</div>
<p class="foot-legal">Copyright line.</p>
</div>
</footer>
Footer links are 40 px tall rather than 44 — they are a dense list, not a primary target, and the extra height would push the footer too long.
Grids
Mobile-first: one column, and the breakpoint adds tracks. Every track is minmax(0, 1fr), not 1fr — that is what stops a long unbroken string from blowing the track out and forcing the page to scroll sideways.
Both --3 and --4 go to two columns at 720 px before reaching full width at 960 px. Three columns on a tablet is too narrow to read.
Content and aside
The two-column reading layout used on this page. The rail sticks below the header once the article scrolls past it — and only from 960 px up, where there is room for it. Below that it stacks above the content.
<div class="with-aside">
<div>
<h2 id="one">Section one</h2>
…
</div>
<aside class="rail">
<p class="rail__h">On this page</p>
<ul class="rail__list">
<li><a href="#one">Section one</a></li>
</ul>
</aside>
</div>
.with-aside--start puts the rail first, for docs navigation. The rail is align-self:start so it does not stretch to the content height — position:sticky would do nothing if it did.
Cluster and stack
Two primitives that cover most of the remaining spacing needs. .cluster is a wrapping horizontal row; .stack is the owl selector as a class.
<div class="cluster">…</div> <!-- flex, wrap, gap --s-3, centre-aligned --> <div class="stack">…</div> <!-- every child after the first gets --s-4 above --> <div class="stack stack--lg">… <!-- --s-8 instead -->
Breakpoints
Four, all written as modern range syntax (@media (width >= 720px)). They are content breakpoints, not device widths — each one is where a particular layout stops working, which is why they are not round numbers from a device chart.
| Width | What changes |
|---|---|
| 640px | Footer goes to three columns; .facts becomes two columns; form grids split |
| 720px | Card and grid layouts go to two columns; .row puts meta on the right |
| 900px | Header nav, tools and CTA appear; the mobile sheet disappears |
| 960px | Grids reach three and four columns; the aside rail becomes sticky |
clamp(), minmax() or auto-fill first — most cases do not need a media query at all.