Building a Fluid Spacing Scale with clamp() and Container Units

Spacing is usually the last thing to go fluid. Teams adopt clamp() for headings, then leave margins, padding, and gaps frozen at fixed rem values that look cramped on large layouts and bloated on small ones. This guide builds a complete fluid spacing scale as design tokens, using clamp() for the interpolation and cqi container units so spacing tracks the component rather than the whole window. It belongs to Fluid Typography with clamp() within Mastering Container Queries & Responsive Layouts, and it reuses the same min/preferred/max anatomy that drives fluid type, applied to space instead of size.

Why container units beat viewport units for spacing

A spacing scale exists to create consistent rhythm. If the rhythm is keyed to the viewport with vw, every component inherits the page width as its reference, so a card in a narrow sidebar gets the same generous padding as a hero spanning the full page. That breaks the intent: the card looks padded out of proportion to its own size. Container units fix this. cqi resolves against the inline size of the nearest ancestor that declares container-type, so a component's spacing grows with the component. Drop the same card into a 280px rail or a 900px main column and its internal gaps stay proportional in both, with no context-specific overrides.

Spacing has its own stake in the zoom question, and it is not the same stake typography has. When a reader enlarges text, the text grows but a px or bare-container-unit gap does not, so the ratio of ink to air collapses and lines of one paragraph start crowding the heading above them. Keeping each clamp() bound in rem ties the floor and ceiling of every gap to the same root the text is growing from, so the whole composition scales as a unit rather than the type outgrowing its container. Only the preferred middle term should hold the cqi slope. The underlying mechanism — why a unit that looks responsive can be inert under zoom — is worked through in fluid type, accessibility and zoom; the consequence for spacing is simply that a gap without a rem term is a gap that shrinks, relatively, every time someone needs it not to.

Because everything resolves in CSS during layout, there is no JavaScript and no resize listener — the spacing recalculates as part of the normal layout pass.

There is a deliberate split worth naming. Page-level rhythm — the outer page gutters, the vertical spacing between major sections — can legitimately use vw, because there the viewport is the relevant reference. Component-internal rhythm should use cqi. A well-built token set exposes both and lets each context pick.

Which token goes where

A scale is only useful if the choice of step is mechanical rather than aesthetic, so it is worth writing the pairing rules down alongside the tokens. Space inside a box should be one or two steps smaller than the space around it — a card padded with --space-m sitting in a grid gapped with --space-m reads as a single undifferentiated field, because the boundary between two cards is the same width as the boundary between a card's edge and its text. Bump the grid gap to --space-l and the grouping becomes legible without any borders.

Within a component, the gap between tightly related items — a label and its value, an icon and its text — should come from the two or three smallest steps, and the gap between distinct groups from the middle steps. The largest two steps belong to page-level section rhythm and almost never appear inside a component; if --space-2xl shows up as a card's padding, either the card is a page region in disguise or the scale is being used to compensate for a layout problem elsewhere.

The last rule is directional. Prefer gap and single-direction margins over symmetric ones. A margin-block: var(--space-m) on every heading collapses against the paragraph margin above it in ways that make the resolved rhythm hard to predict from the stylesheet; margin-block-start only, or a gap on the flow container, gives one declaration one visible effect.


Complete working implementation

This block defines an eight-step token scale, wires a card to a sizing container, and lays the cards out in a grid whose gap is itself a fluid token. Every bound is rem; every slope is cqi.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
  :root {
    /* Fluid spacing tokens. Each is clamp(rem floor, rem base + cqi slope, rem ceiling).
       The cqi term means: grow with the component's own width. */
    --space-3xs: clamp(0.25rem, 0.2rem + 0.4cqi, 0.5rem);
    --space-2xs: clamp(0.5rem,  0.4rem + 0.6cqi, 0.75rem);
    --space-xs:  clamp(0.75rem, 0.6rem + 0.8cqi, 1rem);
    --space-s:   clamp(1rem,    0.8rem + 1cqi,   1.5rem);
    --space-m:   clamp(1.5rem,  1.1rem + 1.6cqi, 2.25rem);
    --space-l:   clamp(2rem,    1.4rem + 2.4cqi, 3.5rem);
    --space-xl:  clamp(3rem,    2rem + 3.6cqi,   5rem);
    --space-2xl: clamp(4rem,    2.6rem + 5cqi,   7rem);
  }

  body { margin: 0; font-family: system-ui, sans-serif; }

  /* The grid region establishes a container so its children's cqi resolves
     against this region's width, and uses a fluid token for the gap. */
  .grid {
    container-type: inline-size;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
    gap: var(--space-m);
    padding: var(--space-l);
  }

  /* Each card is also its own container, so its internal padding/gaps
     scale to the card width, not the grid width. */
  .card {
    container-type: inline-size;
    display: grid;
    gap: var(--space-s);
    padding: var(--space-m);
    border: 1px solid #d0d0d0;
    border-radius: var(--space-xs);
  }

  .card h3 { margin: 0; }
  .card p  { margin: 0; line-height: 1.5; }

  /* A pill uses the smallest tokens; it spaces itself by its own width */
  .tag {
    container-type: inline-size;
    display: inline-block;
    padding: var(--space-3xs) var(--space-xs);
    border-radius: 999px;
    background: #7aa2ff33;
    font-size: 0.875rem;
  }
</style>
</head>
<body>
  <main class="grid">
    <article class="card">
      <span class="tag">Tokens</span>
      <h3>Proportional padding</h3>
      <p>This card's gaps grow with the card, not the page, because cqi resolves against the card.</p>
    </article>
    <article class="card">
      <span class="tag">Rhythm</span>
      <h3>Consistent scale</h3>
      <p>Every gap, margin, and pad references one of eight named tokens.</p>
    </article>
    <article class="card">
      <span class="tag">Zoom-safe</span>
      <h3>rem bounds</h3>
      <p>Floors and ceilings are in rem, so spacing respects browser zoom.</p>
    </article>
  </main>
</body>
</html>

Narrow the window and watch the cards reflow; each card's internal padding shrinks toward its rem floor while the grid gap shrinks independently, because they reference different containers.

Fluid spacing scale ramp Eight stacked bars, each labeled with a token name, growing in width from the smallest 3xs step to the largest 2xl step. spacing token ramp 3xs 2xs xs s m l xl 2xl

Key technique: cqi in the preferred term, rem in the bounds

The whole scale follows one shape per token: clamp(rem-floor, rem-base + N cqi, rem-ceiling). The cqi unit equals 1% of the container's inline size, so 1cqi on a 600px-wide container is 6px and on a 300px container is 3px — the slope automatically halves when the component is half as wide. That is exactly the proportional behavior a spacing scale wants. Keeping the floor and ceiling in rem means the token can never produce zero spacing on a tiny container nor a runaway gap on a huge one, and both bounds still answer to zoom. Adjust the single cqi coefficient per step to tune how aggressively that step responds to size.

Two behaviours of cqi are worth internalising before you deploy a scale built on it.

First, a token applied to the same element that declares container-type is legal and does what you want. .card above sets container-type: inline-size and then pads itself with var(--space-m), whose cqi term resolves against .card's own inline size. That is not circular, because inline-size containment means the card's width was determined from the outside — by the grid track it sits in — before its padding was ever consulted. Without that containment the same declaration would be a genuine loop.

Second, and less obviously, cqi does not fail loudly when there is no container. If no ancestor in the chain declares container-type, container query length units resolve against the small viewport instead, so 1cqi silently becomes 1svw. A token that was supposed to track a 300px card ends up tracking a 1400px window, and the only symptom is spacing that looks slightly too generous. When a component's spacing seems keyed to the wrong thing, check that the containment declaration survived — a refactor that moved a wrapper is the usual cause.


Variation: tokens that also drive transition durations

Because each token is a plain custom property, the spacing scale can do double duty and feed motion timing, so larger components animate slightly slower and motion stays proportional to space. This is the same idea explored cross-area in Fluid Spacing Tokens Driving Transition Durations.

:root {
  /* Derive a duration from a spacing magnitude: more space, more travel,
     so allow a touch more time. Bounds keep it within sane motion limits. */
  --motion-s: clamp(120ms, 80ms + 1cqi, 220ms);
  --motion-m: clamp(180ms, 120ms + 1.6cqi, 320ms);
}

.card {
  transition: transform var(--motion-m) ease, box-shadow var(--motion-s) ease;
}

@media (prefers-reduced-motion: reduce) {
  .card { transition: none; }
}

The reduced-motion guard is mandatory: proportional duration is still motion, and users who opt out must get none.

Browser support note

clamp() shipped across all four engines years ago and needs no gate. Container query units cqi, cqw, and cqb require Chrome 105+, Edge 105+, Firefox 110+, and Safari 16+, the same releases that shipped container-type itself, so a browser that understands the containment declaration understands the units.

The fallback is a two-declaration cascade rather than a feature query, which keeps it to one line per token. Declare a static rem value first and the fluid one second; an engine that cannot parse cqi discards the second declaration at parse time and keeps the first, and every engine that can parse it takes the second because it comes later:

:root {
  --space-m: 1.75rem;                            /* every engine */
  --space-m: clamp(1.5rem, 1.1rem + 1.6cqi, 2.25rem);
}

Choose the static value near the middle of the fluid range rather than at either bound — a fallback pinned to the floor makes old browsers look cramped, and one pinned to the ceiling makes narrow layouts overflow. If you prefer an explicit guard, @supports (padding: 1cqi) tests the units directly and is more accurate here than testing container-type, since it is the unit resolution and not the containment that the fallback is protecting against.

FAQ

Why use cqi instead of vw for a spacing scale?cqi sizes against the nearest sizing container, so a component spaces itself by its own width. The same component then looks correct in a sidebar and a full-width region without per-context overrides, which vw cannot do.

Should spacing tokens use rem floors like typography tokens? Yes. Express the clamp() min and max in rem so spacing respects browser zoom and never collapses to an unusably small gap when a user enlarges text.

How many steps should a fluid spacing scale have? Six to eight named steps covers most interfaces. A common ratio is roughly 1.5 between adjacent steps, which keeps the rhythm visible without producing dozens of near-identical tokens.

Can the same tokens drive animation timing? Yes. Because the tokens are plain custom properties, the same scale that sizes gaps can feed transition durations, keeping motion proportional to spacing across the design system.

Related articles

More pages in the same section.