Owns the Thursday release train.
Actual CSS
Visual guide
Twenty figures on the three things everything else is built from: the spacing scale, the layout primitives, and the difference between rhythm, a density context and a local size. Every bar, gap and control height below is drawn from a live token, so switching the theme redraws the diagrams rather than invalidating them.
Chapter one
Space
One scale, six steps, and a naming rule that keeps the rest of the scale available to you. What the numbers are matters much less than the fact that there are only six of them.
The spacing scale
Six official steps. Each bar below is exactly as long as the token it names — no redrawing, no approximation.
--space-10
0.25rem
--space-20
0.5rem
--space-30
0.75rem
--space-40
1rem
--space-50
1.5rem
--space-60
2rem
--space-15
yours
--space-45
yours
Actual CSS will never define a --space-* that is not a
multiple of ten. Every intermediate name is reserved for your
application, and no future framework step can ever collide with it.
Gap is the relationship
The same three rows, three times. Nothing changed but the gap, and the gap alone says whether these are one thing, a group, or three unrelated sections.
Smaller gaps imply a stronger relationship. Keep the relative
hierarchy when you nest: set a local --gap on the inner
rhythm rather than letting one inherited value flatten every level.
Space before chrome
Both columns group the same six rows into three pairs. The left one draws a rule to say so; the right one lets the space say it.
Before adding a divider, check whether more space already draws the boundary. Chrome is what you add when spacing genuinely cannot carry the meaning — not the first move.
The rules on the left are plain <hr> elements
inside a .stack, and their spacing comes from the
stack's --gap, not from their own margin: a layout
primitive owns the distance between its children. Outside a layout,
an hr keeps its own block rhythm and
--hr-space tunes it.
Two owners, one page
The hatched ring inside each card is the card's own padding: the component owns it and no layout may reach in. The hatched band between the cards is the gap: the layout owns it and no component may encode it as a margin.
Components own their internal rhythm; layout owns the relationships between components. This is why the layout primitives reset the block margins of their direct children — the distance between two siblings belongs to their parent.
Chapter two
Primitives
A handful of classes covering the relationships that actually recur. Pick one from the relationship between the children, not from how many children happen to be in the mockup.
.stack — one axis, one gap
Vertical flow. Direct children become flex items, their block margins
are reset, and the whole rhythm comes from a single
--gap.
Deployment
Version 0.3.0 is live in production.
Healthy<div class="stack">
<h4>Deployment</h4>
<p>Version 0.3.0 is live in production.</p>
<span class="badge success soft">Healthy</span>
</div>
--gap is inherited. A region that composes several
primitives sets its own rhythm on the gap
property, because setting the token would re-space
every nested stack, cluster and grid at any depth.
.cluster — a row that wraps
The same four actions in a wide stage and a narrow one. A cluster wraps instead of overflowing, and its alignment is a hook rather than a set of variants.
$29 / month
The cluster is the row: actions, tags, a row of links. On a
ul, ol or menu it removes the
markers and the indent and keeps the list for assistive technology,
so no separate "actions" or "nav" class is needed.
Alignment is a utility on that one row —
.justify-content-space-between for a split,
.items-baseline when a price and its period should share
a text line rather than a box center. The hooks
(--cluster-justify, --cluster-align,
--cluster-wrap) belong to the row they are set on and
stop at every nested cluster: set one on a recipe class composed
onto that row.
.grid fills space, .switcher switches together
Three peers, first with room to spare and then in a stage too narrow for all of them. The grid fits as many tracks as the space allows and leaves an orphan on the last row; the switcher refuses to split the group at all.
Use .grid for collections where an incomplete final row
is fine: cards, products, results, galleries. Use
.switcher for peer regions that only make sense side by
side or not at all.
.grid-N collapses through divisors
The same .grid-6 in two query containers. Every state it
passes through is a divisor of six, so it is never caught halfway
with a lone item on the last row.
The chain is 6 → 3 → 2 → 1: six columns again at 64rem, and one
column below 28rem. .grid-4 goes 4 → 2 → 1,
.grid-3 goes 3 → 1. Do not fill in the missing integers:
they are exactly the states that produce a 5 + 1 row.
Subdivision is keyed on an ancestor named
actual-container — the .container-query
wrapper around each grid above, sized so the query measures exactly
the width its label claims. Without such an ancestor the responsive
baseline still applies; the container only adds precision.
.center bounds the page, .measure bounds the line
Two limits that are constantly confused. One decides how wide the column of content is; the other decides how many characters fit on a line of prose. They are different jobs, and they compose.
A line this long is comfortable to read: the eye finds the start of
the next line without hunting for it. Past roughly seventy
characters it starts to lose the return sweep, which is the whole
reason the limit exists — the dashed edge on the right is
--measure.
.center is content-box sized, so
--center-pad adds gutters outside the measure instead of
eating into it. Put .measure on the prose, not on the
page wrapper — a dashboard column and a paragraph do not want the
same limit.
.media — fixed leading, flexible trailing
Exactly two children: something with an intrinsic width, then
everything else. It replaces the auto minmax(0, 1fr)
grid that otherwise gets rewritten in every author card, comment and
meta row.
main — the trailing column takes whatever is left, and wraps rather than pushing the avatar around.
Choosing one
Read the left column as a question about the children's relationship. If none of them fits, that is a signal to compose two primitives, not to invent a third.
| The children are… | Primitive |
|---|---|
| A vertical flow with one rhythm | .stack |
| Inline actions, tags or links that may wrap | .cluster |
| A collection where a partial last row is fine | .grid |
| A known structural density, at most N columns | .grid-N |
| Peer regions that must switch together | .switcher |
| Main content followed by a secondary region | .sidebar-layout |
| Fixed media followed by flexible content | .media |
| Regions whose placement is itself the design | .column-layout |
Components hand their rows to primitives
A component owns its anatomy — surface, padding, where a slot sits. The row inside a slot is geometry, so a primitive on the slot lays it out.
<footer class="cluster justify-content-end">…</footer>
<div class="cluster form-actions">…</div>
The card keeps its padding and pins its footer to the bottom; the
header becomes a .media row and the footer a
.cluster pushed to the end. A bare
<header> or <footer> still
gets a split row by default — it yields to whatever you put on it.
.form-actions is a role, not a layout: the cluster owns
the row, the role adds the form's alignment policy and its sticky
variant. The distance to the fields comes from the form's
.stack, never from a margin on the row.
Chapter three
Rhythm, density and size
Three mechanisms that look alike from the outside and behave nothing alike. Rhythm is how close things sit. Density is how much room a whole region gives everything in it. Size is a local role that changes what a single component is.
Rhythm is the gap alone
The same fragment in all three columns, one class on the wrapper. The buttons and the field never move; only the distance between them does.
<div class="tight stack">
<div class="cluster">
<button class="btn primary">Save</button>
<button class="btn neutral outline">Cancel</button>
</div>
<input class="input" value="Project name">
</div>
.tight and .loose set --gap and
nothing else. The token is inherited, so the nested cluster closes
up too — the whole fragment keeps one rhythm. Put one rhythm class
on an element; a nested one wins, exactly like density.
Density is a context
Byte-for-byte the same fragment again, and still one class on the wrapper. This time the controls answer as well: the gaps follow the same two steps, and the buttons and the field change height and inner padding with them.
<div class="compact stack">
<div class="cluster">
<button class="btn primary">Save</button>
<button class="btn neutral outline">Cancel</button>
</div>
<input class="input" value="Project name">
</div>
What the context actually sets
Four tokens, and that is the whole mechanism. Components read whichever of them makes sense for what they are.
| Token | .compact | default | .spacious |
|---|---|---|---|
--gap |
--space-20 |
0.75rem | --space-50 |
--density-space |
--space-20 |
--space-40 |
--space-50 |
--control-size |
2rem | 2.375rem | 2.75rem |
--control-pad-x |
-sm step |
-md step |
-lg step |
sm2rem
md2.375rem
lg2.75rem
Controls consume --control-size and
--control-pad-x, so a compact button is shorter
and narrower; the spacing helpers and
.choice-card consume --density-space; layout
primitives consume --gap. Nothing consumes a fifth
thing, which is why density stays predictable.
.tight and .loose write the first row of
this table and leave the others alone. That is the entire
difference between rhythm and density.
Closer, or denser?
Two toolbars that both ask for less space. The left one wants its items closer together. The right one is a dense working region.
Both rows sit on the same gap. Only the right one also shrinks its controls, because density is a statement about the whole region, not about the space between its items.
Ask what the design changed. Items closer together, everything else
as drawn: .tight. A denser workspace — toolbar, table
filters, an admin side panel: .compact. Reaching for
.compact to fix a gap is how buttons end up smaller
than anyone asked for.
Density moves geometry. Size moves geometry and type.
This is the whole distinction. In the top row the buttons change height and padding and the label stays put. In the bottom row the label changes with them.
A density context is about how much room a region has. A
.sm or .lg is about what one component is
for: a small button is a smaller thing, label included.
Density is opt-in, not a global multiplier
The same set of components at default and inside a
.compact context. The button and the input tighten; the
badge, the alert and the card are identical in both columns.
Geometry and rhythm — controls, spacing helpers, layout primitives.
Rhythm only — content rows such as
.list: the size holds, the inner gap follows. For
these, .compact and .tight look the same.
No effect — badge, alert, card, dialog, table,
accordion, navbar. Inline choices
(.check, .radio, .switch) are a
no-op too, and deliberately: they sit on a line of text, so shrinking
the box without the label would break the alignment density was never
asked to change.
A local size beats an inherited context
Both buttons sit in the same .spacious panel. The second
one carries .sm, declared on the component itself, so it
wins over the density it inherits.
No !important, no specificity trick: the size class is
declared on the sized element, the density on an ancestor, and the
nearer declaration is the one that resolves.
Tune the dimension, not the number
When one dimension needs adjusting, set that dimension's token on the instance. Reaching for a raw value instead is how a system quietly stops being one.
The left column has three measurements and no relationships: 7, 13
and 19 all mean "a bit of space", and none of them will ever line up
with anything else on the page. The right column has two, and the
fact that the third gap is the larger one is the whole message.
Those two steps have names: .tight and
.loose.
Tune a dimension where it is consumed — a rhythm class or
--gap on the layout instance,
--density-space on the spacing helper — rather than
writing a raw value or adding one more family of utility classes for
everyone else to learn.