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.

The section carries the background, the container carries the width

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.

Container widths
Class--containerWhat 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.

Both centred, both gutter-padded, different caps

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.

Classes, generated from the stylesheet source
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.

Design tokens this component reads
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 .container on the landmark itself is fine and often tidier.
  • The line length cap is a readability feature. .container-sm at 40rem is 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() in rem and vw, text never reaches the edge of the screen at 200% zoom — which is where a fixed padding: 16px starts to look cramped.
  • .sticky-top eats 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-start to 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-shell and skip links. A three-row shell makes <main> easy to identify; give it an id and point a .skip-link at 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 a max-inline-size on the child.
  • Not for a component's own width. A card that caps itself at 76rem is 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-auto with a max-inline-size does that without adding a gutter you did not ask for.
  • .section is not for spacing inside a component. Its padding reaches 6rem on a wide screen. Inside a card that is absurd; use .stack-6 or .stack-8.
  • .app-shell is not for a panel. min-block-size: 100dvh makes it the height of the screen wherever you put it, so a shell inside a modal is a modal as tall as the viewport.