Layout
elements man style/layout Read as markdownLayout primitives are composition containers. This is layout.css in
@layer components.
The composition contract
One rule governs spacing in the system:
Layout containers own spacing. Components never set outer margins.
No component in the design system puts a margin on its outer element. If you
need space between two things, wrap them in a .stack or .cluster and let the
container's gap do it. This keeps components portable. The same card works
inside any layout because it never assumes a margin.
Flow primitives
.stack: vertical flow with a consistent gap between children. Gap variants
is-xs / is-sm / is-md / is-lg / is-xl / is-2xl step the gap up.
<div class="stack is-lg">
<h2>Account</h2>
<p>Manage your profile and billing.</p>
<button class="is-primary">Save</button>
</div>
.cluster: horizontal flow that wraps, vertically centered. For rows of
tags, buttons, or metadata that should wrap on narrow screens. Gap variants
is-xs, is-sm, is-md, is-lg, is-xl.
.row: horizontal flex that does not wrap. Alignment variants
is-baseline / is-start / is-end / is-stretch (default is centered).
All three clear their children's block margins, since the gap does the
spacing. They leave the element's own padding alone, so a .stack can go
straight on a <fieldset>, a card, or a panel. On a <ul> or <ol> they also
drop the list markers and indent, for a nav or a menu built from a list.
Page structure
.page-shell: a width-constrained, centered page container with a gutter.
Width variants:
Every width is spelled out. Pick by the number, not by the word: is-narrow
is a phone-width column, and it is the wrong choice for most pages.
| Variant | Max width | Use for |
|---|---|---|
is-narrow |
--container-sm (24rem / 384px) |
A sidebar or a single narrow card. Not a page. |
is-form |
--container-2xl (42rem / 672px) |
A form, a list, a settings page. The usual choice for an app page. |
is-prose |
--container-prose (65ch) |
Running text. |
is-marketing |
--container-5xl (64rem / 1024px) |
A landing page. |
| (default) | --container-shell (72rem / 1152px) |
A general app page. |
is-wide |
--container-wide (80rem / 1280px) |
Tables and dashboards. |
is-full |
none | Edge to edge. |
.page-hero: a hero block at the top of a page with generous top/bottom
padding; is-sm / is-lg adjust it. It also sets rhythm for a direct
.eyebrow, h1/.display, and .lede inside it, and constrains the lede to a
readable measure.
.section: a vertical-rhythm block between content areas; is-sm / is-lg
adjust the padding. Adjacent sections (.section + .section) get a soft top
rule automatically, so stacked sections divide themselves.
<main class="page-shell is-prose">
<div class="page-hero">
<p class="eyebrow">Changelog</p>
<h1 class="display">What's new</h1>
<p class="lede">Recent releases and fixes.</p>
</div>
<section class="section">…</section>
<section class="section">…</section>
</main>
App shell
.app: a full-height shell for an application layout (as opposed to a
content page). Its .app-main, or a bare <main>, takes the spare height
wherever it sits, and everything else keeps its content height. A header above
main, a footer below it, or both need nothing more:
<div class="app">
<header>…</header>
<main class="app-main">…</main>
<footer>…</footer>
</div>
.app.has-sidebar: adds a sidebar column. The width comes from
--sidebar-width (default 240px); set that token to change it. The layout
collapses to a single column at 48rem. .app-main is the main content column
(a flex column that won't overflow its track).
<div class="app has-sidebar" style="--sidebar-width: 280px">
<aside>…</aside>
<div class="app-main">…</div>
</div>
.app.is-fixed: the shell is exactly the viewport and never grows, so a
row inside it scrolls on its own. Reach for it when a region scrolls while the
chrome around it stays put: a message log under a header with a composer
pinned below, a table under a toolbar. Put overflow-y: auto on the element
that scrolls, and the rest follows.
<div class="app is-fixed">
<header>…</header>
<div class="app-main">
<ul class="feed" style="overflow-y: auto">…</ul>
</div>
<footer>…</footer>
</div>
Without it the shell is min-height, so it grows with its content: the log
runs past the bottom of the window, the composer goes with it, and the feed
never scrolls because it was never bounded. is-fixed also lets the main
region shrink below its content (min-height: 0), which is the part that is
easy to miss when writing this by hand.
.app.has-sidebar is a layout grid hook, not a sidebar component. It gives
you the two-column grid and the responsive collapse. What goes in the sidebar
(navigation, its styling, any collapse toggle) is yours to build; v1 ships no
sidebar component.
Helpers
.center: flex-center on both axes; drop something in the middle of its container..divider: a visible horizontal rule (a filled 1px bar, not a border). Variants:is-soft/is-strongchange the weight, andis-verticalturns it into a vertical divider that stretches inside a.rowor.cluster.
<div class="row">
<span>Draft</span>
<span class="divider is-vertical"></span>
<span>Edited 2m ago</span>
</div>
Sizing media
aspect-ratio and max-height on the same block element do not clamp the way
they read. The browser derives the width from the clamped height, so the box
comes out narrower rather than shorter. A banner given
aspect-ratio: 21/9; max-height: 32rem renders 1195px wide inside a 1440px
page, not full bleed:
/* narrows the box instead of cropping it */
.banner { aspect-ratio: 21 / 9; max-height: 32rem; }
/* fixes the width, lets the image crop */
.banner { width: 100%; height: 32rem; }
.banner img { width: 100%; height: 100%; object-fit: cover; }
Give the box its width, or its height, and let object-fit handle the rest.