EmpireUI
Get Pro
← Blog8 min read#spacing#design system#scale

Spacing System Design: Base Unit, Scale, Consistent Component Gaps

A no-fluff guide to building a spacing system that scales — pick your base unit, define your scale, and stop arguing about 14px vs 16px forever.

Grid layout ruler measuring consistent spacing in a UI design system

Why Spacing Systems Break Down

You've been there. The design file says margin-top: 14px on one card and margin-top: 18px on the card below it. A senior dev changed one of them to 1rem in 2023, someone else overrode it with py-3 in Tailwind, and now you've got three competing spacing conventions living in the same codebase. None of them are wrong, exactly. They're just inconsistent, and inconsistency compounds.

Spacing feels like a small problem until it isn't. Honestly, half the design-implementation gaps I've seen aren't about color or typography — they're about someone eyeballing 16px vs 20px on a gap and calling it close enough. Multiply that across 60 components and you get an interface that *looks* off even when you can't immediately say why.

The fix isn't discipline. It's a system. A spacing system defines a finite set of allowed values derived from a single base unit, so every gap, every padding, every margin in your UI is a deliberate multiple of something consistent. It's not magic — it's just constraining the problem space so that 'close enough' has a concrete answer.

In practice, teams that ship spacing tokens early spend dramatically less time in design review arguing about pixel values. The conversation shifts from 'should this be 12 or 14' to 'does this need a size-1 or size-2 gap' — which is a much faster conversation to have.

Picking Your Base Unit

Almost every popular design system — Material Design 3, Atlassian's ADG, Radix Themes — anchors on 4px. It's not arbitrary. 4px divides evenly into 8, 12, 16, 24, 32, 48, 64, all the values you'd reach for naturally. It's also half of the 8px grid that's been standard in UI work since at least 2016, which means your 4px base composes cleanly into an 8px rhythm.

That said, some teams prefer 8px as the base and just skip the half-steps. This works fine for dense UI like data tables or admin dashboards where the smallest spacing you'd ever use is around 8px. If you're building a marketing site or a consumer app with breathing room, 4px gives you more granularity without getting absurd.

Quick aside: don't use rem as your base unit if you can avoid it. I know, I know — accessibility arguments. But 1rem = 16px only if the user hasn't changed their browser default, and building a scale on a value that shifts underneath you makes your token math confusing. Set your tokens in px, then reference them as rem in the output if you need to: --space-4: 0.25rem is perfectly valid. The source of truth is the 4px base.

One more thing — if you're working inside a component library like Empire UI, the base unit is baked in. You don't have to invent it. The components already respect an 8px grid rhythm, so your job becomes choosing which existing step maps to which semantic role in your product.

Defining the Scale

A spacing scale is just a list of named stops. The simplest approach: multiply your base unit by consecutive integers. With a 4px base you get 4, 8, 12, 16, 20, 24, 28, 32… and so on. This is a linear scale and it's totally workable for most products.

The problem with purely linear scales is that the difference between step 1 (4px) and step 2 (8px) — a jump of 4px — feels equivalent to the difference between step 8 (32px) and step 9 (36px), which visually it isn't. At large values the eye can't perceive 4px differences reliably. A geometric scale addresses this: each step is a fixed multiplier of the previous one. ``js // 4px base, 1.5× multiplier — rounded to nearest 4 const base = 4; const ratio = 1.5; const scale = Array.from({ length: 10 }, (_, i) => Math.round((base * Math.pow(ratio, i)) / 4) * 4 ); // [4, 4, 8, 12, 20, 32, 48, 76, 116, 176] ``

Worth noting: geometric scales generate ugly intermediate values fast. 76px is not a number your devs will remember. Most teams end up on a hybrid — linear steps at the small end (4, 8, 12, 16, 24) and then jumpy steps at the large end (32, 48, 64, 96, 128). This is exactly what Tailwind's spacing scale does, and it's where most design systems land after a few iterations.

Name your steps semantically, not numerically, once you've picked values. space-xs, space-sm, space-md, space-lg, space-xl, space-2xl is a scale your whole team can remember. Compare that to space-3 vs space-4 where you always need to look up which is which. Here's a clean token definition that works as CSS custom properties: ``css :root { --space-1: 4px; /* xs */ --space-2: 8px; /* sm */ --space-3: 12px; /* sm+ */ --space-4: 16px; /* md */ --space-5: 24px; /* md+ */ --space-6: 32px; /* lg */ --space-7: 48px; /* xl */ --space-8: 64px; /* 2xl */ --space-9: 96px; /* 3xl */ } ``

Look, the exact values matter less than the commitment. Once you publish a scale you stop making up numbers. Every spacing decision maps to a token. If a design calls for something that isn't on the scale, that's a conversation — not a dev just throwing in margin-top: 22px and moving on.

Mapping Scale Steps to Semantic Roles

Raw scale steps are just numbers. What gives them power is mapping them to *roles* — consistent answers to consistent questions. What's the padding inside a button? What's the gap between a label and its input? What's the vertical rhythm between page sections? Those answers should be written down, not decided ad-hoc in every component.

Here's a mapping that holds up across most product work: ``ts // spacing-roles.ts export const spacing = { // Within a component inset: { xs: 'var(--space-2)', // icon-only button, tight chips sm: 'var(--space-3)', // compact inputs md: 'var(--space-4)', // default button/input padding lg: 'var(--space-5)', // cards, modals }, // Between related elements (inline/stacked) gap: { xs: 'var(--space-1)', // icon + label sm: 'var(--space-2)', // list items, breadcrumbs md: 'var(--space-4)', // form fields lg: 'var(--space-6)', // sections within a card }, // Between unrelated blocks section: { sm: 'var(--space-6)', // related sections md: 'var(--space-7)', // page sections lg: 'var(--space-9)', // hero-to-content breaks }, } as const; ``

The three categories — inset (internal padding), gap (between siblings), section (between content blocks) — cover about 90% of spacing decisions in a typical UI. You might need a fourth for layout gutters or responsive container padding, but start here.

Applying this to an actual React component looks like this: ``tsx import { spacing } from '@/tokens/spacing-roles'; function FormField({ label, children }: { label: string; children: React.ReactNode }) { return ( <div style={{ display: 'flex', flexDirection: 'column', gap: spacing.gap.md }}> <label style={{ fontSize: '14px', fontWeight: 500 }}>{label}</label> {children} </div> ); } ``

Notice there's no magic number anywhere in that component. spacing.gap.md is 16px today — and if your design team decides md gaps should be 20px next quarter, you change one token and every FormField in the product updates. That's the whole point.

Consistent Component Gaps in Practice

The hardest part of a spacing system isn't defining it — it's enforcing it when a design comes in with a value that's slightly off the scale. The most pragmatic policy I've seen: always round to the nearest defined step. If the design says 22px, that's either 20 or 24 depending on context. Make the call, document it in a comment, move on. Don't create a --space-22 token.

Component libraries make this easier because the spacing decisions are already made for you. When you browse components in Empire UI, the internal gaps — between a card's title and body, between a form label and its helper text, between navigation items — already follow a consistent rhythm. You inherit that consistency without having to audit every component yourself.

That said, you still need to control *outer* spacing — the margins and gaps between Empire UI components and the rest of your layout. This is where having your own section and gap tokens pays off. Don't let the layout layer inherit spacing from the component layer; keep them separate. Components own their insets; your layout owns everything else.

One pattern worth adopting: a Stack component that takes a gap prop constrained to your token names. It's eight lines of code and it prevents every developer from independently deciding how much space goes between a page title and its subheading. ``tsx const GAP_MAP = { xs: 'var(--space-1)', sm: 'var(--space-2)', md: 'var(--space-4)', lg: 'var(--space-6)', xl: 'var(--space-7)', } as const; type GapSize = keyof typeof GAP_MAP; function Stack({ gap = 'md', children, }: { gap?: GapSize; children: React.ReactNode; }) { return ( <div style={{ display: 'flex', flexDirection: 'column', gap: GAP_MAP[gap] }}> {children} </div> ); } ``

Spacing Tokens in a Design-to-Code Workflow

Tokens only work if both designers and developers are reading from the same source. In 2026 that usually means a single token file in your repo that gets exported to Figma via a token sync plugin, or a shared package that both the design tool and the codebase import. Whatever your setup, the token names need to match exactly — if the design file says gap/md and the code says spacing.gap.medium, you'll drift.

The most practical starting point if you don't have a token pipeline yet: define your spacing scale as a JSON file and run it through Style Dictionary to generate outputs for CSS custom properties, Tailwind's theme.extend.spacing, and whatever else you need. ``json { "space": { "1": { "value": "4px" }, "2": { "value": "8px" }, "3": { "value": "12px" }, "4": { "value": "16px" }, "5": { "value": "24px" }, "6": { "value": "32px" }, "7": { "value": "48px" }, "8": { "value": "64px" }, "9": { "value": "96px" } } } ``

Style Dictionary version 3+ has a format API that outputs Tailwind-compatible objects directly. One source file, multiple outputs — your CSS custom properties, your JS token object, and your Tailwind config all stay in sync automatically. Set it up once, then your biggest problem is convincing designers to actually use the Figma token plugin.

Worth noting: if you're already using Empire UI's component set, the Tailwind config that ships with it already extends the default spacing scale in a way that matches the component internals. You don't need to redo that work — just map your semantic roles on top of what's already there.

Common Mistakes and How to Sidestep Them

Too many steps. I've seen design systems with 20+ spacing tokens and teams that still use margin: 14px in their CSS because none of the tokens felt right. More steps doesn't mean more coverage — it means more decision fatigue. Eight to ten stops handles nearly every scenario. Add steps only when you can name a concrete use case the existing scale can't address.

Mixing spacing concerns with layout concerns. Spacing tokens govern the space *between and inside* UI elements. They're not the right tool for defining your page grid column count, your breakpoint widths, or your container max-widths. Those belong to a separate layout system. When you conflate them you end up using --space-9 (96px) as a page margin, which makes zero sense once you try to use 96px as a section gap.

No enforcement. A token file with no linting is a suggestion. Tools like eslint-plugin-css-modules or a custom ESLint rule that flags raw pixel values in style props help keep the codebase honest. Even a simple code review checklist item — 'does this introduce a new spacing value not on the scale?' — is better than nothing.

In practice, the teams that keep their spacing systems clean long-term are the ones who treat violations as a conversation starter, not a merge blocker. Someone puts in padding: 22px? Ask why. Maybe there's a missing token. Maybe it's a one-off exception that belongs in a comment. Either way you learn something about where the scale falls short — and that's how you improve it over time.

If you're building out UI components and want to see how a well-structured spacing rhythm looks in action, check out Empire UI's glassmorphism components and notice how the padding and gap values follow a consistent pattern across cards, modals, and inputs. It's a small thing but it's exactly what a spacing system is supposed to produce.

FAQ

Should I use px or rem for my spacing tokens?

Define your source tokens in px — it keeps the math readable and the intent obvious. Output them as rem for the browser (divide by 16) if you need to respect user font-size preferences at the layout level. Most spacing doesn't need that, though; rem matters most for font sizes, not gaps.

How many spacing steps are enough?

Eight to ten covers almost every real scenario. If you find yourself needing a value between two existing steps more than twice, add a step. If you add a token and nobody uses it within a month, remove it. Keep the scale as small as it can be while still covering your product.

How do I get designers to follow the spacing system?

Put the tokens directly in Figma as a shared library and make them the default in component specs. When the scale is the path of least resistance, most designers follow it without being asked. The system fails when tokens are documented in a wiki nobody reads.

What's the difference between gap tokens and inset tokens?

Inset tokens define padding inside a component — the space between a button's edge and its label. Gap tokens define space between siblings — the space between a label and an input, or between list items. Keeping them separate means you can tune them independently without breaking the other.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Typography Design System: Scale, Responsive Size, Line Height TokensMulti-Brand Design System: One Component Library, N ThemesTailwind Spacing System: Consistent Gaps, Padding and the 4px GridMonorepo Design System: Shared Packages, Storybook, Publishing