8 min read

Fluid spacing with CSS clamp: beyond font sizes

The same clamp() logic that fixes type also fixes spacing — but the rules are different. A practical scale, what should never be fluid, and how to document it.

Spacing breaks differently than type

Once a fluid type scale is working, the next thing that looks wrong is everything around it. Headings breathe correctly at 1440px and then sit on top of their sections at 360px, because the type scaled and the 96px section padding did not. The instinct is right — the same clamp() arithmetic that fixed the type will fix the spacing — but applying it unchanged produces a layout that feels subtly broken in a way nobody can name.

The reason is that type and space fail in opposite directions. Type has a floor set by legibility: below roughly 14px, body copy stops being comfortable regardless of screen size, so a fluid type scale compresses gently — a 2.5× ratio between a heading's smallest and largest size is already aggressive. Space has no such floor. On a 360px screen, 96px of section padding is a quarter of the visible height and reads as a bug; on a 1440px screen, 32px reads as cramped. Spacing wants a much wider range than type, often 3× or 4× between the ends.

Which means the two scales cannot share a ratio. Copying the type scale's slope onto spacing tokens gives you padding that barely moves — technically fluid, visually pointless. The fix is not more math, it is a separate scale with its own anchors, built the same way and documented the same way but tuned for a different job.

  • Type compresses gently: legibility sets a hard minimum.
  • Space compresses hard: small screens need proportionally far less of it.
  • A spacing token that moves less than about 8px across the full viewport range is not worth being fluid.
  • The two scales are generated by the same method, never by the same numbers.

Build a spacing scale, not a list of clamps

The failure mode here is identical to the one type tokens hit, and it arrives faster. Someone needs a gap, writes a one-off clamp() inline, and within a month the codebase has nineteen unique fluid spacing expressions that were each correct once. The structure that prevents it is the same two-layer split described in typography tokens for design systems: numbered primitives that hold the scale, semantic names that hold the intent.

For spacing, the primitive layer is genuinely numeric — nobody needs a semantic name for "the fourth step" — so a numbered ramp is honest rather than lazy. What matters is that the steps are few and the ratio between them is visible. Six to eight steps covers almost every layout; a scale with fourteen steps is a scale where two adjacent values differ by 2px and nobody can tell which one to pick.

Note what happens at the bottom of the ramp: the smallest two steps are static. That is deliberate, not an oversight, and it is the single most useful rule in this article — small space is structural, and structural space should not move.

:root {
  /* Primitives — 320px → 1440px, documented below each value. */
  --space-100: clamp(0.25rem, 0.25rem + 0vw,      0.25rem);  /* 4 → 4    (static) */
  --space-200: clamp(0.5rem,  0.5rem  + 0vw,      0.5rem);   /* 8 → 8    (static) */
  --space-300: clamp(0.75rem, 0.679rem + 0.357vw, 1rem);     /* 12 → 16  */
  --space-400: clamp(1rem,    0.857rem + 0.714vw, 1.5rem);   /* 16 → 24  */
  --space-500: clamp(1.5rem,  1.071rem + 2.143vw, 3rem);     /* 24 → 48  */
  --space-600: clamp(2rem,    1.429rem + 2.857vw, 4rem);     /* 32 → 64  */
  --space-700: clamp(3rem,    1.571rem + 7.143vw, 8rem);     /* 48 → 128 */
}

Not everything should be fluid

A 4px gap between an icon and its label is not a design decision about density; it is optical alignment. Scale it to 6px on a wide screen and the icon starts to drift away from the word it belongs to. The same applies to the gap between a form field and its error message, the inset on a focus ring, the padding inside a small badge. These values were chosen against the shape of the glyphs and the border, not against the size of the viewport.

The distinction that holds up in practice is between space that separates regions and space that positions parts. Region space — section padding, grid gutters, the gap between cards, the page margin — should be fluid, because it expresses density and density genuinely should change with screen size. Part space — icon gaps, control insets, border offsets, the space between a label and its input — should be static, because it expresses relationship, and relationships do not get looser on a desktop.

Two more categories should never be fluid, for reasons that are not aesthetic. Touch targets have a hard minimum — 44×44px on iOS, 48×48px in Material — and a fluid inset that shrinks a tap area below it on the exact devices where fingers are the input method is an accessibility failure, not a density choice. And borders, outlines, and focus rings measured in fractional pixels produce visible rendering inconsistency across devices; leave them at fixed values.

  • Fluid: section padding, page margins, grid gutters, gaps between cards, space between major blocks.
  • Static: icon-to-label gaps, control insets, badge padding, label-to-input space.
  • Never fluid: anything that determines a minimum touch target.
  • Never fluid: border widths, outline offsets, focus-ring thickness.
/* Region space: fluid. Density changes with the screen. */
.section      { padding-block: var(--space-700); }
.card-grid    { gap: var(--space-500); }
.page         { padding-inline: var(--space-400); }

/* Part space: static. Relationships don't stretch. */
.button       { gap: var(--space-200); }        /* icon ↔ label */
.field-error  { margin-block-start: var(--space-100); }
.badge        { padding: var(--space-100) var(--space-200); }

Vertical rhythm is where it pays off

Horizontal fluid spacing is nice. Vertical fluid spacing is the reason to do any of this, because vertical space is what makes a page feel considered or cheap, and it is what breaks hardest between breakpoints. A landing page built at 1440px with 128px between sections becomes an endless scroll at 375px — the same content, four times the perceived length, because the user is scrolling through padding.

The pattern that works is a small set of rhythm tokens applied at consistent structural depths, rather than per-component margins. One value for space between major sections, one for space between blocks inside a section, one for space between elements inside a block. Three levels, applied consistently, produce a page that reads as designed at every width.

There is a coupling here that catches people out. Space above a heading should relate to the heading's own size, and the heading's size is itself fluid — so if the spacing scale and the type scale compress at different rates, the ratio between a heading and the air above it changes across the viewport range. Usually that is fine and even desirable, since headings want relatively more air on wide screens. But it is worth checking at both ends rather than assuming, and it is worth remembering that the visible gap is not the margin alone: half-leading from the heading's line-height sits on top of it, which is exactly the trap described in why line-height is harder on the web than it looks.

/* Three levels of vertical rhythm, applied by depth. */
.section + .section     { margin-block-start: var(--space-700); }  /* 48 → 128 */
.section > * + *        { margin-block-start: var(--space-500); }  /* 24 → 48  */
.prose  > * + *         { margin-block-start: var(--space-400); }  /* 16 → 24  */

/* Headings pull closer to the text they introduce. */
.prose > :is(h2, h3) + * { margin-block-start: var(--space-300); }
.prose > * + :is(h2, h3) { margin-block-start: var(--space-600); }

When the viewport is the wrong input

Viewport-based spacing carries an assumption that stops being true the moment a component is reusable: that the component's available width tracks the window's width. A card at --space-500 padding is correctly spacious in a full-width hero and absurdly padded in a 280px sidebar on the same 1440px screen, because vw describes the window and the card lives in a column.

Container query units fix this directly. cqi resolves against the inline size of the nearest container, so the same expression produces tight spacing in a narrow column and generous spacing in a wide one, on any screen. The arithmetic is identical to the viewport version — only the anchors change, from viewport widths to container widths.

The practical rule: page-level scaffolding — page margins, section padding, the outer grid — stays viewport-based, because it genuinely is a function of the window. Anything that can be dropped into more than one column should be container-based. Mixing the two in one stylesheet is correct, not a compromise, and the same reasoning applies to fluid type inside reusable components.

.card-container { container-type: inline-size; }

.card {
  /* 320px container → 16px, 720px container → 24px */
  padding: clamp(1rem, 0.6rem + 2cqi, 1.5rem);
  gap:     clamp(0.75rem, 0.55rem + 1cqi, 1rem);
}

Document it, then generate it

A spacing clamp() is even more opaque than a type one, because there is no obvious sanity check on the numbers. clamp(3rem, 1.571rem + 7.143vw, 8rem) gives no clue that it means "48px on a phone, 128px on a desktop," and six months later someone will adjust the middle term to fix one breakpoint and silently break the other end. The same four facts that make a fluid type token maintainable make a spacing token maintainable: minimum viewport, maximum viewport, size at each end.

From there it is the same single-source pipeline the type tokens already use. In Tailwind v4 the @theme block takes spacing values directly and derives the utilities, so one file feeds both scales without a config layer in between — the mechanics are covered in Tailwind v4 typography tokens with CSS clamp.

The arithmetic itself is the least interesting part, and worth automating — the TYPECLAMP generator takes two viewport widths and two sizes and returns the expression, in CSS, Tailwind, SCSS, Style Dictionary, or JSON. Feed it your spacing anchors the same way you feed it type anchors; the method is the one described in responsive typography with CSS clamp: rules that hold up, and if your anchors are coming out of Figma, the same design-viewport-as-upper-anchor logic from how to convert Figma font sizes to CSS clamp tokens applies unchanged.

A fluid type scale on a static spacing grid is a half-finished system, and the half that is missing is the one users feel first. Getting the spacing to move with the type is not much extra work — the same generator, the same four documented facts, one more scale — but it is the difference between a layout that was designed at one width and a layout that was designed for a range.

  • Give spacing its own scale with its own ratio — never reuse the type scale's slope.
  • Keep the ramp to six to eight steps so the choice stays obvious.
  • Make region space fluid; keep part space, touch targets, and borders static.
  • Apply vertical rhythm by structural depth, not per component.
  • Use cqi for anything reusable, vw for page scaffolding.
  • Document min/max viewport and min/max size on every fluid value.
{
  "space": {
    "700": {
      "$type": "dimension",
      "$value": "clamp(3rem, 1.571rem + 7.143vw, 8rem)",
      "$description": "Space between major page sections. Regenerate, don't hand-edit.",
      "$extensions": {
        "com.typeclamp": {
          "minViewport": 320,
          "maxViewport": 1440,
          "minSize": 48,
          "maxSize": 128
        }
      }
    }
  }
}

@import "tailwindcss";

@theme {
  --spacing-300: clamp(0.75rem, 0.679rem + 0.357vw, 1rem);
  --spacing-400: clamp(1rem,    0.857rem + 0.714vw, 1.5rem);
  --spacing-500: clamp(1.5rem,  1.071rem + 2.143vw, 3rem);
  --spacing-700: clamp(3rem,    1.571rem + 7.143vw, 8rem);
}

/* Usage: class="py-700 gap-500" */