CSS Variable Generator: From Hardcoded Values to Design Systems
CSS Variable Generator: From Hardcoded Values to Design Systems#
Last year I inherited a legacy project. Opened the CSS file and found #3b82f6, #8b5cf6 scattered everywhere. Changing the primary color meant global search-and-replace, praying nothing was missed. After refactoring with CSS variables, changing colors finally became stress-free.
The Nature of CSS Variables#
CSS Custom Properties (commonly called CSS variables) have simple syntax:
:root {
--color-primary: #3b82f6;
}
.button {
background: var(--color-primary);
}
Unlike Sass/Less variables, CSS variables are runtime. This means:
// JavaScript can modify them dynamically
document.documentElement.style.setProperty('--color-primary', '#ef4444')
// Takes effect immediately - all usages update
This makes dark mode and theme switching remarkably simple.
Scope and Inheritance#
CSS variables follow CSS inheritance rules, definable at any level:
:root {
--spacing: 1rem; /* Global */
}
.card {
--spacing: 0.5rem; /* Override within card */
padding: var(--spacing);
}
.card .item {
margin: var(--spacing); /* Inherits card's 0.5rem */
}
This enables “local themes”:
:root {
--text-color: #1f2937;
}
.dark-theme {
--text-color: #f9fafb;
}
/* Force dark mode in a specific area */
.sidebar {
--text-color: #f9fafb;
}
Naming Convention Pitfalls#
Early on, I named variables carelessly - --blue, --mainColor. Maintenance became painful. When --blue changed to red, the name contradicted the value.
Current conventions I follow:
:root {
/* Semantic naming: purpose, not appearance */
--color-primary: #3b82f6;
--color-danger: #ef4444;
--color-text: #1f2937;
--color-text-muted: #6b7280;
/* Layered naming: module-property-variant */
--button-bg: var(--color-primary);
--button-bg-hover: #2563eb;
--button-radius: 0.5rem;
/* Spacing system: multiplicative relationships */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 2rem;
}
Names serve as documentation. Seeing --color-danger immediately conveys meaning without checking the value.
Default Values and Fallbacks#
var() supports default values:
.button {
/* If --btn-bg is undefined, use #3b82f6 */
background: var(--btn-bg, #3b82f6);
}
More practical for progressive enhancement:
.card {
/* Older browsers: fallback */
background: white;
/* Modern browsers: variable */
background: var(--card-bg, white);
}
Chained fallbacks are also possible:
.element {
color: var(--theme-color, var(--brand-color, #333));
}
Dark Mode in Practice#
CSS variables make dark mode elegant:
:root {
--bg: #ffffff;
--text: #1f2937;
--border: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1f2937;
--text: #f9fafb;
--border: #374151;
}
}
/* Or JS-controlled */
[data-theme="dark"] {
--bg: #1f2937;
--text: #f9fafb;
}
Component code remains unchanged:
.card {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
}
Performance Considerations#
CSS variables have overhead, but it’s usually negligible. Key considerations:
1. Avoid excessive use on critical paths#
/* Not recommended: variables for every property */
.element {
margin: var(--m);
padding: var(--p);
border: var(--b);
background: var(--bg);
color: var(--c);
}
/* Recommended: only where dynamic values are needed */
.element {
margin: 1rem;
padding: 0.5rem;
border: 1px solid var(--border-color);
background: var(--bg-color);
}
2. Compute at definition time#
/* Not recommended: calculation on every use */
:root {
--base: 1rem;
}
.element {
padding: calc(var(--base) * 2);
}
/* Recommended: pre-computed */
:root {
--base: 1rem;
--double: 2rem;
}
.element {
padding: var(--double);
}
Implementation Approach#
Based on these practices, I built a CSS Variable Generator:
Core functionality:
interface CssVariable {
name: string // e.g., --color-primary
value: string // e.g., #3b82f6
category: string // e.g., colors, spacing
}
// Generate grouped output
function generateCss(variables: CssVariable[], selector: string): string {
const grouped: Record<string, CssVariable[]> = {}
variables.forEach(v => {
if (!grouped[v.category]) grouped[v.category] = []
grouped[v.category].push(v)
})
let css = `${selector} {\n`
Object.entries(grouped).forEach(([cat, vars]) => {
css += `\n /* ${cat} */\n`
vars.forEach(v => {
css += ` ${v.name}: ${v.value};\n`
})
})
css += '}\n'
return css
}
Key features:
- Color detection: Recognizes
#hex,rgb(),hsl()formats, shows color preview - Category management: Groups output by colors, spacing, typography, etc.
- Custom selectors: Supports
:root,.dark,[data-theme="dark"], etc.
Migrating from Preprocessors#
When migrating from Sass/Less, a hybrid approach works well:
// Keep Sass variables for calculations
$base-spacing: 8px;
// Output CSS variables for runtime
:root {
--spacing: #{$base-spacing};
--spacing-lg: #{$base-spacing * 2};
}
This preserves Sass’s calculation power while gaining CSS variables’ dynamic capabilities.
CSS variables aren’t a silver bullet, but for design systems, theme switching, and component libraries, they offer more flexibility than hardcoded values or preprocessor variables. The key is establishing naming conventions - don’t let variables become another maintenance burden.
Related tools: CSS Gradient Generator | Color Contrast Checker