Taming Spacing with CSS Custom Properties
The problem with magic numbers
Every project accumulates them. You write padding: 16px in one component, gap: 12px in another, margin-top: 32px somewhere else. Each value made sense at the time: you eyeballed the spacing, it looked right, you moved on. Six months later you've got fifteen distinct spacing values scattered across your stylesheets and no idea which ones were intentional design decisions and which were just vibes.
I ran into exactly this on vohzd.com. After auditing the CSS I found spacing values of 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 32, 40, 60, and 64 pixels. Some of those are deliberate (8px for tight gaps, 16px for standard spacing, 64px for section breaks) but others had drifted from the intended scale without anyone noticing.
Defining a scale
The fix is a spacing scale in :root. Nothing revolutionary, just a set of named steps that cover the range you actually need:
:root {
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 64px;
}Six values. That's it. The scale roughly doubles at each step (with --space-lg as the exception, since 24px sits between 16 and 32 and is useful for card padding and similar medium containers).
The naming is deliberately boring. I've seen scales that use t-shirt sizes, fibonacci numbers, or abstract names like --spacing-comfortable. They're all fine until you need to explain them to someone else. sm is small, md is medium, lg is large. Nobody needs a lookup table.
Applying it
The mechanical part is straightforward: find every hardcoded pixel value and replace it with the nearest variable:
/* before */
.datatable-row {
padding: 24px;
}
.datatable-row-header {
gap: 8px;
}
.datatable-link-hint {
margin-top: 16px;
}
/* after */
.datatable-row {
padding: var(--space-lg);
}
.datatable-row-header {
gap: var(--space-sm);
}
.datatable-link-hint {
margin-top: var(--space-md);
}The real value shows up when you find inconsistencies. I had two buttons in my DataTable modal, one with padding: 8px 16px and the other with padding: 8px 18px. That 2px difference was invisible to the eye but visible in the code, and it's exactly the kind of thing that compounds over time. With the scale, both become var(--space-sm) var(--space-md) and the question disappears.
Fluid gutters
The spacing scale also gave me a natural place to handle the layout gutters. My site has a fixed 280px sidebar on desktop with gutters either side of the main content. Previously I had a hard breakpoint at 1600px that dropped the gutters from 60px to 40px:
/* before: abrupt jump */
.main-view {
margin-left: calc(280px + 60px);
margin-right: 60px;
}
@media (max-width: 1600px) {
.main-view {
margin-left: calc(280px + 40px);
margin-right: 40px;
}
}With a clamp() variable, the transition is smooth:
:root {
--space-gutter: clamp(40px, 4vw, 60px);
}
.main-view {
margin-left: calc(280px + var(--space-gutter));
margin-right: var(--space-gutter);
}One line, no media query, no jarring layout shift. The gutter scales fluidly between 40px and 60px based on viewport width.
Fixing the mobile overlap
While I was in the responsive styles I noticed a layering problem. At 1000px, the main content got margin: 0 2%. Then at 700px, it also got padding-left: 32px; padding-right: 32px. Those stack, so on a 700px screen you'd end up with about 46px of dead space on each side (14px margin + 32px padding). That's a lot of wasted real estate on a phone.
The fix was to collapse both into a single fluid padding at the 1300px breakpoint (where the sidebar disappears):
@media (max-width: 1300px) {
.main-view {
padding: 140px clamp(var(--space-md), 4vw, var(--space-xl)) 0;
}
}One rule, one clamp(), fluid from 16px to 32px. The 1000px margin override and the 700px padding override both become unnecessary.
What I didn't do
I didn't convert every single pixel value. Things like font-size, border-radius: 3px, width: 280px (the sidebar), and animation offsets are not spacing, they're sizing or decoration. Forcing those into the spacing scale would make the code harder to read for no benefit.
I also didn't introduce a build step or preprocessor. CSS custom properties work natively, they cascade, they can be overridden per-component if needed, and they're inspectable in DevTools. That's enough.
The result
The codebase went from fifteen-odd spacing values to six named ones. The visual output is identical, not a single pixel moved on screen (except the gutter smoothing and the mobile fix, which were intentional improvements). But the next time I add a component, I don't need to guess whether the right gap is 12px or 16px. It's var(--space-sm) or var(--space-md). The scale decides, not my mood.
