Spacing
A 4px grid, fifteen steps, and one job: make the relationship between two things visible before either of them is read.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Proximity
Both panels contain identical content. The left one uses two gap values; the right uses one. Only the left is scannable.
Density
Three densities, one component. Compact is for power users on a large screen; relaxed is for touch and for first-time users.
Optical vs mathematical
Equal padding on all four sides of a text block looks bottom-heavy, because descenders and line-height already add space below. Trim 1–2px off the bottom for optical balance.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Production
3 regions · 12 instances
One card, five distinct spacing decisions. The hatched region is the card’s own padding.
- Container padding20px
Larger than any internal gap, so the content reads as sitting inside the card rather than being clipped by it. 20px is the sweet spot for a 16px-radius card.
- Title → subtitle4px
The tightest gap in the card, because these two strings are one unit. Anything larger and the subtitle starts to read as separate metadata.
- Header → list16px
4× the title gap. That ratio is what makes the header a header rather than the first list item.
- Between list rows8px
Half the header gap. Rows are more related to each other than they are to the header, and the spacing says so.
- Footer separation20px + 1px rule + 16px
The divider gets more space above than below because the eye reads the rule as belonging to the content beneath it.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Spacing
| Token | Value | Used for |
|---|---|---|
| gap-1 | Label to required marker | |
| gap-1.5 | Icon to label in small controls | |
| gap-2 | Icon to label, chip rows, button rows | |
| gap-3 | Input padding, table cells | |
| gap-4 | Default gap between related elements | |
| gap-5 | Card padding, stacked form fields | |
| gap-6 | Subsections, dialog padding | |
| gap-8 | Distinct content blocks | |
| gap-12 | Page sections | |
| gap-14 | Documentation section rhythm | |
| px-6 / px-10 | Page gutters, mobile and desktop |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-xl | Card corners — pairs with 20px padding |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Label gap | When to use |
|---|---|---|
| Inline (xs) | 4–6px | Inside a single control: icon to label, label to badge. |
| Tight (sm) | 8px | Between siblings that form one visual object — a row of chips or buttons. |
| Related (md) | 12–16px | Between elements in the same group. The default. |
| Grouped (lg) | 20–24px | Between groups inside one section. Also standard card padding. |
| Section (xl) | 32–48px | Between distinct sections of a page. |
| Page (2xl) | 56–96px | Between major page regions, and above the footer. |
.list { display: flex; flex-direction: column; gap: 12px; }margin-top: -3px; /* looks right on my screen */Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Whitespace is not subject to contrast rules, but it is the cheapest way to improve legibility for low-vision users — more space around a block raises comprehension more than raising contrast past AA does.
- Where a divider would need to meet 3:1, extra spacing achieves the same separation with no contrast obligation at all.
Keyboard
| Tab | Spacing does not affect focus order, but visual grouping must match DOM order or keyboard users get a different structure than sighted users. |
Screen readers
- Spacing is invisible to screen readers. Every group the eye sees must also exist as a list, a fieldset, a section, or a labelled region.
- Do not use empty elements or non-breaking spaces to create space — they are announced and they pollute the reading experience.
Focus & touch
- Focus rings sit at 2px offset, so any element needs at least 4px of clear space around it or the ring is clipped by a neighbour. This is a real constraint on tight layouts.
- Adjacent touch targets need at least 8px of clear space between them, and 44 × 44 each. Two 36px buttons 4px apart is the most common target-size failure in real products.
| Attribute | Applied to | Notes |
|---|---|---|
| Text spacing override | User stylesheet | WCAG 1.4.12 requires the layout to survive line-height 1.5, paragraph spacing 2em, letter-spacing 0.12em and word-spacing 0.16em. Fixed-height containers are the usual failure. |
| role="group" + aria-labelledby | Visual groups | Spacing conveys grouping visually. Assistive tech needs the grouping expressed structurally as well — a fieldset, a list, or a labelled group. |
Example usage
1// Gap on the container, never margins on the children2<div className="flex flex-col gap-3">3 <Field label="Email" />4 <Field label="Password" />5</div>67// Group gap is 2x the item gap8<form className="flex flex-col gap-8"> {/* between groups */}9 <fieldset className="flex flex-col gap-4"> {/* within a group */}10 <Field label="First name" />11 <Field label="Last name" />12 </fieldset>13 <fieldset className="flex flex-col gap-4">14 <Field label="Company" />15 <Field label="Role" />16 </fieldset>17</form>1819// Container padding one step above the largest internal gap20<Card className="p-5">21 <div className="flex flex-col gap-4">…</div>22</Card>2324// Density as a token swap, not a second component25const pad = { compact: 'px-3 py-1.5', normal: 'px-3.5 py-2.5', relaxed: 'px-4 py-4' }26<td className={pad[density]}>{value}</td>CSS
/* The scale is Tailwind's default 0.25rem base — every step is a
multiple of 4px, so nothing can land off-grid by accident. */
.stack { display: flex; flex-direction: column; gap: 1rem; } /* 16 */
.stack--tight { gap: 0.5rem; } /* 8 */
.stack--loose { gap: 2rem; } /* 32 */
/* Optical padding: trim the bottom, because line-height already
contributes space below the last baseline. */
.card {
padding: 1.25rem 1.25rem 1.125rem; /* 20 20 18 */
border-radius: var(--radius-xl);
}
/* Page gutters scale with the viewport, content width does not */
.page {
padding-inline: 1.5rem; /* 24 on mobile */
margin-inline: auto;
max-inline-size: 76rem;
}
@media (min-width: 640px) {
.page { padding-inline: 2.5rem; } /* 40 on desktop */
}
/* Survive the WCAG 1.4.12 text-spacing override: no fixed heights
on anything that contains text. */
.callout { min-block-size: 3rem; block-size: auto; }Professional tips
- When a layout feels wrong but you cannot name why, measure the gaps. Nine times out of ten two things that belong together are further apart than two things that do not.
- Squint at the screen. Whitespace should resolve into clean rectangular groups. If you see one undifferentiated grey field, the gaps are uniform and the structure is invisible.
- Space is cheaper than borders. Before adding a divider, try doubling the gap — you usually get the same separation with less visual noise.
- Vertical rhythm matters more than horizontal. Users scroll, so an irregular vertical cadence is felt on every screen; an irregular horizontal one is often never noticed.
Performance
- gap on flex and grid is resolved during layout with no extra boxes. Spacer divs cost DOM nodes, layout time, and are announced by screen readers.
- Avoid animating padding or margin — both trigger layout on every frame. Animate transform: translate() instead, which stays on the compositor.
- In a virtualised list, put the gap in the item height calculation rather than as a CSS gap, or the virtualiser will mis-measure and rows will jitter during fast scroll.
Common mistakes
- Using margin-bottom on list items, then fighting the extra space after the last one with :last-child. gap solves this by construction.
- Setting a fixed height on a container with text inside. It fails WCAG 1.4.12 the moment a user increases line-height, and it fails localisation immediately.
- Collapsing margins between adjacent block elements producing a gap that is neither value. Flex and grid containers do not collapse margins, which is another reason to prefer gap.
- Treating padding as a way to hit a target size. Padding that only exists to reach 44px is invisible and inconsistent — use a pseudo-element overlay instead.
Real-world recommendations
- Adopt the 2× rule as a review checklist item. It is objective, it takes five seconds to check, and it catches most layout problems before they ship.
- For dense enterprise tables, ship compact as a preference but never as the default. New users need the relaxed version to learn the structure; power users will find the toggle.
- When a stakeholder says a page "looks empty", resist filling it. The usual fix is a narrower measure and a clearer hierarchy — the same content, better organised.
- Design the mobile gutters first. 16px is too tight on a modern phone and 32px wastes a third of a 360px screen; 24px is the value that survives contact with real content.