Actual CSS

Visual guide

Seventeen figures on the three things everything else is built from: the spacing scale, the layout primitives, and the difference between 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

Hooks: --cluster-align, --cluster-justify, --cluster-wrap. A header with a title on one side and actions on the other is a cluster with --cluster-justify: space-between, not a new primitive.

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

Chapter three

Density and size

Two mechanisms that look alike from the outside and behave nothing alike. Density is an inherited context that changes how much room things get. Size is a local role that changes what a single component is.

Figure 

Density is a context

Byte-for-byte the same fragment in all three columns. The only difference is one class on the wrapper — nothing is repeated on the children.

.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

Three 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-size, drawn at its real height
sm2rem
md2.375rem
lg2.75rem

Controls consume --control-size; the spacing helpers and .choice-card consume --density-space; layout primitives consume --gap. Nothing consumes a fourth thing, which is why density stays predictable.

Figure 

Density moves geometry. Size moves geometry and type.

This is the whole distinction. In the top row the buttons change height 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.

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.

Tune a dimension where it is consumed — --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.