# Design Spacing Rhythm

> The most complete spacing & alignment skill for AI-generated UI and written documents. Reads the layout, sets three dials (SPACING_STEP / DENSITY / ALIGNMENT_RIGOR), derives ONE scale, and lets proximity carry meaning — establishing a spacing scale, vertical rhythm, optical alignment, whitespace hierarchy, responsive/fluid density, accessibility floors, component recipes, document & long-form (Word / Docs / PDF / Markdown) spacing, and an anti-slop checklist. Every rule is CONTEXTUAL: read the situation, then pull only what fits. Not a blanket "add more padding" pass.

- Skill: `buidangminh23/design-spacing-rhythm` (Agent Skill)
- Install (CLI): `npx skillmds@latest add buidangminh23/design-spacing-rhythm`
- Raw SKILL.md: https://api.skillmd.com/api/skills/buidangminh23/design-spacing-rhythm/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: buidangminh23 (https://skillmd.com/u/buidangminh23)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/buidangminh23/design-spacing-rhythm

---


# spacingskill: The Spacing & Rhythm Skill

> For any UI **or written document** an agent generates or edits — landing
> pages, dashboards, forms, data tables, mobile screens, editorial, and
> Word / Google Docs / PDF / Markdown documents (§14).
> Spacing is the single fastest tell of AI-generated UI: off-scale magic
> numbers, everything equally cramped, no proximity hierarchy, optical
> misalignment.
> Every rule below is **contextual**. Nothing fires automatically. First read
> the layout, set the dials, then pull only what fits. Spacing is a *system*,
> not decoration applied at the end.

---

## 0. SPACE INFERENCE — Read the Layout Before a Single Pixel

Spacing is a decision made BEFORE the first `<div>` exists. Infer it; do not
default to it. State the dials, emit one "Space Read" line, then generate.

### 0.A Signals to read (priority order — higher overrides lower)

| # | Signal | Where it comes from | What it sets |
|---|--------|---------------------|--------------|
| 1 | **Existing scale / tokens** | Tailwind config, `--space-*` / `--spacing` vars, a component lib (MUI/Carbon/Radix/Chakra), repeated literals | SPACING_STEP + the whole scale. Conform, do not invent. |
| 2 | **Brand / explicit constraint** | Brand guide, "match our app", a referenced screenshot, a stated number | Overrides aesthetic preference. Lock it, build around it. |
| 3 | **Surface kind** | table vs hero vs form vs dashboard | DENSITY + SPACING_STEP start |
| 4 | **Density the CONTENT demands** | field count, columns, rows/screen, words/block, scan-vs-read | Final DENSITY. Content outranks aesthetic. |
| 5 | **Grouping relationships** | how many groups; nesting depth | the spacing ramp + ALIGNMENT_RIGOR |
| 6 | **Viewport range** | mobile-only, responsive, desktop-app, TV | margins/gutters + how DENSITY shifts across breakpoints |

- **Detect-and-conform is rule zero.** If a scale exists, SPACING_STEP is already decided — adopt it. Introducing a second scale beside an existing one produces `13px`/`22px` values that read as broken. This is the most damaging spacing error.
- **Content demand outranks the surface stereotype.** A "marketing page" that is really a 9-row pricing table is a *data* surface — raise DENSITY. Classify by what the content does (scan / compare / read / input), not its label.
- **Grouping is the real job.** The deliverable is almost never "more space"; it is a *correct ratio* — items in a group sit closer to each other than to the next group (§6).
- **Viewport sets margins, not the whole system.** SPACING_STEP stays constant across breakpoints; outer margin and sometimes DENSITY change. Mobile gets a smaller margin (≈16px), never zero.

### 0.B State the "Space Read" FIRST, every time

One line before any markup. It makes the inference explicit and reviewable, and locks the dials so the user can redirect in one sentence.

> **Space Read:** `<surface>` · `<airy|balanced|packed>` · STEP `<n>` · DENSITY `<n>` · RIGOR `<n>` · `<the one ratio/constraint that matters>`

| Situation | Space Read |
|-----------|------------|
| Admin table, 7 cols, 30 rows | `data-table · packed · STEP 4 · DENSITY 8 · RIGOR 9 · row rhythm 8px, columns hard-snapped, numerals right-aligned` |
| SaaS landing hero + 3 cards | `marketing-hero · airy · STEP 8 · DENSITY 2 · RIGOR 5 · section padding 96–128px, generous lead under headline` |
| Settings form, existing Tailwind | `form · balanced · STEP 4 (inherited) · DENSITY 5 · RIGOR 8 · label↔input 8px, field↔field 24px, group↔group 40px` |

If you cannot fill the final clause, you have not finished reading the situation — do not start coding.

### 0.C Anti-Default Discipline — forbidden LLM reflexes

| Forbidden default | Why it's wrong | Do instead |
|-------------------|----------------|-----------|
| **Uniform padding everywhere** | destroys grouping; reads as one flat list | apply the §6 proximity ladder — inner gap < outer gap |
| **Magic numbers** (`13`, `17`, `22px`) | on no grid; reads as broken | every value = a multiple of SPACING_STEP |
| **Mixing two scales** | cumulative drift, nothing lines up | one SPACING_STEP for the whole tree |
| **Airy by default on dense data** | wastes the screen the content needs | DENSITY follows content (0.A.4) |
| **`gap: 10px / 20px / 30px`** | base-10 ≠ the grid | snap to 8/12/16/24 |
| **Equal margin above & below a heading** | heading belongs to the content below | ~2:1 above:below (§5) |
| **Zero outer margin on mobile** | cramped, unreadable | ≥16px screen margin for text |
| **Centering everything** | no alignment spine; weak scanning | left-align body; center only hero/empty states |
| **Sub-target tap areas** | fails a11y | ≥24×24px (WCAG AA); 44pt/48dp on touch |
| **Cramming to fit "above the fold"** | breaks rhythm for a myth | hold the ramp; let content scroll |

---

## 1. THE THREE DIALS — Core Configuration

Set three dials after the Space Read. Every later decision is gated by them.

* **`SPACING_STEP`** — base grid unit (px). `4` = fine/dense data UI, `8` = default clean layout. Avoid odd bases. Every spacing value is a multiple of it (the `0.5×` half-step is the only sub-unit, for hairline/icon↔label gaps).
* **`DENSITY`** — `1` = art-gallery airy (marketing, hero) … `10` = cockpit packed (tables, terminals). It does not invent numbers; it selects *which* rungs of the scale you reach for.
* **`ALIGNMENT_RIGOR`** — `1` = freeform/editorial … `10` = strict column- & baseline-grid-locked. Governs how hard elements snap to columns, edges, and a shared text baseline. Governs snapping, **not** gap size — an airy layout can still be strictly aligned.

**Baseline (no signal): `8 / 4 / 7`.** Overrides happen **conversationally**, never by asking the user to edit this file (see §13.B).

### 1.A Dial inference (signal → dials)

| Signal in the Space Read | STEP | DENSITY | RIGOR |
|--------------------------|:---:|:---:|:---:|
| Marketing / hero / landing | 8 | 1–3 | 4–6 |
| Editorial / brand piece | 8 | 2–4 | 1–3 |
| Standard app UI / forms / settings | 8 | 4–6 | 7–8 |
| Dashboard (mixed cards + tables) | 8 (4 inside tables) | 6–8 | 8 |
| Data table / terminal / cockpit | 4 | 8–10 | 9–10 |
| Docs / long-form reading | 8 | 3–4 | 5–6 |
| Mobile primary | 8 | 4–6 | 7 |

**Inference rules:**
- **Compute DENSITY:** start at the **midpoint** of the §1.A range, apply the §1.B deltas (they **sum**), then **clamp to [1, 10]**. When a §1.B content shift contradicts the §1.A surface band, **content wins** (§0.A) — let the value leave the band (a marketing-hero surface holding a 9-row pricing table lands *dense*, not airy).
- `SPACING_STEP = 4` **only when** `DENSITY ≥ 7` (and as a 4-grid local override inside a dense region of an 8-grid page). The 4-scale is a strict subset of the 8-scale, so one product stays coherent.
- DENSITY and gap move **inversely**: high DENSITY pulls every gap one rung *down*; low DENSITY pushes section gaps one rung *up*.
- ALIGNMENT_RIGOR **rises with repetition** — many repeated rows/cells → 8–10; few bespoke blocks → 1–4. The `7` baseline applies only absent any repetition/bespoke signal; a present "few bespoke blocks" signal overrides it to 1–4. High DENSITY does **not** mean low RIGOR; packed UIs need *more* rigor.

### 1.B Content adjustments to DENSITY

| Observed | Shift DENSITY |
|----------|:---:|
| > 6 columns OR > 20 rows visible | +2 |
| Numeric / financial / monitoring data | +1 |
| > 8 input fields in one view | +1 |
| Primary action is *compare* / *scan* | +1 |
| Primary action is *read* (prose) | −2 |
| Single focal CTA, little else | −2 |
| "spacious / premium / calm" | −1 to −2 |
| "compact / power-user / efficient" | +1 to +2 |

Deltas **sum**, then clamp DENSITY to `[1, 10]`. On a content-vs-aesthetic conflict the **content** delta wins (§0.A) — "numeric/financial +1" beats "premium −1".

---

## 2. THE SPACING SCALE — One Scale, No Magic Numbers

Every spacing value resolves to a rung on one scale derived from `SPACING_STEP`. If a number is not on the scale it is a bug — except the one hairline exception in §2.D. **8 is the default** because Material, Apple HIG, IBM Carbon, and Tailwind's effective layout steps all converge on an 8px rhythm; 8 divides cleanly at common zoom/DPR factors and keeps steps visually distinct.

### 2.A The canonical scale (`SPACING_STEP = 8`)

Keyed by px (the universal truth) with rem and the matching Tailwind utility. `px = SPACING_STEP × multiplier`. Spacing is **linear** (multiples of the step) so it tiles and nests on a grid — never a modular/fractional scale for layout (modular is for type, §5, and the macro exception in §2.C).

| px | mult | rem | Tailwind | Typical use |
|---:|:---:|:---:|:---:|-------------|
| 0 | 0 | 0 | `p-0` | reset / collapse |
| **4** | 0.5× | 0.25 | `p-1` | hairline gap, icon↔label, tightest pair |
| **8** | 1× | 0.5 | `p-2` | tight inline gap, compact vertical inset |
| **12** | 1.5× | 0.75 | `p-3` | compact control padding |
| **16** | 2× | 1 | `p-4` | **default gap between related items**, control pad-x |
| **24** | 3× | 1.5 | `p-6` | card inset, field↔field, related-group gap |
| **32** | 4× | 2 | `p-8` | group separation, large card inset |
| **48** | 6× | 3 | `p-12` | sub-section gap |
| **64** | 8× | 4 | `p-16` | section gap (compact) |
| **96** | 12× | 6 | `p-24` | major section gap (marketing) |
| **128** | 16× | 8 | `p-32` | hero vertical rhythm |
| **160** | 20× | 10 | `p-40` | airy landing rhythm (DENSITY ≤ 3) |

**For `SPACING_STEP = 4`:** halve every px — the in-between rungs (`4 / 8 / 12 / 16 / 24 / 32 …`) become load-bearing. Nothing is dropped; the dense scale is the 8-scale with finer resolution.

> **Tailwind note:** Tailwind's unit is 4px (`p-4` = 16px). The Tailwind column above is matched by **px**, not by index — always pick the utility whose px equals your target (16px → `p-4`), and never `p-[13px]`.

### 2.B Primitive vs semantic tokens

Two tiers. Primitives are the raw scale. **Semantic aliases name a job** and point at a primitive, so a density change edits one mapping, not 200 call-sites. Components consume semantic tokens; only the theme layer touches primitives.

| Semantic | Job | DENSITY 1–3 | DENSITY 4–7 | DENSITY 8–10 |
|----------|-----|:---:|:---:|:---:|
| `inset-md` | standard card/panel padding | 48 | 24–32 | 16 |
| `gutter` | column / grid-cell gap | 64 | 32–40 | 16 |
| `section` | gap between page sections | 128–160 | 96 | 48 |
| `stack-sm` | vertical gap between stacked items | 24 | 16 | 8 |

### 2.C Modular scale — only for the macro tier

For section rhythm on low-DENSITY marketing pages, a geometric scale (`stepₙ = base × ratioⁿ`) gives more deliberate jumps — **then snap each result to the nearest grid multiple.** Allowed only when `ALIGNMENT_RIGOR ≤ 4` *and* `DENSITY ≤ 4` *and* the spacing is between full page sections. At `RIGOR ≥ 7` stay linear (a baseline-locked grid can't absorb the snap residue).

Pick the ratio by DENSITY: `1.25` at DENSITY 4 (restrained) · `1.333` at 3 (editorial) · `1.5` at 2 (dramatic) · `1.618` at 1 (hero only). Example (base 16, ×1.5, snapped to 8): 16 → 24 → 40 → 56 → 80. The unsnapped 36/54/81 are forbidden.

### 2.D The ONE allowed off-scale exception

**Hairlines, borders, and rendered lines live off the spacing scale.** A `1px` border, `0.5px` retina rule, `outline-offset`, and optical-centering transform nudges (≤4px — typically 1–2px, up to 4px only for large display glyphs) are rendered lines, not layout space. The *gap around* a border is still on-scale. Nothing else escapes.

### 2.E Emitting tokens

```css
:root {
  --space-step: 8px;
  --space-1: calc(var(--space-step) * 1);   /* 8  */
  --space-2: calc(var(--space-step) * 2);   /* 16 */
  --space-3: calc(var(--space-step) * 3);   /* 24 */
  --space-4: calc(var(--space-step) * 4);   /* 32 */
  --space-8: calc(var(--space-step) * 8);   /* 64 */
  /* semantic aliases point at primitives — density edits these only */
  --inset-md: var(--space-3);
  --gutter:   var(--space-4);
  --section:  var(--space-12, 96px);
}
```

---

## 3. SPACING PRIMITIVES & GAP OWNERSHIP

Every gap expresses a *relationship*. Name the relationship → the primitive is determined.

| Primitive | Spaces | Mechanism | Use when |
|-----------|--------|-----------|----------|
| **Stack** | siblings stacked vertically | parent `flex-direction:column; gap` | form fields, list rows, sections |
| **Inline (cluster)** | siblings in a row | parent `flex; gap` (+ `align-items`) | button rows, tag clusters, toolbars |
| **Inset** | a container & its own children | `padding` on the container | inside card/button/input/section |
| **Grid-gap** | cells of a 2-D grid | `display:grid; gap` | card galleries, dashboards, matrices |

One axis of siblings → Stack/Inline. Container-to-content → Inset. Two-axis repetition → Grid. Inset is the **only** primitive that uses padding; Stack/Inline/Grid express *between* with `gap`. On a *wrapping* flex/grid container, one `gap` owns **both** the inline gap and the cross-axis gap between wrapped rows (`align-content` distributes leftover cross space) — chip/tag clusters wrap with even spacing from one declaration.

### 3.A The single-owner law — one gap, one owner

**The space between two elements has exactly one owner.** Never let A's `margin-bottom` and B's `margin-top` both contribute — you get an unpredictable sum (or a surprise collapse).

- **Default everywhere: `gap` on the flex/grid parent.** Single-owner by construction; children declare zero margins. This is the highest-leverage rule in the skill.
- **Margins are a one-direction fallback** for free-flowing prose where children aren't a known set — pick `margin-block-end`-only and never deviate.
- **Inset never doubles with gap.** A card's `padding` owns edge-to-content; the grid's `gap` owns card-to-card. They meet but never sum.

```css
.stack { display: flex; flex-direction: column; gap: var(--space-2); }
.stack > * { margin: 0; }
```

If you ever compute a gap as "8 here plus 8 there = 16", you have two owners. Collapse to one.

### 3.B DENSITY selects which rungs

| DENSITY | Section gap | Card inset | Stack gap (related) | Inline gap |
|---------|:---:|:---:|:---:|:---:|
| 1–3 (airy) | 64–96 | 32–48 | 16–24 | 12–16 |
| 4–6 (default) | 32–48 | 16–24 | 12–16 | 8–12 |
| 7–10 (data/cockpit) | 16–24 | 8–12 | 4–8 | 4–8 |

At `DENSITY ≥ 8` set `SPACING_STEP = 4` so tight gaps still land on grid.

---

## 4. LAYOUT GRID & ALIGNMENT

### 4.A Column grid, gutters, page margins

Use a 12-column grid (divisible by 2/3/4/6) when content must align across rows of differing components. **Gutters and page margins are fixed per breakpoint; column widths are fluid (`%`/`fr`).** Material 3 reference: 16dp margins at compact, 24dp gutters + margins at the tablet range.

| Viewport | Columns | Gutter | Page margin |
|----------|:---:|:---:|:---:|
| Mobile (<600) | 4 | 16 | 16 |
| Tablet (600–1024) | 8 | 24 | 24 |
| Desktop (>1024) | 12 | 24–32 | `clamp`, see 4.B |

```css
.grid-12 { display: grid; grid-template-columns: repeat(12, 1fr); gap: var(--gutter); }
```

Use `minmax(0, 1fr)` for fluid tracks so a long unbroken string can't blow out a column and break the gap; reach for `fit-content`/`min-content` where a track should hug its content. This also protects the §9.D reflow guarantee.

### 4.B Measure & content max-width

Unbounded text on wide viewports destroys readability. **Cap the measure at 45–75 characters (~66 ideal).** Use `ch` for prose so the measure holds across font sizes; center the *block* with `margin-inline:auto`, keep page margins as `padding-inline` so text never kisses the edge.

```css
.prose { width: min(100% - 2 * var(--page-margin), 65ch); margin-inline: auto; }
```

### 4.C Alignment toolkit (reach for these before any margin/`auto` hack)

| Property | Axis | Use |
|----------|------|-----|
| `justify-content` | main / inline | distribute along the row (`space-between` for logo↔nav, `space-evenly` for segmented controls) |
| `align-items` | cross | line items up across the cross axis (`baseline` for icon+text, number+label) |
| `align-content` | wrapped lines | distribute wrapped rows |
| `place-items` | both (grid) | `place-items:center` centers a single child on both axes |
| `gap` | both | the space owner (§3.A) |

- Mixed-height inline items must align to text → `align-items:baseline`, not `center`.
- Equal-height cards in a row → `align-items:stretch` + a flex-column card with `margin-top:auto` on the footer (the one sanctioned auto-margin — it pushes *within* a flex child, owns nothing between siblings).
- **That auto-margin only pushes when the child has slack — otherwise it is silently `0`.** `margin-top:auto` consumes *free space*; in a column that hugs its content (`flex:none`, `height:auto`, a grid cell sized to `max-content`) there is none, so the footer lands flush against the block above and the gap you "set" never existed. No warning, no overflow, nothing to see in the rule — the declaration is present and its computed value is zero. Measured across one layout's two variants: an identical footer meant to clear its subtitle rendered **44px** in the landscape build, where the column was the flex item absorbing leftover height, and **0px** in the portrait build, where the same column was `flex:none`. Reach for `auto` only where stretch is guaranteed; everywhere else give the gap a real value from the scale (§2). The symptom generalizes: *a spacing rule whose value depends on free space disappears the moment the container stops having any.*
- A bottom-aligned row that **mixes an editable control with static text** aligns the *boxes*, not the *text*: the control carries a touch-target `min-height` (44/48px) and centers its text, while a bare `<span>` is one line tall and sits flush at the bottom — so the two labels land at visibly different heights. Give every slot the same box (`min-height` + padding) and center the static ones (`display:flex; align-items:center`). The touch-target floor (§9) is an alignment constraint, not just an ergonomic one.
- **A hug-width child inside a Stack still fills the cross axis.** `width:auto` (`w-auto`, `fit-content`) says nothing about the cross axis of a `flex-direction:column` parent — `align-items:stretch`, the default, does. A secondary button sized to hug its label renders full-bleed until the child gets `align-self:start` (or the Stack gets `align-items:start`). Measured on one settings screen: `sm:w-auto` alone left the button at **640px**; adding `sm:self-start` brought it to **184px**, its true content width. Same trap for any hug-sized chip, badge, or card stacked vertically — and it hides on mobile, where full-bleed is what you wanted anyway, then surfaces at the first wide breakpoint.
- **A leading icon opens a text column — the row's actions belong in it.** In an icon + title/description row, the copy starts at `icon width + gap` while a control placed after it, being a sibling of the icon block, starts back at the container inset; the eye reads two competing left edges. Indent the action by exactly that amount (icon 44 + gap 12 → `padding-inline-start: 56px`) so title, description, and control share one spine. Mandatory at `RIGOR ≥ 7`. Below `sm`, where the control goes full-width for touch, drop the indent and let it align to the card edge instead — one spine per breakpoint, never a half-step between them.
- `ALIGNMENT_RIGOR`: at 1–3 freeform centering and optical nudges are fine; at 7–10 every edge lands on a column line and body content uses `start`/`stretch` (not `center`) so left edges lock to the grid.

### 4.D Subgrid — align nested card content across siblings

Cards with different title lengths lose internal alignment. `align-items:stretch` equalizes *height* but not internal tracks. **Subgrid fixes it** — each card adopts the row's tracks, so titles/bodies/footers align across the row regardless of text length (widely supported: Chrome/Edge 117+, Firefox 71+, Safari 16+).

```css
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-6); }
.cards > .card { display: grid; grid-row: span 3; grid-template-rows: subgrid; }
```

### 4.E Centering — by what you're centering

| Situation | Strategy |
|-----------|----------|
| One element, both axes | `display:grid; place-items:center` |
| One element horizontally, block flow | `margin-inline:auto` + `max-width` |
| Text within its box | `text-align:center` (text only) |
| A row of items as a group | flex parent + `justify-content:center` |

**Centering acts on the box, not on the ink.** An item whose `max-content` is wider than its container is clamped to the container, so the text inside wraps and leaves dead space on the trailing edge — `align-items:center` then centers a box wider than anything it draws, and the group reads pulled toward the leading edge. The drift *is* that leftover, so it grows with the container: one logo + company-name lockup sat 10.7px off at 375px wide and 63.2px off at 674px from identical markup, which is exactly why it survives a phone-only check. Fix the sizing, not the position — make the wrap deterministic so `max-content` equals the longest line (force the break, or cap the text at that measure) until the item shrink-wraps; only then does any strategy above bite. Acceptance test is `item box width === longest rendered line`, measured at the widest viewport you support.

**Never center long-form body copy** (ragged edges wreck the return sweep) — center the *block*, left-align the *text*. Centering is a DENSITY 1–4 move; at higher density / `RIGOR ≥ 6`, left-align and lock to the grid.

---

## 5. VERTICAL RHYTHM — Type Drives Spacing

Vertical space is emitted *by* the text. Line-height is the atom; set type first, then let it dictate rhythm.

### 5.A Line-height is the base unit

The **line box** (computed line-height), not the font size, is what stacks: 16px × 1.5 = a 24px line box. Reason in line boxes.

| Role | line-height | Why |
|------|:---:|-----|
| Body / paragraph | **1.5–1.6** | WCAG 1.4.12 needs ≥1.5; ship 1.5 minimum |
| Long-form reading | 1.6–1.75 | longer measure needs taller leading |
| UI body / dense rows | 1.4–1.5 | tighter, never below 1.4 on wrapping text |
| Headings (large type) | 1.0–1.25 | big glyphs need less leading |
| Single-line labels/buttons | 1.0–1.2 | inset owns the height (5.E) |

Leading shrinks as size grows. DENSITY trims leading before gaps (≥7 → 1.4; ≤3 → 1.6+), but **never below 1.4 on wrapping text** regardless of DENSITY.

**Stacked-diacritic scripts raise the heading floor.** Scripts whose marks stack *both above and below* a letter — Vietnamese (`Ệ Ợ Ằ Ầ`), and similarly Thai / Arabic / Devanagari — need multi-line headings held at **line-height ≥ 1.1**: below ~1.05 an under-dot on one line collides with a circumflex/breve on the next. Latin all-caps tolerates 0.95; the same heading set in Vietnamese does not. This is gated by the *content language*, not by taste — a `leading-[0.95]` display heading is a latent collision the instant it wraps to two lines of marked caps, so set the floor whenever the text can be localized into a mark-stacking script.

### 5.B Baseline grid — pragmatic, not dogmatic

Round the computed line-height to a multiple of SPACING_STEP so every line lands on the grid (16px body → **24px** line box on an 8-grid). Align line *boxes* to the grid; don't chase per-font baselines. `ALIGNMENT_RIGOR`: ≤3 loose multipliers, no snapping; 4–7 snap line-heights and block gaps (headings may break locally); ≥8 snap everything and insert a corrective spacer to re-sync after off-grid media (images, embeds).

### 5.C Heading spacing is ASYMMETRIC — more above, less below

A heading **belongs to the content beneath it**. Space above separates it from the previous block; space below must be smaller so it bonds to its paragraph. Equal space orphans the heading. Ratio `above ≈ 2–3× below`; both are scale multiples. Zero out the top space on a `:first-child` heading (the container's padding owns it).

| Level | font-size | above | below |
|-------|:---:|:---:|:---:|
| H1 / page title | 36–48 | **48** | 16 |
| H2 / section | 28–32 | **40** | 16 |
| H3 / subsection | 22–24 | **32** | 12 |
| H4 | 18–20 | **24** | 8 |
| H5 / eyebrow | 14–16 | **16** | 8 |

Collapse the asymmetry into one owner (heading `margin-top` + small/zero `margin-bottom`, or container gap) — never set both heading margins *and* the next paragraph's top margin.

### 5.D A paired modular TYPE scale

Type scale and spacing scale share the same `SPACING_STEP` rhythm so sizes and gaps never fight. Ratio by context: 1.333–1.5 for marketing (DENSITY ≤3), **1.25 (major third)** default, 1.2–1.125 for dense UI (≥7). Material 3's line-*boxes* are 4-grid-aligned (body 16/24, title 22/28, headline 24/32, 28/36, 32/40, display 57/**64**) — note some font-sizes (e.g. 57) aren't multiples of 4, so anchor the rhythm to the line boxes, not the sizes.

### 5.E Control heights = line-height + inset, never a clamped fixed height

A button/input height is `line-height + padding + borders` — computed, not asserted. A rigid `height:36px` with centered text clips descenders (g, y, p) when font/zoom/line-height change. Use `min-height` (a floor), never `height` (a clamp).

| Control | font / lh | inset (each side) | height | grid |
|---------|:---:|:---:|:---:|:---:|
| Small | 14 / 20 | 6 | 32 | 4× |
| Medium | 14–16 / 20–24 | 8–10 | 40 | 5× |
| Large | 16 / 24 | 12 | 48 | 6× |

Vertical inset is a **derived** value — pick it so total height lands on a grid multiple (the grid-locked quantity is the **height** 32/40/48, so a computed inset like 6px is exempt, like line-height). Inset is the density dial (≥7 → tighter, ≤3 → 12–14px), but the **touch target must still clear 44×44px / 48×48dp** — pad the hit area, never shrink the target (§9).

**Floor-not-clamp applies to the INLINE axis too — and fails louder there.** An icon rail, logo strip, avatar column, or badge tray given a fixed `width` to line up a text column is the same clamp: it holds only until a row carries one more item than the slot was measured against. Use `min-width` so the slot keeps the shared text edge for the common case and still grows for the fat one. The inline failure is nastier than the block one: a too-short box clips or scrolls, whereas a too-narrow slot whose children are `shrink-0` (or hold an image at `max-w-none`) **overflows and paints over the next flex sibling** — no clip, no scrollbar, no warning, just glyphs on top of glyphs. It also hides until someone adds item N+1 to *one* row, so it reads as "that row is broken" rather than a sizing error, while the untouched rows keep looking right. Measured on one checkout: a rail sized 64px for a single badge got a second row of Visa + `gap-1` + Mastercard needing 96px, and 32px of logo landed 20px deep in its own description text. **When a shared alignment spine and the proximity ladder collide, the ladder wins** — do not widen every rail to the fattest one if that inflates a sparse row's icon↔label gap past its inter-item gap (§6.B); let the fat row run wider and keep each row's own gap tight.

---

## 6. WHITESPACE HIERARCHY — Proximity = Relationship

Whitespace is not leftover; it is the primary grouping signal. The eye groups by distance pre-attentively (~100–150ms), before reading a word. Spacing decides structure whether you control it or not.

### 6.A The Law of Proximity

Closer reads as related; farther reads as separate — distance overrides border, color, and similarity. **The signal is the *ratio between* gaps, not any absolute value.** A 12px gap means "tighter than the 24px gap beside it." Ambiguous spacing (tiers too close in size) reads as flat noise — fix by widening the larger tier, not shrinking the smaller.

### 6.B The Proximity Ladder — the core mental model

Every gap belongs to exactly one tier. Assign the tier, then pull px from the scale.

| Tier | Separates | Reads as |
|------|-----------|----------|
| **Intra-item** | parts of ONE atom (label↔input, icon↔text, number↔unit) | one indivisible unit |
| **Inter-item** | sibling atoms in a group (two fields, two rows) | distinct but related |
| **Inter-group** | whole groups / sections | separate concerns |

**Non-negotiable rule: each tier must be clearly larger than the one below — target ≥2×, never < ~1.5×.** Below 1.5× the eye can't separate the tiers. Clean ladders: `8 → 16 → 32` (each 2×) at STEP 8, `4 → 8 → 16` at STEP 4. Three tiers is usually enough; add a fourth (`→ 64`) only for full pages.

### 6.C A label + its control is ONE unit

The `label → input` gap is intra-item — the tightest tier. **Always keep `label→input` < `input→nextField`** (e.g. 8 < 16). Violating this single inequality is the most common form-spacing bug; it makes the label detach and the form fragment.

### 6.D Macro vs micro whitespace

Micro (inside/between atoms: line-height, padding, intra/inter-item gaps, steps 4–16) drives **legibility** — get it right for usability. Macro (between sections/regions, steps 32–96+) drives **feel** — thin macro whitespace reads as cheap. They are set independently: a dense table can have tight micro spacing and generous macro margins.

### 6.E Active whitespace = emphasis

Isolation is emphasis. When something matters more, give it *more* surrounding space — a primary CTA with 2× the breathing room of its neighbors reads as primary without extra weight or color.

### 6.F Reach for whitespace BEFORE borders and boxes

Separation ladder — stop at the lightest tool that resolves the grouping: **whitespace → background tint → hairline divider (1px) → border → filled card.** A card is justified only when the surface scrolls independently, is draggable/selectable, or genuinely floats (elevation). If a card contains only spacing and no independent behavior, **delete the card and keep the spacing.** Never double-encode (a gap *and* a divider for the same break).

---

## 7. OPTICAL ALIGNMENT — Trust the Eye

The eye, not the math, is the acceptance test. Metric centering is correct only for symmetric shapes; everything asymmetric needs a nudge toward visual mass. `ALIGNMENT_RIGOR` gates *how many* fixes fire — never *whether* the egregious ones do.

### 7.A Rigor gate (apply top-down until the budget runs out)

| RIGOR | Fires | Skips |
|:---:|-------|-------|
| 1–3 | lone glyph in a circular button; icon↔text vertical centering | hanging punctuation, overshoot, mass correction |
| 4–6 | + asymmetric icon nudge (play/chevron/arrow); button icon asymmetric inset; bullet/marker hang | per-icon optical sizing, sub-pixel quote hang |
| 7–10 | + hanging punctuation & quotes, overshoot tuning, equal-gap mass correction | nothing — full optical pass |

**An asymmetric mark metric-centered is a bug at any rigor** — fix it even on an art-gallery page. DENSITY scales nudge magnitude: ~1px at DENSITY ≥7 (small glyphs; over-correction reads as error), 2–4px at ≤3 (large display glyphs).

### 7.B Asymmetric glyph nudges (≈24px icon; scale `≈ round(size/16)px`, clamp 1–3)

| Mark | Direction | Nudge @24px |
|------|-----------|:---:|
| Play ▶ in a circle | right, toward the apex | +1 to +2px translateX |
| Chevron › / ‹ | toward the point | +1px |
| Caret ▾ (dropdown) | down | +1px translateY |

Do this in the artwork or a wrapper transform — **never by unbalancing the button's padding** (that breaks the touch target).

### 7.C Icon ↔ text — the most common real bug

Size an inline icon to ≈cap-height and sit it on the cap band, not the line box (a `1em` icon floats above the optical center):

```css
.inline-icon { width: 1em; height: 1em; vertical-align: -0.125em; }
```

Prefer the `cap` unit where supported; otherwise `-0.1em` to `-0.15em` is the safe default. In dense rows, baseline-lock the icon to the row's text baseline.

### 7.D Hanging punctuation, quotes, list markers (RIGOR ≥ 6)

Pull marks into the margin so the **text edge** — not the punctuation — aligns to the column. Opening quotes hang ≈0.4–0.5em; bullets use `list-style-position: outside`; numbered lists right-align numerals (tabular figures); use `hanging-punctuation: first` where supported. Failure mode: a blockquote whose visible text starts a quote-width right of the body column.

### 7.E Button optical padding — icon beside a label

Inset asymmetrically: **tighter on the icon side, looser on the label side** (a metric-equal pad crams the icon). At STEP 8 / DENSITY 4–6: `padding: 8px 16px 8px 12px` for a leading-icon button; mirror for trailing. Icon↔label gap = one step, and always tighter than the outer pad so the pair groups. Icon-only buttons return to symmetric padding.

### 7.F Overshoot & equal-gap-unequal-mass (RIGOR ≥ 7)

Round/pointed shapes must slightly exceed cap height to *look* equal (≈1–3% for a circle/dot vs an adjacent square; below 16px, snap to whole pixels). For cards of unequal content with equal gaps: equalize *visual mass* — pad the sparse card more so ink-to-space ratio matches its neighbor, adjusting by whole SPACING_STEP units. **Squint test:** misaligned mass survives blur; metric-but-balanced alignment dissolves into even gray.

---

## 8. RESPONSIVE & FLUID SPACING

Space is a function of viewport and container — resolve it with math, gate the breakpoints with the dials, and let components respond to *their* width.

### 8.A `clamp()` for fluid section rhythm

`clamp(MIN, PREFERRED, MAX)` replaces a breakpoint stack. PREFERRED carries a `vw` term so it scales; **always include a `rem` term so it survives zoom**, and snap MIN/MAX to the scale (mid-scroll interpolation off-grid is invisible).

```css
/* 48px @360 → 128px @1440, STEP 8 */
.section { padding-block: clamp(3rem, 1.33rem + 7.41vw, 8rem); }
```

| Signal | clamp band |
|--------|-----------|
| Hero / section, DENSITY 1–3 | `clamp(4rem, …, 10rem)` |
| Standard section, DENSITY 4–6 | `clamp(3rem, …, 6rem)` |
| Dense app shell, DENSITY 7–10 | `clamp(1rem, …, 2rem)` |
| Component padding | `clamp(1rem, …, 1.5rem)` — narrow band |

Keep the fluid band wide for large spaces, narrow for small ones — a 4px→40px icon gap reads as broken.

### 8.B Step DENSITY +1 on mobile — outer skeleton only

On narrow viewports, compress *layout* gaps (section/margin/gutter drop 1–2 steps) but **hold intra-item gaps and touch targets**. If a gap separates layout regions, it is compressible; if it separates parts of one comprehensible unit, it is not.

**The same test governs COLUMN COUNT — and a blanket "collapse everything to one column" is where a phone screen gets spent.** Reflow (§9.D) requires no *horizontal scroll*; it does not require a single column. Set the mobile column count from the **narrowest cell's content demand**, not from the grid it happens to live in: a cell holding one number and its label wants about half a phone's width, while a cell holding a table, a chart or prose wants all of it. A four-up stat row therefore belongs **two-up** at 375px — collapsing it costs roughly a screen of scroll per four cells (measured on one dashboard: 822px of stat cards before the first real content, 371px after the same four went two-up). Target the row that holds the small cells (`:has(> .stat)`, or a modifier class where `:has()` is not available) rather than the breakpoint as a whole, so cells that genuinely need the width still get it.

### 8.C Container queries vs viewport

A component should respond to its **container**, not the window — a card in a 320px sidebar and a 900px column differ at one viewport width. Page chrome → `@media`; everything reusable inside it → `@container` (query `inline-size` for RTL safety).

```css
.card-wrap { container-type: inline-size; }
@container (min-width: 28rem) { .card { --pad: 1.5rem; } }
```

### 8.D Logical properties

Author with `padding-block` / `padding-inline` / `margin-inline` / `inset-*`, not physical `top/right/bottom/left`. They flow with writing direction (LTR/RTL/vertical) with zero overrides and cut declarations roughly in half. In vertical writing modes (CJK), the block/inline axes swap — the §5 line-box rhythm then runs along the inline axis, so re-check the baseline logic there.

### 8.E safe-area insets

Pad against `env(safe-area-inset-*)` for edge-anchored UI (fixed headers, bottom tab bars, FABs). Requires `<meta name="viewport" content="…, viewport-fit=cover">` or insets resolve to 0. **Add to your token, never replace it:** `padding-block-end: calc(1rem + env(safe-area-inset-bottom, 0px))`.

**Anything composited *over* the layout is a safe area too** — burnt-in subtitles, a player's control bar, a stream HUD, a watermark, a floating cookie bar. `env()` knows nothing about these, so the band has to be reserved by hand: measure the overlay's real box (font size × line count + its own bottom margin), then add it to the container's block-end padding. Measured on a 1920×1080 slide: content was padded 72px from the bottom while a 46px caption sat 56px up, so the caption's box covered the last two lines of a panel; padding the layout to 184px cleared it. The trap is *when* it appears — the layout is correct in the browser and only breaks once the overlay is composited, so it survives every check that looks at the page instead of the finished frame. Reserve the band in the layout; never rely on the overlay landing somewhere empty.

### 8.F `vw` pitfalls

Never size spacing/type with bare `vw` (no floor/ceiling, ignores zoom) — always wrap in `clamp()` with a `rem` term. Avoid `100vw` (scrollbar overflow → use `100%`/`100dvw`); prefer `dvh`/`svh`/`lvh` over `vh` so mobile URL-bar changes don't jump the layout.

### 8.G Anchored scroll under a sticky header

A fixed/sticky header (the nav in §10) hides in-page scroll targets. Reserve the space in scale multiples: `scroll-padding-block-start` on the scroll container = header height + a buffer, and `scroll-margin-block-start` on anchor targets.

```css
html      { scroll-padding-block-start: calc(var(--header-h) + var(--space-2)); }
:target   { scroll-margin-block-start: var(--space-2); }
```

### 8.H Motion preserves the scale

Animate `opacity`/`transform`, never layout (`gap`/`padding`/`width`) — animated layout reflows the grid and breaks rhythm. Stagger list reveals by *time*, not by spacing. Honor `prefers-reduced-motion`.

---

## 9. ACCESSIBILITY — Spacing Floors That Override Every Dial

These are floors verified against the spec, not preferences. When a dial collides with a floor, the floor wins and you clamp the dial.

### 9.A Target size

| Standard | Minimum hit area | Level |
|----------|:---:|:---:|
| WCAG 2.5.8 Target Size (Minimum) | **24×24 CSS px** | AA |
| WCAG 2.5.5 Target Size (Enhanced) | **44×44 CSS px** | AAA |
| Apple HIG | **44×44 pt** | platform |
| Material 3 / Android | **48×48 dp** | platform |

**Separate hit area from visual size** — a 16px icon + padding reaches the target: +4px → 24px (AA), +14px → 44px (AAA). DENSITY picks which floor (1–3 → 44; 4–7 → 24 AA, 44 on primary CTAs; 8–10 → 24 AA + the spacing exception) but **never** pushes a hit area below 24px.

**Grow the hit box at zero layout cost — padding + negative margin.** In a tight cluster on a fixed `gap` (header icon buttons, a VN/EN toggle, a toolbar), padding *alone* to reach the floor spreads the row and breaks its rhythm. Pair the padding with an **equal negative margin**: the padding enlarges the hit box, the negative margin restores the margin-box, so ev

…(truncated)
