Skip to content

Spacing

FieldValue
TypeSkill Resource
Source~/.copilot/skills/design/references/spacing.md
DescriptionNot specified

Source Content

Spacing

Contents:

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.

TokenValueTypical use
--space-14pxTight interior — icon-to-label, chip padding
--space-28pxBase unit — default interior gap
--space-312pxRow padding, control interior
--space-416pxStandard card / panel padding
--space-624pxSection spacing
--space-832pxMajor section separation
--space-1248pxPage-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.

PhysicalLogicalNote
padding-left / padding-rightpadding-inline-start / padding-inline-endFlips automatically in RTL
padding-top / padding-bottompadding-block-start / padding-block-endBlock axis
margin-left: automargin-inline-start: autoDirection-aware centering / pushing
text-align: lefttext-align: startLogical 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 primitivegap 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.

TokenMobileTablet+
--ui-pad-card-x1rem (16px)1rem (16px)
--ui-pad-card-y0.875rem (14px)0.875rem (14px)
--ui-pad-card-roomy-x1.125rem (18px)1.25rem (20px)
--ui-pad-card-roomy-y1rem (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.

TokenMobileTablet+
--ui-pad-panel-x1rem (16px)1.125rem (18px)
--ui-pad-panel-y1rem (16px)1rem (16px)

Shell (--ui-pad-shell-*)

Page-level container — outermost padding for main layout.

TokenMobileTablet+
--ui-pad-shell-x1rem (16px)1.25rem (20px)
--ui-pad-shell-y1.25rem (20px)1.5rem (24px)

Row (--ui-pad-row-*)

List items, table rows, menu items.

TokenMobileTablet+
--ui-pad-row-x1rem (16px)1rem (16px)
--ui-pad-row-y0.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.

TailwindpxUse
gap-1 / p-14pxTight interior (icon + label, badge padding)
gap-2 / p-28pxBase unit — component interior gap
gap-3 / p-312pxRow vertical padding, label gap
gap-4 / p-416pxStandard gap, card padding
gap-5 / p-520pxMedium section gap
gap-6 / p-624pxLarge section spacing
gap-8 / p-832pxMajor section separation
gap-10 / p-1040px-
gap-12 / p-1248pxPage-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.