Skip to main content

Spacing

Spacing tokens define the repeatable distances used to build rhythm, hierarchy, alignment, and ergonomics across interfaces.

This system is built on two explicit layers:

  1. Core Tokens — intent-free primitives and the single responsiveness engine
  2. Semantic Tokens — stable layout patterns consumed by UI code

Components must always consume semantic spacing, never core spacing directly.

Rule: Core spacing is never referenced in components.


Core Tokens

Core spacing tokens are intent-free primitives and the single source of truth for responsiveness.

They exist to:

  • centralize fluid logic (the responsive engine lives here)
  • keep semantic tokens as aliases (stable names, theme-tunable engine)
  • make the system predictable (no ad-hoc coefficients in semantics)

Core set

  • Primitive: core.spacing.engine.unit
  • Steps (fluid, engine-driven): core.spacing.0, core.spacing.1, core.spacing.2, core.spacing.3, core.spacing.4, core.spacing.6, core.spacing.8, core.spacing.12, core.spacing.16
  • Fixed steps (non-fluid): core.spacing.fixed.1, core.spacing.fixed.2, core.spacing.fixed.4

Keep the step set small. If you want core.spacing.5, you likely need a semantic mapping, not a new core step.

Why a fixed scale exists beside the fluid one. Most spacing is rhythm and belongs to the engine. A few semantic tokens instead guarantee a resolved outcomeinset.control.* is one, because a control's box is its inset plus its type over the hit floor (ADR-022), so a fluid inset would make the box container-fluid. Those tokens need a value that does not move, and that value is core's to hold: a semantic token is a reference, and a constant written into the semantic layer breaks that rule while claiming an impossibility that does not exist (model.md §8, ADR-023). The base theme sets the fixed steps to the engine's own desktop bound, so a control resolves identically on wide surfaces to the fluid step it references instead; that agreement is a theme choice, not a contract. This mirrors sizing, which has carried both shapes from the start — the fluid ramp.* beside the rem-anchored hit.

Example

const core = {
spacing: {
/**
* The Responsive Engine
* Container-first: scales with the inline size of the nearest query container.
* If no eligible container exists, cqi falls back to the small viewport unit for that axis (sv*).
*/
engine: {
unit: 'clamp(4px, 0.5cqi + 2px, 8px)',
},

/**
* Core steps reference the emitted CSS variable directly (not a {token.ref}).
* This preserves the CSS cascade so themes can override --tt-core-spacing-engine-unit at
* runtime (e.g. density mode) without recompiling every step.
*/
0: '0px',

/**
* Non-fluid steps — for semantic tokens whose resolved outcome is the
* guarantee rather than the rhythm (ADR-023). Plain values, because core
* is the layer that holds values.
*/
fixed: { 1: '6px', 2: '12px', 4: '24px' },

1: 'calc(1 * var(--tt-core-spacing-engine-unit))',
2: 'calc(2 * var(--tt-core-spacing-engine-unit))',
3: 'calc(3 * var(--tt-core-spacing-engine-unit))',
4: 'calc(4 * var(--tt-core-spacing-engine-unit))',
6: 'calc(6 * var(--tt-core-spacing-engine-unit))',
8: 'calc(8 * var(--tt-core-spacing-engine-unit))',
12: 'calc(12 * var(--tt-core-spacing-engine-unit))',
16: 'calc(16 * var(--tt-core-spacing-engine-unit))',

/** Tier-2 (optional, not default): container-aware unit (kept for explicitness) */
// engine.unitCq: 'clamp(4px, 0.6cqi, 8px)',
},
};

Expected consumption pattern: semantic tokens reference core tokens by alias.


Semantic Tokens

Semantic spacing is anchored in layout physics, not UX categories.

Token structure

{pattern}.{context}.{step?}
  • pattern: inset, gap, gutter, separation
  • context: control, action, surface, stack, inline, page, section, interactive
  • step: xs, sm, md, lg, xl, min, block

step is only used in some cases.

Patterns:

{pattern}Description
insetpadding inside elements
gapspacing between siblings
gutterstructural layout padding (page/section)
separationminimum ergonomic distance between interactive targets

Canonical shapes

  • inset.control.{sm|md|lg}
  • inset.action.block
  • inset.surface.{xs|sm|md|lg}
  • gap.stack.{xs|sm|md|lg|xl}
  • gap.inline.{xs|sm|md|lg|xl}
  • gutter.{page|section}
  • separation.interactive.min

Semantic Tokens Summary Table

Default mappings below reflect the base theme (baseTheme.ts). They are a theme choice, not a contract — a theme may remap any step. What the contract guarantees is the ordering (see validation rules), not the specific core step.

tokenuse when you are building…contract (must be true)default mapping (base theme)
inset.control.smcompact controlsfixed — a non-fluid core step (ADR-022/023)core.spacing.fixed.1
inset.control.mddefault controlsfixed — a non-fluid core step (ADR-022/023)core.spacing.fixed.2
inset.control.lglarge/prominent controlsfixed — a non-fluid core step (ADR-022/023)core.spacing.fixed.4
inset.action.blockblock padding of a command trigger — a CTA resolves taller (~40px desktop) than generic controls (~32px); inline padding stays inset.control.lgbounded 8–9px range — a clamp() RawValue (model.md §8, ADR-021 addendum)clamp(8px, core.spacing.2, 9px)
inset.surface.xsanchored / row-framing surfaces — a gutter beside children that carry their own inset.control (popover, menu, tooltip, list container)fixed — the outcome is the relationship to fixed-height children (ADR-022's argument one scale out)core.spacing.fixed.1
inset.surface.smtight surfacesinset.surface ≥ inset.control per stepcore.spacing.4
inset.surface.mddefault surfacesinset.surface ≥ inset.control per stepcore.spacing.6
inset.surface.lgspacious surfacesinset.surface ≥ inset.control per stepcore.spacing.8
gap.stack.xstight vertical rhythmsibling spacing via gapcore.spacing.2
gap.stack.smmedium vertical rhythmsibling spacing via gapcore.spacing.4
gap.stack.mddefault vertical rhythmsibling spacing via gapcore.spacing.6
gap.stack.lgroomy vertical rhythmsibling spacing via gapcore.spacing.8
gap.stack.xlsection-level rhythmsibling spacing via gapcore.spacing.12
gap.inline.xsvisual-only tight grouping (icon + label)never between interactive targetscore.spacing.1
gap.inline.sminline groupingascending inline scalecore.spacing.2
gap.inline.mdlooser inline groupingascending inline scalecore.spacing.3
gap.inline.lgspacious inline groupingascending inline scalecore.spacing.4
gap.inline.xlwide inline groupingascending inline scalecore.spacing.6
gutter.pagepage outer paddingbounded, structuralclamp(core.spacing.6, core.spacing.12, core.spacing.16)
gutter.sectionsection outer paddingbounded, structural; tighter than pageclamp(core.spacing.4, core.spacing.8, core.spacing.16)
separation.interactive.mindense interactive target clustersonly between click/tap/focusable targetsclamp(8px, core.spacing.3, 16px)

Example

const spacing = {
inset: {
control: {
// The NON-FLUID step scale, not the engine steps (ADR-022): a control's
// box is its inset + type over the `hit` floor, so the inset is
// outcome-bearing — a fluid inset makes the box container-fluid, against
// ADR-019/020. Core holds the fixed values (`core.spacing.fixed.*`, set
// to the engine's own desktop bound so wide surfaces are unchanged) and
// this layer references them like every other semantic spacing token —
// a literal here was the wrong mechanism for a right ruling (ADR-023).
sm: 'core.spacing.fixed.1',
md: 'core.spacing.fixed.2',
lg: 'core.spacing.fixed.4',
},
action: {
// Bounded range, not a step (model.md §8): the engine's unit steps
// straddle the 8–9px command block inset. Inline stays inset.control.lg.
block: 'clamp(8px, {core.spacing.2}, 9px)',
},
surface: {
sm: 'core.spacing.4',
md: 'core.spacing.6',
lg: 'core.spacing.8',
},
},

gap: {
stack: {
xs: 'core.spacing.2',
sm: 'core.spacing.4',
md: 'core.spacing.6',
lg: 'core.spacing.8',
xl: 'core.spacing.12',
},
inline: {
xs: 'core.spacing.1', // visual-only tight grouping — never between focusable targets
sm: 'core.spacing.2',
md: 'core.spacing.3',
lg: 'core.spacing.4',
xl: 'core.spacing.6',
},
},

gutter: {
page: 'clamp({core.spacing.6}, {core.spacing.12}, {core.spacing.16})',
section: 'clamp({core.spacing.4}, {core.spacing.8}, {core.spacing.16})',
},

separation: {
interactive: {
min: 'clamp(8px, {core.spacing.3}, 16px)',
},
},
};

Rules of Engagement (non-negotiable)

  1. Semantic-only consumption: components use semantic spacing only.
  2. Gap-first: sibling spacing uses gap (Flex/Grid) by default.
  3. Inset is for padding: inset.* is only for internal padding.
  4. Gutters are structural: use gutter.* for page/section layout padding.
  5. Separation is ergonomic: separation.interactive.min is only for interactive targets.
  6. No responsive logic in components: responsiveness lives in Core (core.spacing.engine.unit), not in UI code.

Decision Matrix (pick fast)

  1. Padding inside an element?inset.control.* / inset.surface.*
  2. Spacing between siblings?gap.stack.* / gap.inline.*
  3. Page/section structure?gutter.page / gutter.section
  4. Dense cluster of interactive targets?separation.interactive.min

Usage Examples

UsageToken
Stack (vertical rhythm){gap: gap.stack.md}
Inline group (visual grouping){gap: gap.inline.sm}
Surface padding{padding: inset.surface.md}
Page gutter{padding-inline: gutter.page}
Dense toolbar (interactive targets){gap: separation.interactive.min}

Build output may expose semantic tokens as CSS variables (as shown above) or as framework-specific bindings. The semantic names remain the API.


Output Guidance (Web)

The following sections are specific to CSS/web output and do not affect the semantic contract.

Flex gap fallback (only if required)

If your support matrix includes environments without flex-gap support, emit a fallback:

const rowStyles = {
display: 'flex',
flexDirection: 'row',
/* Preferred */
gap: 'gap.inline.sm',

/* Fallback */
'@supports not (gap: 1rem)': {
'& > * + *': {
marginLeft: 'gap.inline.sm',
},
},
};

Theming & Density (allowed knobs)

Themes may tune spacing without renaming semantic tokens.

  1. Tune core.spacing.engine.unit (global density + responsiveness)

    • denser: lower clamp bounds
    • airier: higher clamp bounds
  2. Optional density mode (rare) If multiple UI densities are truly needed, remap only:

  • inset.control.*
  • gap.* (stack + inline aliases)

Keep gutter.* and separation.* conservative.


Container-Aware Spacing (optional)

For highly modular layouts (cards in grids, split panes), you may introduce:

  • core.spacing.engine.unitCq = clamp(4px, 0.6cqi, 8px)

This is not default. Use it only in layout primitives/surfaces explicitly designed for container scaling.


Validation

Errors (validation must fail when)

  • inset order breaks:

    • inset.control.sm > inset.control.md
    • inset.control.md > inset.control.lg
    • inset.surface.sm > inset.surface.md
    • inset.surface.md > inset.surface.lg
  • a surface inset step is tighter than the corresponding control inset step (compared in resolved px at the engine's floor, since the two sides have different shapes):

    • inset.surface.sm < inset.control.sm
    • inset.surface.md < inset.control.md
    • inset.surface.lg < inset.control.lg
  • a control inset rides the fluid engine (or any formula) instead of resolving to a fixed px — the control inset is outcome-bearing (ADR-022): a control's box is its inset + type over the hit floor, so a fluid inset makes the box container-fluid, against ADR-019/020

  • a control inset holds the fixed value as a literal in the semantic layer instead of referencing a non-fluid core step (core.spacing.fixed.*) — the ruling is about the resolved outcome, not about who holds the number, and a bare constant in the semantic layer breaks "semantic references core only" (model.md §2/§8, ADR-023)

  • any gap.stack.* token resolves to anything other than a core.spacing.* step alias

  • stack gap order breaks:

    • gap.stack.xs > gap.stack.sm
    • gap.stack.sm > gap.stack.md
    • gap.stack.md > gap.stack.lg
    • gap.stack.lg > gap.stack.xl
  • gap.inline.xs > gap.inline.sm

  • gutter.page is not a bounded clamp(...) contract

  • gutter.section is not a bounded clamp(...) contract

  • gutter.* introduces direct responsive logic such as cqi, cqmin, cqmax, vi, vw, %, media queries, or breakpoint logic instead of composing from core.spacing.*

  • gutter.page resolves smaller than gutter.section at any bound

  • separation.interactive.min is not a bounded clamp(...) contract

  • separation.interactive.min introduces direct responsive logic such as cqi, cqmin, cqmax, vi, vw, %, media queries, or breakpoint logic instead of composing from core.spacing.*

  • separation.interactive.min has a minimum bound below 5px

  • any semantic spacing token other than gutter.* or separation.interactive.min defines its own raw formula instead of aliasing core spacing steps

  • generated output does not emit a viewport-safe fallback before container-based overrides

  • generated output does not gate container-based overrides behind @supports (width: 1cqi)

Warning (validation should warn when)

  • adjacent inset.control.* steps resolve to the same effective value

  • adjacent inset.surface.* steps resolve to the same effective value

  • adjacent gap.stack.* steps resolve to the same effective value

  • gap.inline.xs resolves to the same effective value as gap.inline.sm

  • gutter.page and gutter.section resolve to the same effective contract

  • separation.interactive.min resolves to the same effective value as gap.inline.sm


Summary

  • One responsiveness engine: core.spacing.engine.unit
  • Small core step set: core.spacing.{0..16} (sparse)
  • Small semantic set: inset / gap / gutter / separation
  • Gap-first, semantic-only consumption
  • Responsive by contract, no breakpoint logic in components