Learning Track
Filter content by your role to see the most relevant sections
Spacing & Sizing Foundations
Build layout rhythm you can trust: a modular spacing scale, density modes that rescale an entire product, sizing tokens for components, and the accessibility floors—44px touch targets—that spacing must never violate.
Why This Matters
Whitespace is the cheapest material a designer has, and the most commonly squandered. When every screen invents its own margins, users experience the product as subtly untrustworthy—nothing lines up, nothing rhymes, and scanning costs more effort than it should. A spacing scale replaces those thousands of micro-decisions with eleven numbers everyone shares.
Spacing also carries accessibility obligations in a way that is easy to miss: it governs whether controls are large enough to tap, whether text has the breathing room to stay legible, and whether related things read as related. And unlike color, spacing has a second dimension most systems skip—density—the ability to rescale an entire interface for a data-dense console or a spacious marketing page without redesigning either.
This page covers the spacing system as built in this repository: the modular size scale in ui/designTokens/core/spacing.tokens.json, the density mapping that makes semantic spacing resizable per brand and mode, and the minimum-dimension tokens that keep touch targets legal.
Core Concepts: Scale, Density, and Floors
A Modular Scale, Not a Ruler
The core scale defines eleven steps, spacing.size.00 through spacing.size.10:
// ui/designTokens/core/spacing.tokens.json (values)
size.00 = 0px size.04 = 8px size.08 = 32px
size.01 = 1px size.05 = 12px size.09 = 48px
size.02 = 2px size.06 = 16px size.10 = 64px
size.03 = 4px size.07 = 24pxThe shape is deliberate: two hairline steps (1px, 2px) for borders and dividers, then a roughly geometric climb from 4px that keeps every step at least +4 and never more than ×2 apart. That last property is what makes the scale composable—if 16px is right for card padding and 24px for section gaps, the jump reads as intentional hierarchy rather than a different system. A linear scale (2, 4, 6, 8…) can't do that; by step seven its steps are perceptually identical, and by step twelve they are uselessly far apart.
In CSS the scale emits as custom properties— --core-spacing-size-04: 8px and siblings—and component styles reference them (or their scoped aliases) rather than literals.
Density: The Second Axis
A single scale answers "how much space?" but not "how tight is this product?" That is density's job. The core layer defines four density scales—tight, compact, default, and spacious—each a complete set of slots (xs…2xl) at different magnitudes:
// density slot values per scale (xs sm md lg xl 2xl)
tight: 2 4 6 8 12 16
compact: 4 8 12 16 20 24
default: 8 16 24 32 40 48
spacious: 12 20 32 40 56 64Semantic spacing references these scales, not the raw sizes: spacing.semantic.stack is {spacing.density.default.sm}—16px today, and 4px automatically in a tight-density brand. This is the mechanism behind the density cascade layer you can see in the generated stylesheet: the ocean brand ships spacious, midnight ships tight, and every component that went through semantic spacing rescales without a single edit. Components that hardcoded size.05 stay fixed and quietly break the density story.
Minimum Targets Are Not Opinions
Two dimension tokens set floors that spacing must respect rather than tune: dimension.tapTargetMin is 44px—the WCAG 2.5.5 / 2.1-style minimum for pointer targets—and dimension.actionMinHeight is 36px for primary action controls. The pattern these enable: a compact icon button can be visually 32px (the icon size plus padding) while its hit area extends to 44px via padding or a pseudo-element. The visual size lives in spacing tokens; the legal size lives in the dimension floors; and a component that violates the floor to look sleeker is shipping an accessibility defect, not a style choice.
Sizing Components from the Scale
Component dimensions come from the same scale rather than their own numbers: a Badge's height is a scale step, a Card's padding is a scale step, and control heights align to actionMinHeight plus padding steps. The payoff is alignment: when paddings, gaps, and heights are all scale multiples, text baselines line up across adjacent components without anyone trying to make them.
System Roles: Where Spacing Decisions Land
Design Impact
Designers own rhythm—the choice of which steps mean what. A durable convention assigns each step a role: 4/8px for inside-component gaps, 12/16px for related-content grouping, 24px for separation between groups, 48/64px for page-level sections. The rule of thumb is proximity encodes relationship: things 8px apart read as one thing; things 48px apart read as different things; nothing in between reads as uncertain.
Engineering Impact
Engineers own the plumbing: gap properties referencing scale variables, container queries and breakpoints consuming the scale (see Layout), and the lint discipline that keeps literals out of styles. The mechanical win of the scale is that vertical rhythm becomes arithmetic—stacks of scale steps land on scale steps.
Accessibility Impact
Accessibility owns the floors and the side effects: targets at or above 44px, adjacent controls separated enough to prevent mis-taps, and text line height left to the typography tokens rather than compressed for density's sake. Density modes get audited against the same floors—tight does not mean below the minimum.
Design & Code Interplay
In the design tool, the spacing system appears as a named set of styles a designer applies instead of dragging: 4 and 8 for in-component, 12 and 16 for grouping, 24 for sections' inner rhythm, 48 and 64 for page sections. The Figma side of density is mode-paired like color—each semantic spacing style has four density variants—so a designer can preview the tight brand by switching a mode, not by re-spacing the file.
The checklist mentality travels with it: a design review that asks "which step is this gap?" catches drift before it reaches code, exactly the way asking "which token is this color?" does for color.
In code, the scale emits as core custom properties and components consume scoped aliases; density rides the cascade layer:
/* Generated: app/designTokens.scss */
@layer core, semantic, theme, brand, density;
@layer core {
:root {
--core-spacing-size-03: 4px;
--core-spacing-size-04: 8px;
--core-spacing-size-06: 16px;
}
}
/* Semantic spacing references density, not raw sizes */
@layer semantic {
:root {
--semantic-spacing-stack:
var(--core-spacing-density-default-sm); /* 16px */
}
}
/* A dense brand rescales everything that went semantic */
@layer density {
[data-density='tight'] {
--semantic-spacing-stack:
var(--core-spacing-density-tight-sm); /* 4px */
}
}And the touch-target floor as a component pattern:
/* Visual size from the scale; legal size from the floor */
.iconButton {
width: var(--ds-icon-button-size, 32px);
height: var(--ds-icon-button-size, 32px);
/* hit area grows without changing the visual box */
position: relative;
}
.iconButton::after {
content: '';
position: absolute;
inset: calc((var(--core-dimension-tap-target-min, 44px) - 100%) / 2);
}Applied Example: Spacing a Card List Screen
Take a plain screen—a page of cards with a heading—and make every spacing decision once, with the system:
- Page gutter:
size.06(16px) on mobile, stepping tosize.08(32px) at the medium breakpoint—the only two responsive spacing decisions the screen makes; everything else is constant. - Section separation: heading block to card list is
size.07(24px)—same-group distance, because the heading describes the list. - Card internal rhythm: padding
size.05(12px) in tight contexts,size.06(16px) default; title-to-body gapsize.03(4px)—inside-component scale. - Card-to-card gap: the stack gap—semantic
spacing.semantic.stack, which is density-mapped—so a dense dashboard variant tightens the list automatically. - Floors check: the card's ghost action has a 32px visual box; its hit area extends to 44px via the pseudo-element pattern. Nothing on the screen is below
tapTargetMin.
Five decisions, all scale references, one density-aware gap, two floors respected. The screen now matches every other screen that made the same five decisions the same way—which is the entire point.
Constraints & Trade-offs
- Eleven steps vs freedom: a fixed scale removes the 13px option, which is the point—the 13px was always a rounding error. Escape hatch: a new step with review, not an inline literal.
- Density-aware vs predictable components: when gaps are density-mapped, a component's look varies by brand. That is the feature; the cost is that visual regression tests must run per density, not once.
- Floors vs sleekness: 44px targets look chunky next to 24px typography. The resolution is separating visual size from hit area—not quietly shrinking the target.
- One scale for padding and gaps: sharing the scale keeps arithmetic aligned but means the "gap scale" and "padding scale" can never drift apart. Teams that want independent scales usually want density, which already exists here.
Common Pitfalls & Failure Modes
1. The one-off literal
/* Bad */
.card { padding: 14px; }
/* Good */
.card { padding: var(--ds-card-space-padding, 12px); }14px is 12px plus a hunch. Every literal re-opens the decision the scale closed.
2. Hardcoding density-mapped gaps
/* Bad: Opted out of density silently */
.list { gap: var(--core-spacing-size-06); }
/* Good: Rides the density mode */
.list { gap: var(--semantic-spacing-stack); }3. Shrinking targets to save space
/* Bad: 28px target in a dense table row */
.rowAction { width: 28px; height: 28px; }
/* Good: Small visual, legal hit area */
.rowAction { width: 28px; height: 28px; position: relative; }
.rowAction::after { content:''; position:absolute;
inset: calc((44px - 100%) / 2); }4. Meaning-free step choice
When 20px and 24px both exist in a screen with no role difference, the scale has become a palette instead of a rhythm. The repair is assigning steps to meanings (component / group / section) and reviewing against the assignment.
Spacing System Health Metrics
Spacing health is the most measurable foundation quality—the data is a grep away—and three signals cover the risk:
Signal 1: Literal coverage
- Healthy: zero literal
pxpaddings, margins, or gaps in component rules; spacing appears only as token references (and tracked fallbacks in scoped token contracts). A regex sweep ofui/components/**/*.cssreturns nothing but fallback literals. - Warning: literals cluster by area—usually a legacy feature or an imported design—marking where a migration stalled. The count trends down or the tracking is lying.
- Critical: new components ship with literal spacing; the scale has stopped being the path of least resistance, which is the only property that made it work.
Signal 2: Density reach
- Healthy: inter-component gaps resolve through density-mapped semantic tokens, so the tight and spacious modes measurably differ product-wide—a two-minute visual diff between densities shows everything moved together.
- Warning: some surfaces respond to density and some do not; the mode switch produces a patchwork, and each unresponsive island is a hardcoded gap.
- Critical: density is decorative—a switch that changes margins the product never references—meaning the second axis exists in tokens but not in fact.
Signal 3: Floor compliance
- Healthy: interactive targets clear
tapTargetMin(44px) at the smallest supported viewport, verified by the e2e bounding-box walk, not by hope. - Warning: targets pass on primary surfaces but fail in dense contexts (tables, toolbars) where the visual-size/hit-area pattern was skipped.
- Critical: any interactive element below the floor anywhere—this is a shipping defect with a number attached, not a style disagreement.
The pattern across the three: healthy spacing is machine-checkable and checked. When the answers require opening files and squinting, the rhythm has started becoming a palette.
Migration Strategy: From Literal Spacing to the Scale
Spacing migrations are the friendliest kind—behavior shifts by a few pixels, never by a category—so the strategy is mechanical and unemotional:
- Inventory the literals: extract every spacing declaration (padding, margin, gap) with file and line. Dedupe by value; most surfaces discover they use six or seven distinct numbers against an eleven-step scale.
- Snap to nearest step: map each distinct value to its nearest scale step (14px→12, 18px→16, 20px→24). The visual diff of snapping is sub-perceptual by the JND floor—anything within 15% of a step is that step wearing a rounding error.
- Route inter-component gaps semantically: the gaps between things become
semantic spacingreferences so density reaches them; the padding inside things may reference scale tokens directly. - Migrate by surface, highest-traffic first: each screen's conversion is an independently revertible commit with a screenshot diff; the six-or-seven numbers make every diff reviewable in minutes.
- Turn the sweep on: add the literal pattern to the per-directory lint as each area cleans, so regression is blocked where cleanup happened rather than everywhere at once.
// The snap map, built in step 2 — replace, keep literal as fallback
const snap = {
'14px': '--core-spacing-size-05', /* 12px, -14% snapped */
'18px': '--core-spacing-size-06', /* 16px */
'20px': '--core-spacing-size-07', /* 24px, +20% snapped */
};
for (const file of componentCssFiles) {
let css = read(file);
for (const [literal, token] of Object.entries(snap)) {
css = css.replaceAll(literal, `var(${token}, ${literal})`);
}
write(file, css); // literals survive as fallbacks; behavior holds
}Note what the strategy never does: it never introduces new steps to "preserve pixel-perfection." The scale is the truth; the literals were always approximations of it.
Real-World Case Studies
Case 1: The dashboard that wanted to be dense
A data product team requested "20% tighter everything" for their console. The system answer was not a new scale but the existing density axis: semantic gaps already resolved through spacing.density, so applying compact to the console shell tightened every gap that had gone semantic—and left untouched the two screens that had hardcoded their margins, which is how those screens were finally identified as migration debt. The fix took a data attribute; the finding took the density switch working as designed.
Case 2: The marketing page that broke the rhythm
A launch page shipped with 20px section gaps—off-scale, chosen in a rush. Alone it looked fine; beside the rest of the site, its sections felt subtly cheaper, and nobody could say why until the audit asked "which step is this?" The repair (24px) took one line; the lesson took the question—off-scale values are perceptible as disorder even when unnameable.
Case 3: The touch-target regression
A redesign squeezed table-row actions to 28px for elegance. The e2e target walk failed at the floor (tapTargetMin 44px), and the "visual 28, hit 44" pseudo-element pattern restored the look without the defect. The floor had been encoded precisely so this argument would end at a number instead of a taste debate.
Verification Checklist
Additional Resources
- Layout — how the spacing scale meets containers and flow (
/blueprints/foundations/layout) - Grid Systems — columns and gutters on the same scale (
/blueprints/foundations/grid) - WCAG 2.5.5 target size — the normative basis for
tapTargetMin(https://www.w3.org/WAI/WCAG21/Understanding/target-size.html) - The sources —
ui/designTokens/core/spacing.tokens.json,ui/designTokens/core/dimension.tokens.json,ui/designTokens/semantic/spacing.tokens.json
Related Concepts
Reflection Questions
Apply This Concept
A dashboard team wants everything 20% tighter. Walk through what changes when density mode switches to compact, what does not, and which bugs that asymmetry predicts.
Reflect
A designer complains the 11-step scale is limiting and requests a 20px step. What questions decide whether this is a scale gap, a density need, or drift?
Reflect
Run the three health signals on a product you touch. Which signal is worst, and what does its worst entry predict users will complain about first?
