Defining Fluid Spacing Tokens with clamp() and Container Queries
Part of Spacing & Layout Tokens. This page walks through building fluid spacing tokens in two stages: viewport-relative tokens using clamp(), then container-relative tokens using cqi units, so component padding scales to its nearest container rather than to the browser viewport.
vw-based clamp() produces the same padding in both sidebar and main content because both reference the viewport. Right: cqi-based clamp() produces proportionate padding because each component's container is its reference.Prerequisites
Before starting, confirm the following:
- Your spacing tokens follow the three-tier primitive → semantic → component structure described in Spacing & Layout Tokens.
- You understand the
clamp(MIN, PREFERRED, MAX)syntax. The PREFERRED value must be a length that can grow and shrink;vworcqiserve as the dynamic unit. - Your browser target includes Chrome 105+, Safari 16+, and Firefox 110+ for
container-typesupport. If you need to support older browsers, the fallback strategy in Step 6 is mandatory. - You have a token authoring file (JSON or CSS) where primitives live. The examples below use raw CSS custom properties; adapt the JSON token format to match your build pipeline.
- The CSS you are writing compiles to a stylesheet that is served on a page with
<meta name="viewport" content="width=device-width, initial-scale=1">. Without this tag,vwcalculates against the layout viewport at 980px, which breaks the slope math.
Step-by-Step Implementation
Step 1: Define primitive spacing anchors
Start with two fixed endpoints for each token — the minimum value at the narrowest useful viewport and the maximum value at the widest. These anchors become the MIN and MAX arguments of clamp(). Keeping them in a dedicated primitive block prevents magic numbers from leaking into downstream tokens.
/* tokens/spacing-primitives.css */
:root {
/* Fluid spacing anchors: --ds-space-fluid-{scale}-min/max */
--ds-space-fluid-xs-min: 0.25rem; /* 4px at 320px viewport */
--ds-space-fluid-xs-max: 0.5rem; /* 8px at 1440px viewport */
--ds-space-fluid-sm-min: 0.5rem; /* 8px at 320px viewport */
--ds-space-fluid-sm-max: 1rem; /* 16px at 1440px viewport */
--ds-space-fluid-md-min: 1rem; /* 16px at 320px viewport */
--ds-space-fluid-md-max: 2rem; /* 32px at 1440px viewport */
--ds-space-fluid-lg-min: 1.5rem; /* 24px at 320px viewport */
--ds-space-fluid-lg-max: 3rem; /* 48px at 1440px viewport */
}
Why this works: Decoupling the MIN/MAX anchors from the computed clamp() expression means you can update a single value and regenerate all fluid tokens without auditing every rule that consumes them. This is the same separation-of-concerns principle applied to responsive spacing scale naming.
Step 2: Compute the clamp slope for viewport-relative tokens
The PREFERRED argument inside clamp() is a linear equation of the form intercept + slope * 1vw. The slope encodes how many rem units the value changes per 1% of viewport width. Computing this correctly is the one place most implementations go wrong.
The formula (matching the derivation used for fluid typography tokens):
slope_vw = (max_rem - min_rem) / (max_vp_px - min_vp_px) * 100
intercept_rem = min_rem - slope_vw * (min_vp_px / 100)
For --ds-space-fluid-md (1rem → 2rem, 320px → 1440px):
slope_vw = (2 - 1) / (1440 - 320) * 100
= 1 / 1120 * 100
≈ 0.08929
intercept = 1 - 0.08929 * (320 / 100)
= 1 - 0.08929 * 3.2
= 1 - 0.2857
≈ 0.7143rem
Spot-check at 320px viewport: 0.7143 + 0.08929 * 3.2 = 0.7143 + 0.2857 = 1.0rem. Spot-check at 1440px: 0.7143 + 0.08929 * 14.4 = 0.7143 + 1.2857 = 2.0rem. Both clamp boundaries match exactly.
/* tokens/spacing-fluid-vw.css */
:root {
--ds-space-fluid-xs: clamp(0.25rem, calc(0.2143rem + 0.04464vw), 0.5rem);
--ds-space-fluid-sm: clamp(0.5rem, calc(0.4286rem + 0.08929vw), 1rem);
--ds-space-fluid-md: clamp(1rem, calc(0.7143rem + 0.08929vw * 2), 2rem);
--ds-space-fluid-lg: clamp(1.5rem, calc(0.9286rem + 0.13393vw * 2), 3rem);
}
Why this works: calc() inside clamp() lets the browser evaluate the preferred value at every rendered pixel width with full floating-point precision. Writing the slope explicitly prevents post-processor rounding from silently truncating the coefficient to a less precise value.
Step 3: Establish container contexts
Viewport-relative tokens break down the moment a component is placed inside a narrow column — the sidebar example from the diagram above. The fix is to opt components into the CSS containment system with container-type: inline-size. Every element with this property becomes a named containment context; cqi units inside it measure the element’s own inline size rather than the viewport.
/* layout/containers.css */
/* Named layout regions */
.layout-sidebar {
container-type: inline-size;
container-name: sidebar;
}
.layout-main {
container-type: inline-size;
container-name: main-content;
}
/* Generic component wrapper — use when no semantic name is needed */
.container-query-root {
container-type: inline-size;
}
Why this works: container-type: inline-size enables query containment on the horizontal axis only, which is what layout needs. Setting container-type: size (both axes) forces the element’s height to participate in containment too, which breaks intrinsic sizing for most components. Inline-size containment is the safe default.
Step 4: Compute container-relative clamp values with cqi
1cqi equals 1% of the containing element’s inline size — the same relationship 1vw has with the viewport. Swap the viewport-relative slope for a container-relative one by treating the container’s expected min/max width as the reference range instead of 320px–1440px.
For a card component expected to live in containers ranging from 200px (narrow column) to 600px (full-width panel):
slope_cqi = (max_rem - min_rem) / (max_container_px - min_container_px) * 100
= (2 - 1) / (600 - 200) * 100
= 1 / 400 * 100
= 0.25
intercept = 1 - 0.25 * (200 / 100)
= 1 - 0.5
= 0.5rem
/* tokens/spacing-fluid-cqi.css */
:root {
/*
* Container-relative fluid tokens.
* Reference range: 200px (narrow column) → 600px (full panel).
* These resolve against the nearest container-type: inline-size ancestor.
*/
--ds-space-cqi-sm: clamp(0.5rem, calc(0.3rem + 0.1cqi), 1rem);
--ds-space-cqi-md: clamp(1rem, calc(0.5rem + 0.25cqi), 2rem);
--ds-space-cqi-lg: clamp(1.5rem, calc(0.5rem + 0.5cqi), 3rem);
}
Why this works: cqi is a relative length unit that lives in the same syntactic slot as vw. Because clamp() resolves after containment is established, the browser correctly evaluates cqi against the nearest container-type: inline-size ancestor rather than the viewport. Components can be moved between layout slots with no CSS changes required.
Step 5: Apply the container tokens to components
Wire the cqi-based tokens to component padding and gap properties. The component does not reference its container by name — containment propagates automatically through the nearest ancestor with container-type: inline-size.
/* components/card.css */
.card {
padding: var(--ds-space-cqi-md);
gap: var(--ds-space-cqi-sm);
}
/* components/media-object.css */
.media-object {
padding-block: var(--ds-space-cqi-sm);
padding-inline: var(--ds-space-cqi-md);
gap: var(--ds-space-cqi-sm);
}
/* components/section-header.css */
.section-header {
padding-block: var(--ds-space-cqi-lg);
padding-inline: var(--ds-space-cqi-md);
}
Why this works: Using logical properties (padding-block, padding-inline) alongside cqi keeps the token coherent across writing modes. cqi is always the inline-size axis of the container, which aligns with the inline axis of the text, regardless of the writing direction.
Step 6: Write a vw fallback for older browsers
cqi requires Chrome 105+, Safari 16+, and Firefox 110+. For products that must support older engines, layer a vw-based fallback before the cqi declaration. Modern browsers apply the last valid declaration; older ones apply the first one they understand.
/* Progressively enhance from vw to cqi */
.card {
/* Fallback: viewport-relative fluid spacing */
padding: var(--ds-space-fluid-md);
/* Enhancement: container-relative fluid spacing (overrides above in supporting browsers) */
padding: var(--ds-space-cqi-md);
}
Alternatively, gate the override with @supports:
.card {
padding: var(--ds-space-fluid-md); /* vw-based fallback */
}
@supports (padding: 1cqi) {
.card {
padding: var(--ds-space-cqi-md);
}
}
Why this works: CSS custom properties resolve at computed-value time, so the fallback inside var(--ds-space-fluid-md, ...) is never reached — the property itself is defined on :root regardless of browser support. The @supports block targets the cqi unit directly, which gives a clean feature boundary without relying on the token variable name.
Step 7: Export tokens from your build pipeline
If your team uses Style Dictionary or a similar token compiler, the slope calculation belongs in a transform, not handwritten CSS. Add a custom transform that computes intercept and slope from the min/max anchors at build time.
// style-dictionary/transforms/fluid-spacing.js
const StyleDictionary = require('style-dictionary');
StyleDictionary.registerTransform({
name: 'spacing/fluid-clamp',
type: 'value',
matcher: (token) => token.attributes.category === 'spacing' && token.fluid,
transformer: (token) => {
const { minRem, maxRem, minContainerPx, maxContainerPx, unit = 'vw' } = token.fluid;
const scale = 100 / (maxContainerPx - minContainerPx);
const slope = (maxRem - minRem) * scale;
const intercept = minRem - slope * (minContainerPx / 100);
const slopeFixed = slope.toFixed(5);
const interceptFixed = intercept.toFixed(4);
return `clamp(${minRem}rem, calc(${interceptFixed}rem + ${slopeFixed}${unit}), ${maxRem}rem)`;
},
});
Why this works: Generating the clamp() expression from declarative token data — rather than hard-coding it in CSS — means a designer can change the min/max anchors in the token file and the correct slope is recalculated automatically in CI. No manual arithmetic, no rounding drift.
Verification
After applying the tokens, confirm correct behavior with these checks:
DevTools computed value check. Open DevTools, select an element that uses --ds-space-cqi-md, and inspect the computed padding value. Resize the nearest ancestor container (not the viewport) by dragging a layout splitter or toggling a CSS width in DevTools. The padding should change even if the viewport width stays fixed. If it does not change, the element’s ancestor is missing container-type: inline-size.
Viewport resize cross-check. With vw-based tokens, resizing the viewport changes the value. With cqi-based tokens and a fixed-width container, resizing the viewport should have no effect on the component’s padding. This confirms cqi is resolving against the container and not falling back to viewport-relative behavior.
Clamp boundary confirmation. In DevTools, set the container width to exactly min_container_px (200px in the examples above). The computed padding should equal min_rem (1rem for --ds-space-cqi-md). Set it to max_container_px (600px); the computed value should equal max_rem (2rem). Values outside these bounds should be clamped, not extrapolated.
Build pipeline output check. If using Style Dictionary, inspect the emitted CSS file and confirm the clamp() expression’s slope has at least four decimal places. A truncated slope like 0.09vw instead of 0.08929vw introduces visible step artifacts at certain viewport widths.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
cqi padding does not change when container resizes |
No ancestor has container-type: inline-size |
Add container-type: inline-size to the layout wrapper immediately enclosing the component |
cqi padding changes when only the viewport resizes, not the container |
The element itself has container-type — making it its own container — and its width is set with vw |
Move container-type to a dedicated wrapper element; do not set it on the component that consumes the token |
Computed padding is always at the clamp() minimum |
Slope coefficient is negative or the intercept is miscalculated — the preferred value is below MIN at most container widths | Recalculate slope: verify (maxRem - minRem) is positive and (maxContainerPx - minContainerPx) uses the correct range |
| Nested containers produce unexpected spacing | Inner container-type shadows the outer one; the component resolves cqi against the wrong ancestor |
Audit the containment tree with DevTools; use container-name to reference a specific ancestor explicitly via @container name { } |
clamp() slope correct but visual output looks stepped |
Build pipeline truncated the vw/cqi coefficient to fewer than 4 decimal places |
Increase float precision in the token transform to toFixed(5) and rebuild |
Migration Note
Teams coming from a fixed breakpoint spacing system can phase in fluid tokens without a rewrite by using CSS cascade layers. Declare legacy breakpoint overrides in a low-priority layer and fluid tokens in a higher-priority layer. Components can be migrated cluster by cluster — old components continue using the legacy layer, while new or updated components reference fluid tokens.
/* Phase 1: layer structure lets both coexist */
@layer ds.legacy, ds.fluid;
@layer ds.legacy {
:root {
--space-padding: 1rem;
}
@media (min-width: 768px) {
:root { --space-padding: 1.5rem; }
}
@media (min-width: 1280px) {
:root { --space-padding: 2rem; }
}
}
@layer ds.fluid {
/* Migrated tokens override legacy equivalents in this layer */
:root {
--space-padding: var(--ds-space-fluid-md);
}
}
During Phase 2, components that have been migrated reference --ds-space-cqi-md directly. After two release cycles with no regression in layout shift metrics, remove the ds.legacy layer and its media query overrides entirely. The breakpoint count in the codebase drops to zero for spacing, replaced by continuous interpolation.
Related
- Spacing & Layout Tokens — the parent covering the full primitive-to-semantic token hierarchy and CI validation pipeline for all spacing tokens.
- Naming Conventions for Responsive Spacing Scales — naming strategy and Stylelint enforcement for the token names referenced throughout this page.
- Mapping Typography Tokens to CSS Clamp Functions — the same slope-intercept derivation applied to font sizes, with additional CI debugging protocols for computed value drift.