Vertical spacing on a page is not per-component guesswork. lib/rhythm defines one
fluid scale — three clamp() steps that grow with the viewport — and every Module draws
its top/bottom spacing from it. The result is consistent rhythm down the page and a
single place for the editor to override it.
Two words: gutter and rhythm
The system distinguishes two kinds of vertical space, and the vocabulary matters:
- gutter — spacing between and around Modules (the
margin-block/padding-blocka Module puts above and below itself). - rhythm — spacing between elements within a stack (the
row-gapinside a content stack, a list, a form).
Both are exposed as --vertical-* CSS custom properties, in three fluid sizes plus a
default:
// lib/rhythm.scss (excerpt)
--vertical-gutter-sm: clamp(1.5rem, 1.379vw + 1.19rem, 2.5rem);
--vertical-gutter-md: clamp(2.5rem, 3.448vw + 1.724rem, 5rem);
--vertical-gutter-lg: clamp(2.5rem, 6.897vw + 0.948rem, 7.5rem);
--vertical-rhythm-sm: clamp(0.5rem, 1.034vw + 0.267rem, 1.25rem);
--vertical-rhythm-md: clamp(1.25rem, -0.345vw + 1.578rem, 1.5rem);
--vertical-rhythm-lg: clamp(1.5rem, 1.379vw + 1.19rem, 2.5rem);
How a Module applies it
A Module declares its default spacing with default(), then commits those defaults to
real properties with apply():
// modules/Intro/Intro.scss
@use 'lib/rhythm' as rhythm;
.intro {
@include rhythm.default(sm, md, none, none);
@include rhythm.apply();
}
default($padding_top, $padding_bottom, $margin_top, $margin_bottom) sets the
--vertical-*-default custom properties (here: small top padding, medium bottom
padding, no vertical margin). apply() then writes the actual box properties, reading
each one with a fallback to its default:
// lib/rhythm.scss
@mixin apply() {
margin-block-start: var(--vertical-margin-top, var(--vertical-margin-top-default));
margin-block-end: var(--vertical-margin-bottom, var(--vertical-margin-bottom-default));
padding-block-start: var(--vertical-padding-top, var(--vertical-padding-top-default));
padding-block-end: var(--vertical-padding-bottom, var(--vertical-padding-bottom-default));
}
That two-property shape — a bare --vertical-margin-top overriding a
--vertical-margin-top-default — is the whole point.
Why defaults and overrides are separate variables. apply() reads the override
variable first and falls back to the default. The SCSS sets only the default, so the
editor can set the override without touching your stylesheet. When an author picks a
Spacing value, the Spacing Field emits --vertical-margin-top (etc.) inline on the
element, and it wins over the default the Module declared — no front-end change needed.
See Fields → CSS custom properties.
The rest of the toolkit
| Mixin | Use it for |
|---|---|
default($pt, $pb, $mt, $mb) |
Declare a Module's default gutter (padding/margin block). |
apply() |
Commit the gutter variables to real box properties. |
stack($size) |
A vertical flex column with row-gap: var(--vertical-rhythm-#{$size}) — rhythm within a stack. |
pile() |
Overlap children in one grid cell (grid-template-areas: 'pile'). |
gutter($location, $type, $size) |
Apply a single gutter value to one edge (top / bottom / both) as margin or padding. |
negate($location) |
Pull back a gutter with a negative margin (e.g. to bleed a child full-width). |
stack is how you get in-flow rhythm between elements:
// lib/rhythm.scss
@mixin stack($size: default) {
display: flex;
flex-direction: column;
row-gap: var(--vertical-rhythm-#{$size});
}
Reach for gutter mixins (default/apply/gutter/negate) when you're spacing a
Module against the page, and stack when you're spacing elements inside a
component. Mixing them up — e.g. adding a gutter to get gap between list items —
breaks the editor's Spacing control, because the Spacing Field only drives the gutter
variables.
Related
- The
lib/directory — whererhythmlives and how it's imported. - Fields → CSS custom properties — how the Spacing Field overrides the
--vertical-*variables. - SCSS architecture — colocated component styles that
@usethis scale.