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

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

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.