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