Container
.container does three things: caps the content at
--container, centres it with margin-inline: auto, and
applies the page gutter. The gutter is
clamp(1rem, .55rem + 2.2vw, 2rem)
— a clamp, so the space at the edge of the page grows with the viewport instead of
stepping at breakpoints.
When to use it
Wrap the content of a page section in one. Almost every page is a
<header>, some <section>s and a
<footer>, each with a container inside it — which is what lets a
full-width background sit behind content that is still capped and centred.
Full-width band, capped content
The background reaches the edges; the text does not.
<section class="section" style="background:var(--surface-2)">
<div class="container">
<div class="stack stack-2">
<h3>Full-width band, capped content</h3>
<p class="text-muted">The background reaches the edges; the text does not.</p>
</div>
</div>
</section>
<section class="section" style="background:var(--surface-2)">
<div class="container">
<div class="stack stack-2">
<h3>Full-width band, capped content</h3>
<p class="text-muted">The background reaches the edges; the text does not.</p>
</div>
</div>
</section>
The order matters. Put the background on the outer element and the container inside it. A container with a background gives you a coloured band that stops short of the edges, which is almost never what anyone wants.
Widths
Each variant sets --container and nothing else, so switching width never
changes the gutter or the centring.
| Class | --container | What it is for |
|---|---|---|
.container-sm |
40rem |
A single column of prose. About 70 characters at the body size. |
.container-md |
56rem |
An article with a rail, or a form. |
.container |
76rem |
The default. A page of cards or a dashboard. |
.container-lg |
76rem |
The default, named. Use it when you want to be explicit. |
.container-xl |
90rem |
A wide dashboard or a data grid. |
.container-full |
none |
No cap. Keeps the gutter, drops the max width. |
.container-lg and the bare .container are the same
76rem. The named one exists so a page can say which width it means
rather than relying on the default, which is worth having when the default might
change.
container-sm — 40rem
container-md — 56rem
<div class="stack stack-3">
<div class="container-sm" style="background:var(--surface-2);padding-block:var(--space-3)">
<p class="text-muted">container-sm — 40rem</p>
</div>
<div class="container-md" style="background:var(--surface-2);padding-block:var(--space-3)">
<p class="text-muted">container-md — 56rem</p>
</div>
</div>
<div class="stack stack-3">
<div class="container-sm" style="background:var(--surface-2);padding-block:var(--space-3)">
<p class="text-muted">container-sm — 40rem</p>
</div>
<div class="container-md" style="background:var(--surface-2);padding-block:var(--space-3)">
<p class="text-muted">container-md — 56rem</p>
</div>
</div>
Those two examples put a background on the container to make the width visible, which is the thing the note above says not to do. It is a diagram, not a pattern.
Do not nest containers
A container inside a container applies the gutter twice, so the inner content sits
two gutters in from the edge and no longer lines up with anything else on the page.
If you need a narrower column inside a container, use
.container-full — which keeps the gutter and drops the cap — or set
max-inline-size on the child directly.
Gutter, no cap — safe inside another container.
<div class="container-full" style="background:var(--surface-2);padding-block:var(--space-3)">
<p class="text-muted">Gutter, no cap — safe inside another container.</p>
</div>
<div class="container-full" style="background:var(--surface-2);padding-block:var(--space-3)">
<p class="text-muted">Gutter, no cap — safe inside another container.</p>
</div>
Vertical page rhythm
The container handles the horizontal axis. The vertical counterpart is
.section, whose padding is a
clamp() that grows with the viewport in the same way the gutter does.
The two are almost always used together — a <section
class="section"> carrying a background, with a
.container inside it — and .section has
its own page.
.app-shell — header, content, footer
.app-shell is a grid with
min-block-size: 100dvh and grid-template-rows: auto 1fr
auto. The middle row takes all the remaining height, which is the modern
answer to the sticky-footer problem: on a short page the footer sits at the bottom of
the screen rather than halfway up it, and on a long page it is where the content
ends.
<body class="app-shell">
<header>…</header>
<main>…</main>
<footer>…</footer>
</body>
100dvh rather than 100vh is the detail that matters on a
phone: vh is measured against the largest viewport, so with the browser
chrome visible a 100vh shell is taller than the screen and the page
scrolls a little for no reason. dvh tracks the viewport as the chrome
shows and hides.
It expects exactly three children. A fourth gets an auto row from
grid-auto-rows and lands after the footer, which is rarely what was
intended.
.sticky-top
A sticky header. What is interesting is what it does not do while it is at
rest: where the browser supports container-type: scroll-state, the blur
and the shadow are moved onto an ::after that is transparent until the
header actually sticks. Sitting in flow it looks like part of the page; the moment it
pins, it earns a shadow.
@supports (container-type: scroll-state) {
.sticky-top { container-type: scroll-state; }
.sticky-top::after { opacity: 0; /* blur + shadow live here */ }
@container scroll-state(stuck: block-start) {
.sticky-top::after { opacity: 1; box-shadow: var(--shadow-2); }
}
}
The treatment rides on a pseudo-element because a scroll-state container cannot style
itself — only its descendants — and ::after is a descendant. Without
support, the fallback is the unconditional blurred background, which is what the
header on this page is using if your browser is older.
Classes
Generated from src/04-layout.css by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.container |
deck.layout |
Centres content and caps it at --container, with the responsive page gutter already applied. Nest one inside another and the inner one adds a second gutter, so use .container-full instead. | 04-layout.css:11 |
.container-full |
deck.layout |
Keeps the page gutter, drops the max width. This is what to nest inside another container — a second .container would apply the gutter twice. | 04-layout.css:30 |
.container-lg |
deck.layout |
The same 76rem as the bare .container, named. Use it when a page should say which width it means rather than rely on the default. | 04-layout.css:24 |
.container-md |
deck.layout |
An article with a rail, or a form. | 04-layout.css:21 |
.container-sm |
deck.layout |
A single column of prose: about 70 characters at the body size, which is inside the maximum line length WCAG recommends. | 04-layout.css:19 |
.container-xl |
deck.layout |
A wide dashboard or a data grid. | 04-layout.css:26 |
.app-shell |
deck.layout |
Header, content, footer in a 100dvh grid whose middle row takes the remaining height — the sticky footer, without the hacks. dvh rather than vh so it tracks a phone browser hiding its chrome. It expects exactly three children. | 04-layout.css:222 |
.sticky-top |
deck.layout |
A sticky header. Where scroll-state container queries are supported the blur and shadow live on an ::after that is transparent until the header actually sticks, so in flow it looks like part of the page. The treatment rides on a pseudo-element because a scroll-state container can style its descendants but not itself. | 04-layout.css:183 |
Tokens
Both page-level spacings are clamps rather than breakpoint steps, which is why a Deck page has no width at which the margins suddenly jump.
| Token | Live | Declared value |
|---|---|---|
--space-gutter |
clamp(1rem, .55rem + 2.2vw, 2rem) |
|
--space-section |
clamp(2.5rem, 1.4rem + 5.5vw, 6rem) |
|
--z-sticky |
— | 100 |
--shadow-2 |
0 2px 4px oklch(20% .03 var(--shadow-color) / .05), 0 4px 12px oklch(20% .03 var(--shadow-color) / .07) |
Accessibility
-
A container is a
<div>with no role. Landmarks come from the elements around it —<header>,<main>,<nav>,<footer>. Putting.containeron the landmark itself is fine and often tidier. -
The line length cap is a readability feature.
.container-smat40remis about 70 characters at the body size, which is inside the 80-character maximum WCAG recommends for blocks of text. A full-width paragraph on a wide monitor is genuinely harder to read, not just less pretty. -
The gutter survives zoom. Because it is a
clamp()inremandvw, text never reaches the edge of the screen at 200% zoom — which is where a fixedpadding: 16pxstarts to look cramped. -
.sticky-topeats vertical space. At 400% zoom a sticky header can take a third of the screen. Consider@media (max-height: 30rem) { .sticky-top { position: static } }if your header is tall. -
A sticky header hides anchor targets. Following an in-page link
scrolls the target to the top of the viewport, which is underneath the header. Add
scroll-margin-block-startto headings equal to the header's height — Deck does not do this for you because it does not know how tall your header is. -
.app-shelland skip links. A three-row shell makes<main>easy to identify; give it anidand point a.skip-linkat it, as this documentation site does.
Right to left
margin-inline: auto and padding-inline are both
direction-agnostic, and .section's padding-block is
unaffected by direction. Nothing here needs an RTL rule — a centred, capped column is
the same shape in every writing direction.
Reduced motion
.sticky-top::after transitions its opacity over
190ms as the header sticks and unsticks.
Under prefers-reduced-motion: reduce the global reset collapses it to
.01ms, so the shadow appears immediately instead of fading in. Nothing
else on this page moves.
Printing
src/99-print.css removes the cap and the gutter from every container
variant — paper has its own margins, and a 76rem cap inside them would
waste an inch on both sides. .section loses its top padding and keeps
8mm at the bottom, so sections stay separated without a band of white at
the top of every page. .sticky-top is forced to
position: static, or the header would be stamped across every sheet.
Overriding it
/* One container, no layer needed */
<div class="container" style="--container:64rem">
@layer app.components {
/* A different default width for the whole site */
.container { --container: 68rem; }
/* A wider gutter on large screens only */
:root { --space-gutter: clamp(1rem, .55rem + 3vw, 3rem); }
}
Changing --space-gutter on :root moves every container,
every .scroller's bleed and every .panel's edge together,
because all three read the same token. That is the intended way to change a page's
horizontal rhythm.
When not to use it
-
Not inside another container. Two gutters. Use
.container-full, or set amax-inline-sizeon the child. -
Not for a component's own width. A card that caps itself at
76remis a card that behaves differently depending on how deeply it is nested. Components should fill what they are given; the container is the page's job. -
Not for centring one element.
.mx-autowith amax-inline-sizedoes that without adding a gutter you did not ask for. -
.sectionis not for spacing inside a component. Its padding reaches 6rem on a wide screen. Inside a card that is absurd; use.stack-6or.stack-8. -
.app-shellis not for a panel.min-block-size: 100dvhmakes it the height of the screen wherever you put it, so a shell inside a modal is a modal as tall as the viewport.