DEV Community

Cover image for Implementing Dark Mode: CSS Variables, System Preference, and Persistence
Bishoy Bishai
Bishoy Bishai

Posted on • Originally published at bishoy-bishai.github.io

Implementing Dark Mode: CSS Variables, System Preference, and Persistence

Demystifying Dark Mode: The Elegant Path with CSS Variables, System Preference, and Persistence

Every modern web application eventually faces the dark mode dilemma. It's no longer just a "nice-to-have"; users expect it, often as a fundamental part of their browsing experience. But how often have you seen dark mode implementations that feel clunky, suffer from a "flash of unstyled content" (FOUC), or completely disregard your OS-level preferences? It's a common struggle, and honestly, it used to be a source of frustration for me too.

Early in my career, theming often meant complex JavaScript manipulating styles directly, or an endless array of Sass variables compiled into bulky stylesheets. It worked, but it was brittle, hard to maintain, and far from dynamic. Then, CSS Custom Properties—CSS Variables—landed, and the game changed entirely. It unlocked a truly elegant, performant, and user-friendly approach to dynamic theming.

In this deep dive, we'll build a dark mode solution for a React application that's resilient, respectful of user preferences, and persistent across sessions. We'll leverage the power of CSS variables, React's context API, and a touch of vanilla JavaScript for a seamless experience.

The Core Idea: CSS Variables as Theme Tokens

At the heart of a great dark mode implementation are CSS variables. Think of them as dynamic tokens for your styles. Instead of hardcoding color: #FFFFFF;, you'd use color: var(--color-text-primary);.

Here's why this is revolutionary: you define your variables once, and then you can change their values based on a parent selector.

/* default light theme */
:root {
  --color-background: #ffffff;
  --color-text-primary: #333333;
  --color-accent: #007bff;
}

/* dark theme overrides */
[data-theme='dark'] {
  --color-background: #1a1a1a;
  --color-text-primary: #f0f0f0;
  --color-accent: #6200ee;
}

/* Your component styles */
body {
  background-color: var(--color-background);
  color: var(--color-text-primary);
  transition: background-color 0.3s ease, color 0.3s ease; /* smooth transitions */
}

button {
  background-color: var(--color-accent);
  color: var(--color-text-primary); /* or white for contrast */
}
Enter fullscreen mode Exit fullscreen mode

Notice the [data-theme='dark'] selector. This is the magic. By toggling a data-theme attribute on our body or html element, we can swap out all our theme-dependent CSS variable values instantly. No JavaScript style manipulation needed, just good old CSS cascading.

Respecting System Preferences with prefers-color-scheme

Before we even talk about toggles and persistence, the absolute first thing you should do is respect your user's operating system preference. Many users already have dark mode enabled at the OS level, and your application should honor that by default.

This is where the @media (prefers-color-scheme: dark) media query comes in:

/* default light theme (or just no media query for light) */
:root {
  --color-background: #ffffff;
  --color-text-primary: #333333;
  /* ... other light theme variables */
}

/* System preference for dark mode */
@media (prefers-color-scheme: dark) {
  :root {
    --color-background: #1a1a1a;
    --color-text-primary: #f0f0f0;
    /* ... other dark theme variables */
  }
}

/* Override system preference if user explicitly chooses light */
[data-theme='light'] {
  --color-background: #ffffff;
  --color-text-primary: #333333;
}

/* Override system preference if user explicitly chooses dark */
[data-theme='dark'] {
  --color-background: #1a1a1a;
  --color-text-primary: #f0f0f0;
}
Enter fullscreen mode Exit fullscreen mode

With this setup, if a user's system is set to dark mode and they haven't explicitly chosen a theme on your site, they'll automatically see your dark theme. Beautiful, right?

Bringing it to React: Context and a Custom Hook

Now, how do we manage this data-theme attribute in a React app, provide a toggle, and ensure persistence? React's Context API is perfect for this.

First, let's define our theme types:

// src/types/theme.ts
export type Theme = 'light' | 'dark';
Enter fullscreen mode Exit fullscreen mode

Next, our ThemeContext and ThemeProvider:

// src/contexts/ThemeContext.tsx
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { Theme } from '../types/theme';

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
  setTheme: (theme: Theme) => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

interface ThemeProviderProps {
  children: React.ReactNode;
}

export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
  const [theme, setThemeState] = useState<Theme>(() => {
    // 1. Check for stored preference
    const storedTheme = localStorage.getItem('theme') as Theme | null;
    if (storedTheme) return storedTheme;

    // 2. Check system preference
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  });

  const setTheme = useCallback((newTheme: Theme) => {
    setThemeState(newTheme);
    localStorage.setItem('theme', newTheme);
    document.documentElement.setAttribute('data-theme', newTheme);
  }, []);

  // Set initial theme and listen for system preference changes
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    const handleChange = (e: MediaQueryListEvent) => {
      if (!localStorage.getItem('theme')) { // Only update if user hasn't made an explicit choice
        setThemeState(e.matches ? 'dark' : 'light');
        document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
      }
    };
    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, [theme, setTheme]); // Added setTheme to deps to satisfy linter, though it's memoized

  const toggleTheme = useCallback(() => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  }, [theme, setTheme]);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
};
Enter fullscreen mode Exit fullscreen mode

Wrap your application's root component with ThemeProvider:

```typescript jsx
// src/App.tsx
import React from 'react';
import { ThemeProvider } from './contexts/ThemeContext';
import MyComponent from './MyComponent';
import ThemeToggle from './ThemeToggle'; // We'll create this

const App: React.FC = () => {
return (


My Awesome App






);
};

export default App;



And a simple `ThemeToggle` component:



```typescript jsx
// src/components/ThemeToggle.tsx
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';

const ThemeToggle: React.FC = () => {
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme} aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}>
      {theme === 'light' ? '🌙 Dark Mode' : '☀️ Light Mode'}
    </button>
  );
};

export default ThemeToggle;
Enter fullscreen mode Exit fullscreen mode

The Crucial Detail: Preventing FOUC (Flash of Unstyled Content)

Here's a common pitfall: when your React app loads, it takes a moment for JavaScript to execute, fetch the theme from localStorage, and apply data-theme. In that brief moment, your page might render with the default (light) theme before flipping to dark, causing an unpleasant "flash."

To combat this, we inject a small, blocking script directly into the head of our index.html (or equivalent in your framework, like Next.js _document.tsx):

<!-- public/index.html (inside <head>) -->
<script>
  (function() {
    const getInitialTheme = () => {
      const storedTheme = localStorage.getItem('theme');
      if (storedTheme) {
        return storedTheme;
      }
      return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    };
    const initialTheme = getInitialTheme();
    document.documentElement.setAttribute('data-theme', initialTheme);
  })();
</script>
Enter fullscreen mode Exit fullscreen mode

This tiny script runs before your React bundle loads. It checks localStorage first, then prefers-color-scheme, and immediately sets the data-theme attribute. By the time your CSS loads and React renders, the correct theme is already applied, eliminating the dreaded FOUC.

Real-World Insights and Lessons Learned

  • Semantic Naming is Key: Name your CSS variables semantically (--color-text-primary, --color-background-card) rather than based on their current value (--light-grey, --dark-blue). This makes theme swapping much more robust.
  • Accessibility First: Always test your dark mode (and light mode!) for sufficient contrast. Tools like Chrome's Lighthouse or even simple contrast checkers can help. Don't forget focus states for keyboard users.
  • Beyond Colors: Dark mode isn't just about colors. Sometimes, shadows need to be lighter or less pronounced, borders might need to change, or images might need slightly different overlays to maintain readability. Consider all visual aspects.
  • Transitions: Add transition properties to your background-color and color on body or html to ensure a smooth, pleasing fade when the theme changes, rather than an abrupt switch.
  • Server-Side Rendering (SSR): If you're using SSR (like Next.js), the FOUC prevention script needs to be handled carefully within your _document.tsx or similar file to ensure it's injected correctly into the server-rendered HTML.

Wrapping Up

Implementing dark mode gracefully no longer has to be a headache. By combining the power of CSS variables for dynamic styling, @media (prefers-color-scheme: dark) for respecting user preferences, localStorage for persistence, and a small, critical script to prevent FOUC, you can deliver a truly polished and user-centric experience. This approach is not just about aesthetics; it’s about providing choice and improving accessibility, all while keeping your codebase clean and maintainable. Go forth and build beautifully themed applications!


✨ Let's keep the conversation going!

If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.

✍️ Read more on my blog: bishoy-bishai.github.io

Let's chat on LinkedIn: linkedin.com/in/bishoybishai


Top comments (0)