CSS Variables Are All You Need for Theming

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:

  1. Inspect an element
  2. Look at the Computed tab
  3. 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:

  1. Define your colors as variables in :root
  2. Override them in [data-theme='light'] or similar
  3. 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.

Send a Message