Design System

A practical spacing system for product UI

A simple spacing system for product and marketing screens: fewer arbitrary values, clearer density rules, and less visual drift.

Effective
Last updated
Reading time
7 min

Spacing decisions look small until a product has hundreds of screens.

Should this card use 16px or 20px? Should the grid gap change on tablet? Should the button padding follow the text size? Should dashboards be denser than marketing pages? If every designer and engineer answers those questions locally, the product starts to drift.

The goal of this spacing system is simple: reduce arbitrary decisions without making the UI feel rigid.

The problem

Most design systems give teams a spacing scale, then still ask them to choose values everywhere.

An 8px scale is useful. It does not say when to use 16px versus 24px. Breakpoints are useful. They do not explain why the jump should happen at 768px. Utility classes are useful. They make it easy to choose from many values, which can still produce inconsistent screens.

The result is not dramatic on one page. It becomes visible across the product:

  • cards feel denser in one surface than another
  • dashboards waste space or become cramped
  • marketing pages use different rhythm from product pages
  • engineers add one-off overrides
  • visual QA becomes a spacing debate

We need fewer spacing decisions and clearer rules.

Why spacing debt compounds

Spacing debt rarely appears as one obvious defect.

It shows up as a product that feels slightly different from page to page. One dashboard uses comfortable card spacing. Another feels cramped. One marketing page has a strong editorial rhythm. Another feels like a stack of modules. One table wastes vertical space. Another becomes hard to scan. None of these issues is large enough to stop a release. Together, they make the product feel less intentional.

The cost is also operational. Designers spend review time correcting local choices. Engineers add overrides. Components grow optional props for one-off spacing. QA starts catching visual drift that should have been prevented by the system. The team debates pixels because the system did not give them a better language.

Good spacing systems do not make products more decorative. They make product work calmer. They reduce how many small visual decisions a team has to make while shipping real features.

The rules

The system uses three rules.

Components own inside spacing. Button padding should scale with the button's text. Form-field padding should scale with the field's text. This uses relative units like em, so the component stays balanced when text size changes.

Layouts own outside spacing. Gaps between cards, columns, rows, and sections should come from a shared scale. Engineers should not ask "is this 20px or 24px?" on every screen. They should ask "which step of the scale does this job sit on, and is this surface dense or open?"

Spacing follows type. Typography and spacing should move together. If body text grows slightly on larger screens, vertical rhythm and gaps should grow in proportion, but not so much that dashboards become airy and inefficient.

The tokens

The system has two numbers at the top, and everything else is derived from them.

--phi: 1.618; /* golden ratio */
--rho: 0.618; /* damping coefficient, φ⁻¹ */

The unit of vertical rhythm is the baseline: body text size multiplied by its own line height.

--baseline: calc(var(--size-md) * var(--size-md--line-height));

From there the system defines a ladder of ratios, not a ladder of pixels. Each step down multiplies by ρ, so the scale is geometric rather than arbitrary.

--sizing-lg: 1;
--sizing-md: var(--rho); /* ×0.62 */
--sizing-sm: calc(var(--sizing-md) * var(--rho)); /* ×0.38 */
--sizing-xs: calc(var(--sizing-sm) * var(--rho)); /* ×0.24 */

Steps above lg open up more slowly, because large spacing should grow but not run away:

--sizing-xl: calc(1 + var(--rho) * 0.5); /* ×1.31 */
--sizing-2xl: calc(1 + var(--rho) * 1); /* ×1.62 */

The actual lengths are the baseline multiplied by a rung of that ladder:

--spacing-md: calc(var(--baseline) * var(--sizing-md)); /* ~16–18px */
--spacing-sm: calc(var(--baseline) * var(--sizing-sm)); /* ~10–11px */

The ladder runs from 3xs to 4xl. Corner radius comes off the same baseline through its own ratio, so rounding stays proportional to rhythm instead of being a separate opinion:

--rho-radius: 0.6;
--radius-base: calc(var(--baseline) * var(--rho-radius));

The point is that the steps are related to each other by a constant. Change the baseline or ρ, and the whole system moves together.

How to choose the right token

The token should describe the job, not the size.

Most spacing decisions pick a rung of the ladder: gap-sm, py-md, mt-2xl. Scanning-dense surfaces such as tables and inboxes sit low on the ladder. Narrative sections on marketing and docs pages sit high on it.

For component padding and gaps, the system offers a second option that removes the choice entirely. The p-auto, px-auto, py-auto, and gap-auto utilities do not name a rung. They read a coefficient from their context:

.p-auto {
  padding-inline: calc(var(--baseline) * var(--k-px));
  padding-block: calc(var(--baseline) * var(--k-py));
}

A component written with p-auto gap-auto inherits whatever density its surrounding surface declares. The same component is comfortable on a marketing page and tight in a data table, without a prop, a variant, or an override.

This makes review much easier. The question becomes "is this the right kind of spacing?" instead of "is this the right pixel value?"

Fluid type and spacing

The implementation uses CSS clamp() for type and spacing. That lets values change smoothly between small and large screens without hard jumps at arbitrary breakpoints.

Example:

--size-md: clamp(1rem, 0.94rem + 0.35vw, 1.125rem);

Spacing does not need its own clamp(). It inherits the fluidity through the baseline:

--baseline: calc(var(--size-md) * var(--size-md--line-height));
--spacing-md: calc(var(--baseline) * var(--sizing-md));

Because --size-md is fluid, --baseline is fluid, and every rung of the spacing ladder is fluid with it. Only the type scale declares breakpoint behavior. Type and space are not separate systems.

Density matters

Not every surface should breathe the same way.

A marketing page can use more vertical space. A revenue dashboard should be tighter. A table of campaigns should not feel like a landing page. A hero should not use the same rhythm as a budget-allocation grid.

So density is a context, not a page type. Three coefficients select which rung of the ladder the automatic utilities land on:

--k-py: var(--sizing-md); /* padding-block coefficient */
--k-px: var(--sizing-lg); /* padding-inline coefficient */
--k-gap: var(--sizing-md); /* gap coefficient */

Any container can move them for its subtree with the dense-y-*, dense-x-*, and dense-gap-* utilities. A table wrapper marked dense-y-sm dense-gap-sm tightens every p-auto and gap-auto component inside it. Nothing about those components changes.

The ladder stays the same. The rung changes. That keeps the product coherent without forcing every page to feel identical, and without a theme class that a component has to know about.

How we use it

In Lyberty, spacing is a shared system, not a per-page preference.

The marketing site, docs, product surfaces, and published tenant sites all resolve against the same --phi and --rho. Components that should adapt use the automatic utilities and inherit their context's density. Components that need a specific rung name one from the ladder.

Published tenant sites are the useful stress test. A tenant can override --rho, --baseline, or the density coefficients for its own pages, and the rest of the ladder recomputes from those values. This works only because the ladder is expressed as ratios: there is a single place to intervene, and the proportions survive the change.

This reduces visual drift and makes review easier. Instead of debating arbitrary pixel values, the team can ask whether the right token was used.

Practical guidance

Use this sequence when building a screen:

  1. Set the density on the surface, not on the components inside it.
  2. Inside reusable components, prefer the automatic utilities so they adapt to wherever they are used.
  3. For layout you control directly, name a rung of the ladder rather than a pixel value.
  4. Move down the ladder for repeated dense rows, and up it for narrative blocks.
  5. Change ρ or the baseline when a whole surface should breathe differently. Do not patch individual values to simulate it.
  6. Only add a new token when no rung of the existing ladder can describe the job.

The system is not about mathematical purity. The constants are there so that spacing has one place to change instead of hundreds. It is about making screens feel consistent while keeping product work fast.