CSS Variables Are All You Need for Theming
You don't need a CSS-in-JS library to do dark mode. You don't need Tailwind. You don't need styled-components. Plain CSS variables work great, they're supported everywhere, and they're simpler than any JavaScript solution.
This portfolio uses CSS variables for all its colors. Here's how it works.
The basic idea
CSS variables (also called custom properties) let you define values once and use them everywhere. They cascade like regular CSS properties, which means you can override them at any level.
:root {
--primary-color: #22d3ee;
}
.button {
background: var(--primary-color);
}
Change --primary-color once, and every element using it updates. No build step, no JavaScript, no re-renders.
Setting up a complete color system
For theming, define all your colors as variables:
:root {
/* Background colors */
--bg-primary: #0a0a0f;
--bg-secondary: #1a1a2e;
--bg-elevated: #252542;
/* Text colors */
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
/* Accent colors */
--accent: #22d3ee;
--accent-hover: #06b6d4;
--accent-muted: rgba(34, 211, 238, 0.2);
/* Semantic colors */
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
/* Borders and shadows */
--border: rgba(255, 255, 255, 0.1);
--shadow: rgba(0, 0, 0, 0.3);
}
Now use these throughout your CSS:
body {
background: var(--bg-primary);
color: var(--text-primary);
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
box-shadow: 0 4px 6px var(--shadow);
}
a {
color: var(--accent);
}
a:hover {
color: var(--accent-hover);
}
Adding a light theme
For dark/light mode, override the variables based on a data attribute:
:root[data-theme='light'] {
--bg-primary: #f8fafc;
--bg-secondary: #e2e8f0;
--bg-elevated: #ffffff;
--text-primary: #1e293b;
--text-secondary: #475569;
--text-muted: #64748b;
--accent: #0891b2;
--accent-hover: #0e7490;
--accent-muted: rgba(8, 145, 178, 0.2);
--border: rgba(0, 0, 0, 0.1);
--shadow: rgba(0, 0, 0, 0.1);
}
The magic: you only override colors, not the actual CSS rules. The same .card class works in both themes because it uses the variables.
Toggling themes with JavaScript
One line of JavaScript toggles the entire theme:
function toggleTheme() {
const html = document.documentElement;
html.dataset.theme = html.dataset.theme === 'light' ? 'dark' : 'light';
localStorage.setItem('theme', html.dataset.theme);
}
That's it. Change one data attribute, every color in your entire site updates instantly. No re-rendering, no flash of incorrect colors.
Respecting system preference
Users can set their preferred color scheme in their OS. You should respect that:
function initTheme() {
// Check for saved preference first
const saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.dataset.theme = saved;
return;
}
// Otherwise, use system preference
const prefersDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
document.documentElement.dataset.theme = prefersDark ? 'dark' : 'light';
}
// Run on page load
initTheme();
You can also listen for system changes:
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
}
});
Preventing flash of wrong theme
If your JavaScript runs after the page paints, you might see a flash of the wrong theme. Fix this by putting the theme script in the <head>:
<head>
<script>
(function() {
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
})();
</script>
</head>
This runs before the page renders, so there's no flash.
Beyond colors: other uses for variables
CSS variables aren't just for colors. I use them for:
Spacing:
:root {
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 2rem;
--space-xl: 4rem;
}
Typography:
:root {
--font-sans: 'Inter', sans-serif;
--font-mono: 'Fira Code', monospace;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.25rem;
}
Animations:
:root {
--transition-fast: 150ms ease;
--transition-normal: 300ms ease;
}
Fallback values
You can provide fallbacks in case a variable isn't defined:
.button {
background: var(--button-bg, var(--accent));
}
If --button-bg isn't set, it falls back to --accent.
Component-scoped variables
Variables can be scoped to components:
.card {
--card-padding: var(--space-md);
padding: var(--card-padding);
}
.card--large {
--card-padding: var(--space-lg);
}
This is a clean pattern for component variants.
Why not CSS-in-JS?
CSS-in-JS libraries (styled-components, Emotion, etc.) have their place, especially in component-heavy React apps. But they add complexity:
- JavaScript bundle size increases
- Runtime overhead for style computation
- Build step complexity
- Server-side rendering considerations
For theming specifically, CSS variables do the same job with zero JavaScript overhead and native browser support.
Advanced patterns and gotchas
Once you get comfortable with the basics, there are some advanced patterns worth knowing.
Derived colors
You can create color variations using the same base color by manipulating opacity or combining variables:
:root {
--primary-rgb: 34, 211, 238;
--primary: rgb(var(--primary-rgb));
--primary-10: rgba(var(--primary-rgb), 0.1);
--primary-20: rgba(var(--primary-rgb), 0.2);
--primary-50: rgba(var(--primary-rgb), 0.5);
}
.badge {
background: var(--primary-10);
color: var(--primary);
border: 1px solid var(--primary-20);
}
.overlay {
background: var(--primary-50);
}
This gives you tint variations without defining a dozen separate colors. When you change the base, all the tints update automatically.
Calc() with variables
CSS variables work great with calc() for creating dynamic values:
:root {
--base-size: 1rem;
--header-height: calc(var(--base-size) * 4);
--sidebar-width: calc(var(--base-size) * 20);
--content-padding: calc(var(--base-size) * 2);
}
@media (max-width: 768px) {
:root {
--base-size: 0.875rem; /* Everything scales down */
}
}
Change one variable, and all the calculated values adjust proportionally.
Conditional variables with media queries
You can change variables based on screen size, not just theme:
:root {
--section-gap: 1rem;
--max-width: 100%;
}
@media (min-width: 768px) {
:root {
--section-gap: 2rem;
--max-width: 768px;
}
}
@media (min-width: 1024px) {
:root {
--section-gap: 3rem;
--max-width: 1024px;
}
}
.container {
max-width: var(--max-width);
padding: var(--section-gap);
}
This keeps your component CSS clean while letting you adjust values at different breakpoints.
Real-world gotchas
Here are some issues I've run into and how to avoid them.
Variable inheritance can surprise you
CSS variables cascade, which is usually what you want, but it can bite you:
:root {
--spacing: 1rem;
}
.card {
--spacing: 2rem; /* Override for card */
padding: var(--spacing);
}
.card .button {
margin: var(--spacing); /* Gets 2rem, not 1rem! */
}
The button inherits the card's override. If that's not what you want, use a different variable name or be explicit:
.card {
--card-spacing: 2rem;
padding: var(--card-spacing);
}
.card .button {
margin: var(--spacing); /* Gets 1rem from :root */
}
Variables in SVGs need inline styles
CSS variables don't work in external SVG files. They do work in inline SVGs:
<!-- This works -->
<svg>
<circle fill="var(--accent)" />
</svg>
<!-- This doesn't -->
<img src="icon.svg" /> <!-- Can't use variables in external file -->
If you need dynamic SVG colors, either inline the SVG or use CSS filters as a workaround.
SASS/SCSS variable conflicts
If you're using SASS, be careful not to confuse SASS variables ($var) with CSS variables (--var):
$spacing: 1rem; // SASS variable, compiled at build time
--spacing: 1rem; // CSS variable, available at runtime
.element {
padding: $spacing; // Becomes: padding: 1rem;
padding: var(--spacing); // Stays: padding: var(--spacing);
}
CSS variables can be changed at runtime; SASS variables cannot. Use CSS variables for anything that needs to be dynamic.
Performance considerations
CSS variables are fast, but there are a few things to know about performance.
They're faster than you think
Changing a CSS variable is incredibly fast. The browser doesn't have to recalculate the entire CSSOM or trigger a full style recalculation. It just updates the values that reference that variable.
In practice, toggling a theme with CSS variables is faster than:
- Swapping entire stylesheets
- Using JavaScript to change inline styles
- Re-rendering a React component tree
Avoid changing too many at once
That said, if you change 100 variables at once, you might see a brief hiccup on lower-end devices. In practice, theme switching (10-20 color variables) is fine.
If you're doing something crazy like animating a variable 60 times per second, test on mobile devices.
Use transforms for animations, not variables
Don't do this:
@keyframes slide {
from { --x: 0; }
to { --x: 100px; }
}
.element {
transform: translateX(var(--x));
animation: slide 300ms;
}
Instead, animate the transform directly:
@keyframes slide {
from { transform: translateX(0); }
to { transform: translateX(100px); }
}
.element {
animation: slide 300ms;
}
Transforms are hardware-accelerated; changing a variable in an animation isn't.
Building a complete theme system
Here's a more complete example of how I structure themes in real projects.
Color scales
Instead of arbitrary colors, define semantic scales:
:root {
/* Neutrals (darkest to lightest) */
--neutral-900: #0a0a0f;
--neutral-800: #1a1a2e;
--neutral-700: #252542;
--neutral-600: #475569;
--neutral-500: #64748b;
--neutral-400: #94a3b8;
--neutral-300: #cbd5e1;
--neutral-200: #e2e8f0;
--neutral-100: #f1f5f9;
--neutral-50: #f8fafc;
/* Semantic mapping */
--bg-primary: var(--neutral-900);
--bg-secondary: var(--neutral-800);
--text-primary: var(--neutral-100);
--text-secondary: var(--neutral-400);
}
:root[data-theme='light'] {
/* Just flip the mapping */
--bg-primary: var(--neutral-50);
--bg-secondary: var(--neutral-100);
--text-primary: var(--neutral-900);
--text-secondary: var(--neutral-600);
}
This gives you fine-grained control while keeping the semantic names clean.
Elevation system
For shadows and depth:
:root {
--elevation-0: none;
--elevation-1: 0 1px 2px var(--shadow-color);
--elevation-2: 0 2px 4px var(--shadow-color);
--elevation-3: 0 4px 8px var(--shadow-color);
--elevation-4: 0 8px 16px var(--shadow-color);
--shadow-color: rgba(0, 0, 0, 0.3);
}
:root[data-theme='light'] {
--shadow-color: rgba(0, 0, 0, 0.1);
}
.card {
box-shadow: var(--elevation-2);
}
.modal {
box-shadow: var(--elevation-4);
}
Motion system
For consistent animations:
:root {
--duration-instant: 50ms;
--duration-fast: 150ms;
--duration-normal: 300ms;
--duration-slow: 500ms;
--easing-standard: cubic-bezier(0.4, 0.0, 0.2, 1);
--easing-decelerate: cubic-bezier(0.0, 0.0, 0.2, 1);
--easing-accelerate: cubic-bezier(0.4, 0.0, 1, 1);
--transition-fade: opacity var(--duration-normal) var(--easing-standard);
--transition-slide: transform var(--duration-normal) var(--easing-standard);
}
.modal {
transition: var(--transition-fade);
}
.drawer {
transition: var(--transition-slide);
}
Users who prefer reduced motion can override this:
@media (prefers-reduced-motion: reduce) {
:root {
--duration-fast: 1ms;
--duration-normal: 1ms;
--duration-slow: 1ms;
}
}
Multi-theme support (more than two themes)
What if you want more than just light and dark?
/* Default dark */
:root {
--bg-primary: #0a0a0f;
--accent: #22d3ee;
}
/* Light theme */
:root[data-theme='light'] {
--bg-primary: #f8fafc;
--accent: #0891b2;
}
/* High contrast */
:root[data-theme='high-contrast'] {
--bg-primary: #000000;
--text-primary: #ffffff;
--accent: #ffff00;
--border: #ffffff;
}
/* Solarized */
:root[data-theme='solarized'] {
--bg-primary: #002b36;
--text-primary: #839496;
--accent: #268bd2;
}
Your JavaScript just sets the data attribute:
function setTheme(themeName) {
document.documentElement.dataset.theme = themeName;
localStorage.setItem('theme', themeName);
}
// setTheme('solarized')
// setTheme('high-contrast')
Browser support
CSS variables work in all modern browsers: Chrome, Firefox, Safari, Edge. IE11 doesn't support them, but IE11 is officially dead as of June 2022.
If you need to support ancient browsers (you probably don't), you can provide fallbacks:
.button {
background: #22d3ee; /* Fallback */
background: var(--accent); /* Overrides if supported */
}
But honestly, if you're building something new in 2025, just use CSS variables. The browser support is there.
DevTools support
Modern browser DevTools have great support for CSS variables. In Chrome/Firefox/Edge:
- Inspect an element
- Look at the Computed tab
- You'll see both the variable name and the resolved value
You can even edit variable values live in DevTools to test different colors. Change --accent once, and watch every element using it update in real-time.
CSS variables vs. JavaScript solutions
Let's talk about why CSS variables beat JavaScript for theming.
CSS-in-JS libraries (styled-components, Emotion):
- Add 10-30KB to your JavaScript bundle
- Require React context for theme access
- Create style tags at runtime
- Need SSR consideration for hydration
- Can't be changed without re-rendering components
Tailwind with theme switching:
- Requires dark: prefix on every utility
- Doubles your HTML size for dual themes
- Can't support more than two themes easily
- Still needs JavaScript to toggle classes
Plain CSS with class toggling:
- Requires maintaining duplicate CSS rulesets
- Larger CSS file size
- Harder to maintain consistency
CSS variables:
- Zero JavaScript overhead
- Works without any framework
- Instant theme switching
- Easy to extend to 3+ themes
- Can be changed from DevTools for testing
- No SSR considerations
For theming specifically, CSS variables are the right tool.
Practical patterns from real projects
Let me share some patterns I've used in production that make theming easier.
Theme-aware component variants
Sometimes you want a component to look different in each theme, but not just by color:
.button {
background: var(--accent);
color: var(--text-primary);
border: 1px solid transparent;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
}
:root[data-theme='light'] .button {
/* Light theme gets a subtle border */
border-color: var(--neutral-200);
box-shadow: var(--elevation-1);
}
:root[data-theme='dark'] .button {
/* Dark theme gets a glow */
box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.3);
}
This lets you maintain a single .button class while tweaking appearance per theme.
Context-aware colors
Sometimes you need colors that adapt based on their background:
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #1a1a2e;
/* Default text color for primary bg */
--text-on-primary: #e2e8f0;
/* Different text color for secondary bg */
--text-on-secondary: #cbd5e1;
/* Text color for colored backgrounds */
--text-on-accent: #0a0a0f;
}
.card {
background: var(--bg-secondary);
color: var(--text-on-secondary);
}
.badge {
background: var(--accent);
color: var(--text-on-accent);
}
This ensures text is always readable regardless of the background.
Scoped theme overrides
You can override the theme for a specific section:
.marketing-section {
/* Force light theme in this section */
--bg-primary: #ffffff;
--text-primary: #0a0a0f;
--accent: #0891b2;
}
/* All children inherit the overridden values */
.marketing-section .card {
background: var(--bg-primary); /* Gets white, not the global dark bg */
color: var(--text-primary); /* Gets dark text */
}
Useful for landing pages where you want a specific look regardless of user preference.
Dynamic color generation
If you store your base colors as RGB values, you can generate variations on the fly:
:root {
--primary-h: 186;
--primary-s: 83%;
--primary-l: 53%;
--primary: hsl(var(--primary-h), var(--primary-s), var(--primary-l));
--primary-light: hsl(var(--primary-h), var(--primary-s), 70%);
--primary-dark: hsl(var(--primary-h), var(--primary-s), 35%);
--primary-pale: hsl(var(--primary-h), 50%, 90%);
}
Change one hue value, get a whole new color scheme. Great for white-label apps.
Organizing your variables
As your theme grows, organization matters. Here's how I structure variables in larger projects:
File structure
styles/
variables/
_colors.css /* All color definitions */
_spacing.css /* Spacing scale */
_typography.css /* Font and text sizes */
_motion.css /* Animation durations and easings */
_elevation.css /* Shadows and depths */
themes/
_dark.css /* Dark theme overrides */
_light.css /* Light theme overrides */
_high-contrast.css
main.css /* Imports everything */
Naming conventions
Use consistent prefixes to group related variables:
/* Colors */
--color-bg-primary
--color-bg-secondary
--color-text-primary
--color-accent
/* Spacing */
--space-xs
--space-sm
--space-md
/* Typography */
--font-sans
--font-mono
--text-sm
--text-base
/* Motion */
--duration-fast
--easing-standard
This makes autocomplete more useful and variables easier to find.
Documentation
I keep a comment block at the top of my variables file:
/**
* Design System Variables
*
* Color scale: 50 (lightest) to 900 (darkest)
* Spacing scale: xs (4px) to xl (64px), powers of 2
* Typography scale: sm (14px) to 2xl (32px)
*
* Semantic naming:
* - bg-* for backgrounds
* - text-* for text colors
* - border-* for borders
* - accent for primary brand color
*/
:root {
/* Variables here */
}
Future you (or your team) will thank you.
Debugging CSS variables
When things go wrong, here's how to debug:
Use DevTools computed values
Open DevTools, select an element, and check the Computed tab. You'll see:
background-color: rgb(26, 26, 46)
--bg-secondary: #1a1a2e
This shows both the variable name and resolved value. If it shows invalid, the variable isn't defined.
Add temporary borders
To see which theme is active:
:root[data-theme='dark'] {
--debug-border: 2px solid red;
}
:root[data-theme='light'] {
--debug-border: 2px solid blue;
}
body {
border: var(--debug-border, none);
}
You'll see a red border in dark mode, blue in light mode.
Console logging
Log the current theme from JavaScript:
console.log('Current theme:', document.documentElement.dataset.theme);
console.log('Computed color:',
getComputedStyle(document.documentElement)
.getPropertyValue('--bg-primary'));
CSS variable inspector extension
Some browser extensions show all defined CSS variables. Search for "CSS Variable Inspector" in your browser's extension store.
Common mistakes to avoid
From my experience (and mistakes), here are pitfalls to watch out for:
Don't hardcode colors outside :root
Bad:
.header {
background: #1a1a2e; /* Hardcoded! */
}
Good:
:root {
--header-bg: #1a1a2e;
}
.header {
background: var(--header-bg);
}
Even if you think a color will never change, use a variable. Future flexibility is worth it.
Don't skip semantic naming
Bad:
:root {
--color1: #22d3ee;
--color2: #1a1a2e;
--color3: #e2e8f0;
}
Good:
:root {
--accent: #22d3ee;
--bg-secondary: #1a1a2e;
--text-primary: #e2e8f0;
}
Names should describe purpose, not appearance.
Don't forget about specificity
If a variable override isn't working, check specificity:
:root {
--spacing: 1rem;
}
/* This won't work as expected */
.container {
--spacing: 2rem;
}
html[data-theme='dark'] {
--spacing: 1.5rem; /* Overrides .container due to higher specificity */
}
Theme selectors should have lower specificity than component overrides, or use !important sparingly.
Don't over-nest variables
Bad:
:root {
--primary: var(--cyan);
--cyan: var(--color-cyan-500);
--color-cyan-500: #22d3ee;
}
Good:
:root {
--cyan-500: #22d3ee;
--primary: var(--cyan-500);
}
One level of indirection is fine, multiple levels make debugging harder.
Testing your themes
Before shipping, test your themes thoroughly:
Manual checklist
- [ ] Check all pages in both light and dark themes
- [ ] Verify focus states are visible in both themes
- [ ] Test hover states and transitions
- [ ] Check form inputs and buttons
- [ ] Review loading states and spinners
- [ ] Verify modals and overlays
- [ ] Check syntax highlighted code blocks
- [ ] Test with browser DevTools in different viewports
Automated testing
You can write tests for theme switching:
// Jest/Testing Library example
test('theme toggle works', () => {
const toggle = screen.getByRole('button', { name: /toggle theme/i });
// Should start with system preference or default
expect(document.documentElement.dataset.theme).toBeDefined();
// Click should toggle
fireEvent.click(toggle);
const firstTheme = document.documentElement.dataset.theme;
fireEvent.click(toggle);
const secondTheme = document.documentElement.dataset.theme;
expect(firstTheme).not.toBe(secondTheme);
});
test('theme persists on reload', () => {
// Set theme
document.documentElement.dataset.theme = 'dark';
localStorage.setItem('theme', 'dark');
// Simulate page reload
window.location.reload();
// Should restore theme
expect(document.documentElement.dataset.theme).toBe('dark');
});
Visual regression testing
Tools like Percy or Chromatic can screenshot your site in different themes and flag visual changes. Worth it for larger projects.
Accessibility considerations
When building themes, think about accessibility:
WCAG contrast ratios: Make sure your text colors have sufficient contrast against backgrounds. Use a contrast checker when defining your color variables.
:root {
/* Bad: Only 2.5:1 contrast */
--bg: #333333;
--text: #666666;
/* Good: 7:1 contrast for AA compliance */
--bg: #1a1a1a;
--text: #e2e8f0;
}
Respect user preferences: Some users set prefers-color-scheme or prefers-contrast in their OS. Honor these:
@media (prefers-contrast: high) {
:root {
--border: 2px solid #ffffff; /* Thicker, higher contrast */
--text-primary: #ffffff;
--bg-primary: #000000;
}
}
Focus indicators: Make sure your accent color works for focus states:
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
Test this in both themes to ensure it's visible.
Migrating existing sites to CSS variables
Already have a site with hardcoded colors? Here's how to migrate without breaking everything.
Step 1: Audit your colors
First, find all the colors you're using:
# Search for hex colors in CSS files
grep -r "#[0-9a-fA-F]\{3,6\}" styles/
# Search for rgb/rgba
grep -r "rgba\?(" styles/
Make a list. You'll probably find you're using way more colors than you thought.
Step 2: Group and name them
Look for duplicates or near-duplicates. That #1a1a2e and #1b1b2f are probably meant to be the same color. Consolidate them.
Create a variables file with semantic names:
/* variables.css */
:root {
/* Map your existing colors to variables */
--bg-primary: #0a0a0f;
--bg-secondary: #1a1a2e;
--text-primary: #e2e8f0;
--accent: #22d3ee;
/* ... etc */
}
Step 3: Replace incrementally
Don't try to replace everything at once. Start with one component:
/* Before */
.header {
background: #1a1a2e;
color: #e2e8f0;
}
/* After */
.header {
background: var(--bg-secondary);
color: var(--text-primary);
}
Test thoroughly, then move to the next component.
Step 4: Add your light theme
Once all colors are variables, adding a light theme is easy:
:root[data-theme='light'] {
--bg-primary: #f8fafc;
--bg-secondary: #e2e8f0;
--text-primary: #1e293b;
--accent: #0891b2;
}
Step 5: Add the toggle
Finally, add the JavaScript to toggle themes:
function toggleTheme() {
const html = document.documentElement;
const newTheme = html.dataset.theme === 'light' ? 'dark' : 'light';
html.dataset.theme = newTheme;
localStorage.setItem('theme', newTheme);
}
This incremental approach is safer than a big-bang rewrite. You can deploy each step independently.
Real-world example: this portfolio
This portfolio site uses CSS variables for everything. The implementation is simple:
- About 20 color variables defined in
:root - Light theme overrides in
:root[data-theme='light'] - One 15-line JavaScript function for theme toggling
- Zero dependencies, zero build tools needed
The entire theming system is under 100 lines of code. It handles dark mode, light mode, respects system preferences, persists user choice, and has zero flash of incorrect theme.
That's the power of CSS variables. They let you build sophisticated theming with minimal code.
The bottom line
CSS variables are underrated. For theming, they're the simplest solution:
- Define your colors as variables in
:root - Override them in
[data-theme='light']or similar - Toggle with one line of JavaScript
No library needed. No build step. Works in every browser. That's the kind of solution I like.
The beauty is in the simplicity. You don't need a complex architecture or a JavaScript framework to build a great theming system. CSS already has the primitives you need. Use them.
Start small. Add a few variables to your next project. Once you see how clean it makes your code, you'll wonder why you ever used anything else.