Spacing
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/design/references/spacing.md |
| Description | Not specified |
Source Content
Spacing
Contents:
- Symmetric padding is the default
- The spacing scale
- Logical properties for i18n
- Margins live at the layout layer
- Layout primitives
- The two allowed component margins
- Implementation values: —ui-pad-* tokens
- Icon row gaps (ADR-009)
Spacing is never accidental. Every value comes from the scale, padding is symmetric unless a comment says otherwise, and outer spacing is the layout parent’s job — not the component’s. These rules are also enforced by Stylelint (see ./css/linting.md); the canonical non-negotiable list lives in ./css/hard-rules.md.
Symmetric padding is the default
Left equals right; top equals bottom. Symmetric padding reads as deliberate, survives RTL flips, and is the overwhelmingly common case. Prefer the logical-property pair (padding-inline / padding-block) so the values stay symmetric and direction-aware:
/* component.css — symmetric interior padding */.card { /* inline = left+right, block = top+bottom — one value each, so symmetry is structural */ padding-inline: var(--space-4); padding-block: var(--space-3);}The two-value shorthand (padding: <block> <inline>) is equivalent and also acceptable. What you must avoid is four different numbers with no reason:
/* WHY NOT: four-sided asymmetry with no explanation — looks like a hack, breaks RTL */.card { padding: 12px 16px 4px 20px; /* lint error */}Asymmetric padding is allowed only with a specific, justified, inline comment naming the reason. Stylelint’s css-skill/symmetric-padding rule flags asymmetric padding that has no adjacent comment (see ./css/linting.md):
/* OK: asymmetry is intentional and documented */.toast { /* extra inline-end room so the close button never overlaps the text */ padding-block: var(--space-3); padding-inline-start: var(--space-4); padding-inline-end: var(--space-8);}The spacing scale
All structural spacing comes from a small set of tokens on a consistent step (a 4px base, doubling into an 8pt rhythm). Never type a raw pixel value for layout spacing — reach for the nearest token.
| Token | Value | Typical use |
|---|---|---|
--space-1 | 4px | Tight interior — icon-to-label, chip padding |
--space-2 | 8px | Base unit — default interior gap |
--space-3 | 12px | Row padding, control interior |
--space-4 | 16px | Standard card / panel padding |
--space-6 | 24px | Section spacing |
--space-8 | 32px | Major section separation |
--space-12 | 48px | Page-level vertical rhythm |
/* tokens.css — the scale, defined once, consumed everywhere */:root { --space-1: 0.25rem; /* 4px */ --space-2: 0.5rem; /* 8px */ --space-3: 0.75rem; /* 12px */ --space-4: 1rem; /* 16px */ --space-6: 1.5rem; /* 24px */ --space-8: 2rem; /* 32px */ --space-12: 3rem; /* 48px */}Magic numbers (margin-top: 13px, gap: 7px) are a lint error. If the scale lacks a value you need, the scale is wrong — fix the scale, do not sprinkle one-offs. When Tailwind is in play, its p-* / gap-* utilities ride the same step; see ./css/tailwind-config.md for mapping the scale to the config.
Logical properties for i18n
Use inline (the writing direction, left↔right in LTR) and block (top↔bottom) properties everywhere, not physical left / right / top / bottom. They make a single rule correct in both LTR and RTL with zero overrides.
| Physical | Logical | Note |
|---|---|---|
padding-left / padding-right | padding-inline-start / padding-inline-end | Flips automatically in RTL |
padding-top / padding-bottom | padding-block-start / padding-block-end | Block axis |
margin-left: auto | margin-inline-start: auto | Direction-aware centering / pushing |
text-align: left | text-align: start | Logical alignment keyword |
Margins live at the layout layer
A component never sets an outer margin. This is the most important rule on the page.
A component does not know where it will be placed. The same card might sit in a tight list, a roomy dashboard, or a modal — each wants different spacing around it. If the card hardcodes margin-bottom, that spacing leaks into every context, and the only way to override it is a more-specific selector or a utility patch. Component margins are how spacing becomes a pile of hacks. They also break reuse: two cards rendered side by side collapse their margins unpredictably.
The fix is a clean ownership split:
Component
Owns only what is inside its own box: padding, internal gap, border, background. It exposes zero outer margin.
Layout parent
Owns the space between children. It uses a layout primitive — gap on a flex or grid container, or the owl selector — so spacing is declared in exactly one place and is trivial to retune per context.
/* WHY NOT: the component reaches outside its own box */.card { padding: var(--space-4); margin-bottom: var(--space-4); /* lint error — outer margin on a component */}/* OK: the card owns padding only; the list owns the gap between cards */.card { padding: var(--space-4);}
.card-list { display: flex; flex-direction: column; gap: var(--space-4); /* spacing between cards lives here, where the layout decides it */}Layout primitives
Two tiny, reusable primitives cover almost all spacing-between-things. Define them once and use them everywhere instead of re-deriving gaps (see the no-duplication rule in ./css/architecture.md).
Stack
Vertical rhythm — space between stacked children. Use gap on a flex column; the lobotomized-owl selector (> * + *) is the zero-dependency fallback for browsers or contexts where gap is awkward.
/* layout.css — Stack: vertical space between direct children */.stack { display: flex; flex-direction: column; gap: var(--space-4); /* the one place this stack's rhythm is set */}
/* Owl fallback: margin only BETWEEN items, never on the first/last edge */.stack-owl > * + * { margin-block-start: var(--space-4);}Cluster
Horizontal grouping — space between inline items (button rows, tag lists, toolbars). Wraps gracefully.
/* layout.css — Cluster: horizontal space between items, wraps when narrow */.cluster { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2);}The owl pattern uses margin-block-start deliberately: it sets space only between siblings (never a leading or trailing edge), so the primitive itself stays free of the leaking-margin problem this whole section exists to prevent.
The two allowed component margins
Two narrow uses of margin are legitimate inside a component and are exempt from the no-margin rule:
Reset
margin: 0 to zero out a user-agent default (headings, paragraphs, lists). This removes spacing rather than adding it, so it cannot leak.
/* WHY: kill the browser's default heading margin so layout owns all spacing */.card__title { margin: 0;}Centering
margin-inline: auto to center a fixed-width block in its container. This is layout of the element within itself, not spacing against a sibling.
/* WHY: center a constrained content column; auto pushes equally on both sides */.prose { max-inline-size: 65ch; margin-inline: auto;}Any other margin on a component is a lint error. When in doubt, the answer is a gap on the parent.
Implementation values: --ui-pad-* tokens
Source of truth: src/index.css :root block. These cover the four structural container types the design system ships. Values are responsive — the larger set applies at min-width: 48rem (768px, “tablet+”).
Card (--ui-pad-card-*)
Standard card interior padding — compact surfaces.
| Token | Mobile | Tablet+ |
|---|---|---|
--ui-pad-card-x | 1rem (16px) | 1rem (16px) |
--ui-pad-card-y | 0.875rem (14px) | 0.875rem (14px) |
--ui-pad-card-roomy-x | 1.125rem (18px) | 1.25rem (20px) |
--ui-pad-card-roomy-y | 1rem (16px) | 1.125rem (18px) |
Usage: px-[--ui-pad-card-x] py-[--ui-pad-card-y] or style={{ padding: 'var(--ui-pad-card-y) var(--ui-pad-card-x)' }}
Panel (--ui-pad-panel-*)
Sidebar panels, drawers, content sections.
| Token | Mobile | Tablet+ |
|---|---|---|
--ui-pad-panel-x | 1rem (16px) | 1.125rem (18px) |
--ui-pad-panel-y | 1rem (16px) | 1rem (16px) |
Shell (--ui-pad-shell-*)
Page-level container — outermost padding for main layout.
| Token | Mobile | Tablet+ |
|---|---|---|
--ui-pad-shell-x | 1rem (16px) | 1.25rem (20px) |
--ui-pad-shell-y | 1.25rem (20px) | 1.5rem (24px) |
Row (--ui-pad-row-*)
List items, table rows, menu items.
| Token | Mobile | Tablet+ |
|---|---|---|
--ui-pad-row-x | 1rem (16px) | 1rem (16px) |
--ui-pad-row-y | 0.75rem (12px) | 0.875rem (14px) |
Responsive breakpoint: @media (min-width: 48rem) (768px — mobile → tablet+). The --ui-pad-* tokens update automatically at this breakpoint.
8pt grid — Tailwind class map
All structural spacing uses the 8pt grid. Base unit: 8px.
| Tailwind | px | Use |
|---|---|---|
gap-1 / p-1 | 4px | Tight interior (icon + label, badge padding) |
gap-2 / p-2 | 8px | Base unit — component interior gap |
gap-3 / p-3 | 12px | Row vertical padding, label gap |
gap-4 / p-4 | 16px | Standard gap, card padding |
gap-5 / p-5 | 20px | Medium section gap |
gap-6 / p-6 | 24px | Large section spacing |
gap-8 / p-8 | 32px | Major section separation |
gap-10 / p-10 | 40px | - |
gap-12 / p-12 | 48px | Page-level vertical rhythm |
No arbitrary values for structural spacing. Use the scale above. Only use [value] notation for the --ui-pad-* CSS variables:
{/* Tailwind arbitrary property syntax — preferred, visible in dev tools */}<div className="px-[--ui-pad-card-x] py-[--ui-pad-card-y]">
{/* Inline style — use only when the token is computed */}<div style={{ padding: 'var(--ui-pad-card-y) var(--ui-pad-card-x)' }}>[data-density="compact"] and [data-density="comfortable"] can be applied to override spacing. These are defined in component-level CSS, not in --ui-pad-* tokens.
Icon row gaps (ADR-009)
Icon grid rows always use gap-3 (12px) or gap-4 (16px):
<div className="grid grid-cols-[auto_1fr] items-start gap-3"> {/* tighter list items */}<div className="grid grid-cols-[auto_1fr] items-start gap-4"> {/* standard cards */}See icon-and-border.md for the full ADR-009 grid rule.