Content Rhythm
Space content by role, not by eye — one escalating scale from tight text pairings to section breaks, and when to reach for gap, padding, or margin.
Every vertical gap between content — a heading and its subtitle, two paragraphs, one block and the next, one section and the next — comes from a single escalating scale keyed to the relationship between the two elements, not from a per-page judgment call. Pick the step by what the elements are to each other; the token re-modulates automatically under each spacing mode, so the rhythm holds across compact product UI and spacious marketing pages without re-tuning.
The rhythm scale
Tighter means more coupled. As the relationship loosens — words in a heading pair, sentences in a paragraph, blocks in a section, sections on a page — the step widens by one notch.
| Relationship | Intent | Token | Base |
|---|---|---|---|
| Title ↔ subtitle / eyebrow | Tightly coupled — reads as one unit | --gap-sm | 6px |
| Heading / subtitle ↔ body | Readable separation | --gap-md | 8px |
| Paragraph ↔ paragraph | Distinct thoughts | --gap-lg | 16px |
| Block ↔ block | Separate content units | --gap-xl | 24px |
| Section ↔ section | Page-level break | padding-block: clamp(--padding-xl, 7vw, --padding-huge) | 32–48px, fluid |
Never use --gap-xs or --gap-tiny in any vertical rhythm position — content or component. Both collapse to 0px outside the default spacing mode, which erases the step entirely. And never hardcode a px value: the scale re-modulates per mode, a literal does not. The lint-content-rhythm gate enforces both rules on component CSS.
Component rhythm
The same discipline extends one layer up: vertical space between components is also keyed to the relationship, not to the component (ADR-024).
| Relationship | Intent | Token | Base |
|---|---|---|---|
| Label ↔ the control it names | Reads as one unit — meter label ↔ bar, stacked field label ↔ value | --gap-sm | 6px |
| Row ↔ row in a dense container | Menu options, list items, bullet items (comfortable density steps up one) | --gap-sm | 6px |
| Control ↔ control in a cluster | Footer actions, a filter-chip row | --gap-md | 8px |
| Field ↔ field in a form | Distinct inputs, one flow | --gap-lg | 16px |
| Card ↔ card in a grid or list | Separate bounded units | --gap-lg | 16px |
| Banner ↔ banner in a group | A queue the reader clears, not peers they compare — one step tighter than cards so the stack reads as one notice block (banner groups) | --gap-md | 8px |
| Container ↔ container | Same as block ↔ block above | --gap-xl | 24px |
Layout primitives (Stack, Grid, Cluster) stay generic — they can't know which relationship they're arranging, so this table governs which gap value you pass, not the primitive. Horizontal gaps (icon ↔ label, chips in a row) are outside this table; --gap-xs remains legitimate there.
Gap, padding, or margin
The scale above answers how much. This answers with which property — and the answer shifts as you move up the layers.
| Mechanism | Controls | Reach for it when | Token family |
|---|---|---|---|
gap (flex / grid) | Uniform space between sibling elements | One repeating step between peers — blocks in a Stack, cells in a Grid | --gap-* |
Adjacency margin (a + b) | Exact space between two specific elements | Each pair needs a different step — the text slots inside a Block, elements inside Prose | --gap-* |
padding | Inset from a container's own edge | Space between content and the box boundary; a section's vertical band | --padding-* |
The distinction the layers turn on: gap is between siblings; padding is the edge. A section break is padding, not gap — each section owns its own padding-block band, sections don't sit in a gapped flow. So block-to-block spacing inside a section is the layout primitive's gap, but section-to-section spacing is each section's padding.
Adjacency margin covers the one case a single flex gap cannot: a Block's title → subtitle is --gap-sm while its subtitle → description is --gap-md — two different steps between three stacked elements. ContentBlock sets each with margin-top rather than one uniform gap (ADR-023); Prose spaces h + p and p + p differently for the same reason.
Adjacency margin is the only place margin sets vertical rhythm. Never use margin to space one component from another — that space belongs to the parent layout's gap.
The section separator
The section component owns the separator between sections. A hand-placed <Divider /> between two sections is duplicate chrome — the line is already there.
On a product page, consecutive DataSection siblings draw their own rule and their own space from one adjacency selector: --padding-lg of margin-top, the same again as padding-top, and a --border-width-md --border-muted top border. That is the identical token treatment <Divider /> renders, so placing one between two DataSections paints the line twice. SheetSection takes the spacing half of the same rule (margin-top: var(--padding-lg)) and deliberately draws no border — a sheet body is already a bounded surface.
| Between | Separator comes from | Hand-place a Divider? |
|---|---|---|
Two DataSection siblings | The adjacency rule on DataSection | No — it is already drawn |
Two SheetSection siblings | The adjacency rule on SheetSection, spacing only | No — a sheet body needs no rule |
| A section and a non-section block | Nothing — the sibling selector does not match | Yes, this is the one correct case |
The sibling selector is what makes this automatic and what makes the exception real. Insert any non-section element between two sections and the adjacency breaks, so the second section loses its rule — that gap is the only place a hand-placed Divider belongs. If you find one anywhere else, delete it rather than tuning the space around it.
Where each step is set
You rarely set these values by hand — the standard is baked into the layer that owns each relationship.
- Text within a block →
ContentBlock(named slots) orProse(free HTML) own it. See Content blocks and prose. - A section intro (centered title + description) →
SectionHeader. It composesContentBlockfor this rhythm and adds the one thing rhythm alone doesn't: the horizontal measure that caps and centers the text column so intros stop being 600px on one page and full-width on the next. See Content measure. - Block ↔ block → the layout primitive's
gap(Stack,Grid) —--gap-xlin a section's content region. - Section ↔ section → the section shell's
padding-blockclamp, harmonised to7vw(ADR-021). A section needing a different band re-declarespadding-blockon its own selector.
Related
- Display Choice — which display holds the content this page then spaces.
- Spacing — the
--gap-*/--padding-*token scales and the density modes that modulate them. - Composition Layers — the Section → Layout → Container → Block or Control → Component model these adjacencies span.
- Headings — the heading roles these steps sit above.
- Paragraphs — the body roles the paragraph steps space.
- Page Grid — the horizontal axis: the width-container recipe and the page gutter.
- Page Structure — the HTML skeleton the rhythm applies within.
- Typography — the type scale for the content being spaced.