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:
- Core Tokens — intent-free primitives and the single responsiveness engine
- 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
outcome — inset.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,separationcontext:control,action,surface,stack,inline,page,section,interactivestep:xs,sm,md,lg,xl,min,block
step is only used in some cases.
Patterns:
{pattern} | Description |
|---|---|
inset | padding inside elements |
gap | spacing between siblings |
gutter | structural layout padding (page/section) |
separation | minimum ergonomic distance between interactive targets |
Canonical shapes
inset.control.{sm|md|lg}inset.action.blockinset.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.
| token | use when you are building… | contract (must be true) | default mapping (base theme) |
|---|---|---|---|
inset.control.sm | compact controls | fixed — a non-fluid core step (ADR-022/023) | core.spacing.fixed.1 |
inset.control.md | default controls | fixed — a non-fluid core step (ADR-022/023) | core.spacing.fixed.2 |
inset.control.lg | large/prominent controls | fixed — a non-fluid core step (ADR-022/023) | core.spacing.fixed.4 |
inset.action.block | block padding of a command trigger — a CTA resolves taller (~40px desktop) than generic controls (~32px); inline padding stays inset.control.lg | bounded 8–9px range — a clamp() RawValue (model.md §8, ADR-021 addendum) | clamp(8px, core.spacing.2, 9px) |
inset.surface.xs | anchored / 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.sm | tight surfaces | inset.surface ≥ inset.control per step | core.spacing.4 |
inset.surface.md | default surfaces | inset.surface ≥ inset.control per step | core.spacing.6 |
inset.surface.lg | spacious surfaces | inset.surface ≥ inset.control per step | core.spacing.8 |
gap.stack.xs | tight vertical rhythm | sibling spacing via gap | core.spacing.2 |
gap.stack.sm | medium vertical rhythm | sibling spacing via gap | core.spacing.4 |
gap.stack.md | default vertical rhythm | sibling spacing via gap | core.spacing.6 |
gap.stack.lg | roomy vertical rhythm | sibling spacing via gap | core.spacing.8 |
gap.stack.xl | section-level rhythm | sibling spacing via gap | core.spacing.12 |
gap.inline.xs | visual-only tight grouping (icon + label) | never between interactive targets | core.spacing.1 |
gap.inline.sm | inline grouping | ascending inline scale | core.spacing.2 |
gap.inline.md | looser inline grouping | ascending inline scale | core.spacing.3 |
gap.inline.lg | spacious inline grouping | ascending inline scale | core.spacing.4 |
gap.inline.xl | wide inline grouping | ascending inline scale | core.spacing.6 |
gutter.page | page outer padding | bounded, structural | clamp(core.spacing.6, core.spacing.12, core.spacing.16) |
gutter.section | section outer padding | bounded, structural; tighter than page | clamp(core.spacing.4, core.spacing.8, core.spacing.16) |
separation.interactive.min | dense interactive target clusters | only between click/tap/focusable targets | clamp(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)
- Semantic-only consumption: components use semantic spacing only.
- Gap-first: sibling spacing uses
gap(Flex/Grid) by default. - Inset is for padding:
inset.*is only for internal padding. - Gutters are structural: use
gutter.*for page/section layout padding. - Separation is ergonomic:
separation.interactive.minis only for interactive targets. - No responsive logic in components: responsiveness lives in Core (
core.spacing.engine.unit), not in UI code.
Decision Matrix (pick fast)
- Padding inside an element? →
inset.control.*/inset.surface.* - Spacing between siblings? →
gap.stack.*/gap.inline.* - Page/section structure? →
gutter.page/gutter.section - Dense cluster of interactive targets? →
separation.interactive.min
Usage Examples
| Usage | Token |
|---|---|
| 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.
-
Tune
core.spacing.engine.unit(global density + responsiveness)- denser: lower clamp bounds
- airier: higher clamp bounds
-
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.mdinset.control.md > inset.control.lginset.surface.sm > inset.surface.mdinset.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.sminset.surface.md < inset.control.mdinset.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
hitfloor, 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 acore.spacing.*step alias -
stack gap order breaks:
gap.stack.xs > gap.stack.smgap.stack.sm > gap.stack.mdgap.stack.md > gap.stack.lggap.stack.lg > gap.stack.xl
-
gap.inline.xs > gap.inline.sm -
gutter.pageis not a boundedclamp(...)contract -
gutter.sectionis not a boundedclamp(...)contract -
gutter.*introduces direct responsive logic such ascqi,cqmin,cqmax,vi,vw,%, media queries, or breakpoint logic instead of composing fromcore.spacing.* -
gutter.pageresolves smaller thangutter.sectionat any bound -
separation.interactive.minis not a boundedclamp(...)contract -
separation.interactive.minintroduces direct responsive logic such ascqi,cqmin,cqmax,vi,vw,%, media queries, or breakpoint logic instead of composing fromcore.spacing.* -
separation.interactive.minhas a minimum bound below5px -
any semantic spacing token other than
gutter.*orseparation.interactive.mindefines 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.xsresolves to the same effective value asgap.inline.sm -
gutter.pageandgutter.sectionresolve to the same effective contract -
separation.interactive.minresolves to the same effective value asgap.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