Why custom properties over preprocessor variables
Sass and Less variables are compile-time constants — they resolve to static values in the output CSS. CSS custom properties (often called CSS variables) exist at runtime in the browser, which means they can be changed with JavaScript, overridden in media queries, and scoped to specific elements. For a design system, this flexibility is significant.
The core structure
Define everything on :root so values are globally available. Group by concern:
:root {
/* Colour */
--colour-bg: #0b0a0a;
--colour-surface: rgba(255, 255, 255, 0.04);
--colour-border: rgba(255, 255, 255, 0.08);
--colour-accent: #ff641f;
--colour-text: #cccccc;
--colour-text-strong: #f0f0f0;
--colour-muted: #787878;
/* Spacing — use a scale */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Typography */
--font-body: "Inter", system-ui, sans-serif;
--font-heading: "Oswald", "Montserrat", sans-serif;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
/* Radius */
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 22px;
--radius-xl: 30px;
--radius-full: 999px;
/* Shadows */
--shadow-sm: 0 4px 16px rgba(0, 0, 0, 0.2);
--shadow-md: 0 12px 40px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 24px 80px rgba(0, 0, 0, 0.4);
/* Transitions */
--transition-fast: 0.15s ease;
--transition-base: 0.3s ease;
--transition-slow: 0.5s ease;
}
Dark mode without duplication
Because custom properties are runtime values, dark mode becomes a matter of redefining a handful of variables on a class or media query — not duplicating your entire stylesheet:
@media (prefers-color-scheme: light) {
:root {
--colour-bg: #ffffff;
--colour-text: #1a1a1a;
--colour-text-strong: #0a0a0a;
--colour-muted: #666666;
--colour-border: rgba(0, 0, 0, 0.1);
--colour-surface: rgba(0, 0, 0, 0.03);
}
}
/* Or with a class for manual toggle */
[data-theme="light"] {
--colour-bg: #ffffff;
/* ... */
}
Component-level scoping
Custom properties inherit through the DOM. This means you can set component-specific values on a parent element and have them apply to all children — useful for things like card variants or section theming:
/* Default card */
.card {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
}
/* Featured card — overrides locally */
.card--featured {
--colour-surface: rgba(255, 100, 31, 0.08);
--colour-border: rgba(255, 100, 31, 0.24);
}
Animating with custom properties
Unlike Sass variables, CSS custom properties can be animated using the @property rule, which lets you define the type and initial value:
@property --glow-opacity {
syntax: "<number>";
initial-value: 0;
inherits: false;
}
.card {
--glow-opacity: 0;
box-shadow: 0 0 30px rgba(255, 100, 31, var(--glow-opacity));
transition: --glow-opacity var(--transition-base);
}
.card:hover {
--glow-opacity: 0.4;
}
Practical tips
- Use semantic names for colours (
--colour-accent) not value names (--colour-orange) — it makes refactoring far less painful. - Keep the scale consistent and limited. More than 20 spacing values and the system stops being useful.
- Document the variables. A short comment above each group explaining what it is for saves significant time when someone else (or future-you) touches it six months later.
- Avoid putting complex calculations in custom properties if they will be re-evaluated frequently — it can impact paint performance.