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.

Viewport-fluid vs container-fluid spacing comparison Two columns comparing how clamp() with vw units scales to the viewport while cqi units scale to the nearest container, isolating component spacing from page width. Viewport-fluid (vw) clamp() references viewport width Viewport Sidebar pad: 18px Main Content pad: 18px same vw → same value ignores container size Container-fluid (cqi) clamp() references container inline size Viewport Sidebar pad: 8px Main Content pad: 22px each cqi → own container padding scales independently Container-fluid tokens adapt to component context; viewport-fluid tokens do not.
Left: 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 Link to this section

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; vw or cqi serve as the dynamic unit.
  • Your browser target includes Chrome 105+, Safari 16+, and Firefox 110+ for container-type support. 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, vw calculates against the layout viewport at 980px, which breaks the slope math.

Step-by-Step Implementation Link to this section

How a clamp expression resolves across three viewport widths A plot of one clamp expression: a flat minimum at 320 pixels, a linear fluid region at 768 pixels, and a clamped maximum at 1440 pixels, with the resolved value labelled at each width. clamp(1rem, 0.5rem + 2vw, 2rem) across three viewports 320px 768px 1440px 16px min 23px fluid 32px max The floor holds until the preferred value overtakes it, then growth is linear until the ceiling clamps it. Both ends matter: the floor protects dense small screens, the ceiling stops a 4K layout from drifting apart. Use cqi instead of vw inside a container query context — the token then scales with the component, not the window.
A clamp token is three behaviours in one declaration. Naming it after the role rather than a size keeps that flexibility invisible to the component.

Step 1: Define primitive spacing anchors Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

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
Three ways a correct clamp expression still renders wrong Three panels describe the zoom, nesting and container traps, each with the symptom and the corresponding fix. Why a fluid token can look wrong while the maths is right Zoom trap vw ignores page zoom at 200% the gap shrinks relative to the text fix: mix in a rem term Nesting trap two fluid gaps compound in a nested card visible as drift, not a jump fix: fixed inner, fluid outer Container trap cqi with no container falls back to the viewport silently, at the small size fix: assert container-type
All three failures come from the unit, not the formula. Check what the percentage is measured against before adjusting any numbers.

Migration Note Link to this section

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.