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.

Figure 

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
The gaps in the numbering are deliberate
--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.

Figure 

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.

--space-20 · one thing
Name
Role
Team
--space-40 · a group
Name
Role
Team
--space-60 · three sections
Name
Role
Team

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.

Figure 

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.

Ruled
Overview
Activity

Members
Invitations

Billing
Invoices
Spaced
Overview
Activity
Members
Invitations
Billing
Invoices

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.

Figure 

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.

Card content
--gap
Card content

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.

Figure 

.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.

Figure 

.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.

Room to spare
Docs
Out of room
Docs
A list stays a list
One left, one right
Deployments
Mixed sizes, one baseline

$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.

Figure 

.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.

Room for three — both recipes agree
Region A
Region B
Region C
.grid · --grid-min: 9rem
Region A
Region B
Region C
.switcher · --switcher-threshold: 34rem
Region A
Region B
Region C

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.

Figure 

.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.

Container 48rem → 3 columns
1
2
3
4
5
6
Container 28rem → 2 columns
1
2
3
4
5
6

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.

Figure 

.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.

.center · max-inline-size + auto margins
auto
--center-size
auto
.measure · the reading limit

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.

Figure 

.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.

AR
Avery Rhodes Pushed 4 commits to main — the trailing column takes whatever is left, and wraps rather than pushing the avatar around.
Figure 

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
Figure 

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.

Card slots
AR
Avery Rhodes Release manager

Owns the Thursday release train.

A role on a cluster
<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.

Figure 

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.

.tight
default
.loose
<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.

Figure 

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.

.compact
default
.spacious
<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>
Figure 

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
--control-size, drawn at its real height
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.

Figure 

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.

.cluster.tight — closer
.cluster.compact — denser

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.

Figure 

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.

Density context — geometry only
Local size — geometry and typography

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.

Figure 

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.

default
Badge
Alert
Card padding is its own contract.
.compact
Badge
Alert
Card padding is its own contract.

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.

Figure 

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.

Figure 

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.

Three numbers, no scale
Filters
7px
Results
13px
Pagination
19px
Footnote
Two steps, one hierarchy
Filters
--space-20
Results
--space-20
Pagination
--space-50
Footnote

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.