AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Custom useDarkMode Hook: Dark Theme Toggle Made Right (2026)

Last updated:
Custom useDarkMode Hook: Dark Theme Toggle Made Right (2026)

Master building a production-ready useDarkMode hook. Learn system preference detection, localStorage persistence, SSR handling, and seamless theme switching with complete TypeScript and JavaScript examples.

# Custom useDarkMode Hook: Dark Theme Toggle Made Right (2026)

Dark mode has moved from a nice-to-have feature to an expectation. Users expect applications to respect their system preferences, remember their choice, and switch themes instantly without page flicker. Implementing this correctly requires handling multiple concerns: system preference detection, localStorage persistence, server-side rendering, and real-time updates across components.

A custom useDarkMode hook encapsulates all this complexity, giving you a clean interface to toggle themes throughout your application. In this guide, we'll build a production-ready implementation that handles edge cases, respects user preferences, and works seamlessly in modern React applications.

# Table of Contents

  1. Understanding Dark Mode Requirements
  2. Basic useDarkMode Hook Implementation
  3. Detecting System Preferences
  4. Persisting User Choice with localStorage
  5. Server-Side Rendering Considerations
  6. Integration with React Context
  7. Advanced: Real-Time System Preference Changes
  8. Practical Example: Theme Provider Setup
  9. FAQ

# Understanding Dark Mode Requirements

Before building, let's clarify what a robust dark mode solution needs:

1. System Preference Detection

Modern browsers support prefers-color-scheme media query. You should detect whether the user's OS is set to dark mode. This is the first fallback when the user hasn't explicitly chosen a theme.

2. User Choice Persistence

When a user toggles dark mode in your app, their choice should be remembered across sessions. localStorage is the standard approach for this, though some apps prefer URL parameters or server-side storage.

3. No Flash of Wrong Theme

The worst user experience is when the page loads in light mode, then flashes to dark mode. This happens when your theme detection happens after the initial render. You need to detect the theme synchronously or use special techniques to load the theme before rendering.

4. Responsive to System Changes

If the user changes their OS theme preference, your app should update immediately. You'll use the change event on the prefers-color-scheme media query listener to detect this.

5. Server-Side Rendering Safety

In Next.js or other SSR frameworks, localStorage isn't available on the server. You need to handle this gracefully without throwing errors.

6. Performance

Switching themes should update the DOM instantly without layout shifts. Ideally, you're just toggling a class or CSS variable, not re-rendering the entire component tree.

# Basic useDarkMode Hook Implementation

Here's a straightforward version that gets you started:

# TypeScript Version

typescript
import { useState } from 'react';

interface UseDarkModeReturn {
  isDarkMode: boolean;
  toggle: () => void;
}

export function useDarkMode(): UseDarkModeReturn {
  const [isDarkMode, setIsDarkMode] = useState(false);

  const toggle = () => {
    setIsDarkMode(prev => !prev);
  };

  return { isDarkMode, toggle };
}

# JavaScript Version

javascript
import { useState } from 'react';

export function useDarkMode() {
  const [isDarkMode, setIsDarkMode] = useState(false);

  const toggle = () => {
    setIsDarkMode(prev => !prev);
  };

  return { isDarkMode, toggle };
}

You can use this in a component like this:

# TypeScript Component Usage

typescript
export function App() {
  const { isDarkMode, toggle } = useDarkMode();

  return (
    <div className={isDarkMode ? 'dark' : 'light'}>
      <button onClick={toggle}>
        {isDarkMode ? '☀️ Light' : '🌙 Dark'}
      </button>
      <p>Current theme: {isDarkMode ? 'Dark' : 'Light'}</p>
    </div>
  );
}

This basic version works but misses all the real-world requirements. Let's progressively add them.

# Detecting System Preferences

The first improvement is respecting the user's system setting. The window.matchMedia() API lets you check and listen to the prefers-color-scheme media query:

# TypeScript Version (With System Detection)

typescript
import { useState, useEffect } from 'react';

interface UseDarkModeReturn {
  isDarkMode: boolean;
  toggle: () => void;
}

export function useDarkMode(): UseDarkModeReturn {
  // Default to system preference
  const [isDarkMode, setIsDarkMode] = useState(() => {
    // Check if we're in the browser
    if (typeof window === 'undefined') {
      return false;
    }

    // Check system preference
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    return isDark;
  });

  const toggle = () => {
    setIsDarkMode(prev => !prev);
  };

  // Listen for system preference changes
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    const handleChange = (e: MediaQueryListEvent) => {
      setIsDarkMode(e.matches);
    };

    // addEventListener is the modern approach
    mediaQuery.addEventListener('change', handleChange);

    return () => {
      mediaQuery.removeEventListener('change', handleChange);
    };
  }, []);

  return { isDarkMode, toggle };
}

# JavaScript Version (With System Detection)

javascript
import { useState, useEffect } from 'react';

export function useDarkMode() {
  const [isDarkMode, setIsDarkMode] = useState(() => {
    if (typeof window === 'undefined') {
      return false;
    }

    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    return isDark;
  });

  const toggle = () => {
    setIsDarkMode(prev => !prev);
  };

  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    const handleChange = (e) => {
      setIsDarkMode(e.matches);
    };

    mediaQuery.addEventListener('change', handleChange);

    return () => {
      mediaQuery.removeEventListener('change', handleChange);
    };
  }, []);

  return { isDarkMode, toggle };
}

Key points here:

  • The typeof window === 'undefined' check prevents errors in SSR environments
  • window.matchMedia() returns a MediaQueryList object
  • We use addEventListener (modern) instead of the deprecated addListener
  • The cleanup function removes the listener to prevent memory leaks
  • System preference changes automatically update the state

# Persisting User Choice with localStorage

Now let's add localStorage persistence so the user's explicit choice is remembered:

# TypeScript Version (With Persistence)

typescript
import { useState, useEffect } from 'react';

const DARK_MODE_KEY = 'app-dark-mode-preference';

interface UseDarkModeReturn {
  isDarkMode: boolean;
  toggle: () => void;
}

export function useDarkMode(): UseDarkModeReturn {
  // Initialize from localStorage or system preference
  const [isDarkMode, setIsDarkMode] = useState(() => {
    if (typeof window === 'undefined') {
      return false;
    }

    // Check localStorage first (user's explicit choice)
    const stored = localStorage.getItem(DARK_MODE_KEY);
    if (stored !== null) {
      return stored === 'true';
    }

    // Fall back to system preference
    return window.matchMedia('(prefers-color-scheme: dark)').matches;
  });

  // Persist to localStorage whenever dark mode changes
  useEffect(() => {
    localStorage.setItem(DARK_MODE_KEY, String(isDarkMode));
  }, [isDarkMode]);

  // Listen for system preference changes (only if no user preference stored)
  useEffect(() => {
    if (typeof window === 'undefined') return;

    const stored = localStorage.getItem(DARK_MODE_KEY);
    // Only listen to system changes if user hasn't made explicit choice
    if (stored !== null) return;

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    const handleChange = (e: MediaQueryListEvent) => {
      setIsDarkMode(e.matches);
    };

    mediaQuery.addEventListener('change', handleChange);

    return () => {
      mediaQuery.removeEventListener('change', handleChange);
    };
  }, []);

  const toggle = () => {
    setIsDarkMode(prev => !prev);
  };

  return { isDarkMode, toggle };
}

# JavaScript Version (With Persistence)

javascript
import { useState, useEffect } from 'react';

const DARK_MODE_KEY = 'app-dark-mode-preference';

export function useDarkMode() {
  const [isDarkMode, setIsDarkMode] = useState(() => {
    if (typeof window === 'undefined') {
      return false;
    }

    const stored = localStorage.getItem(DARK_MODE_KEY);
    if (stored !== null) {
      return stored === 'true';
    }

    return window.matchMedia('(prefers-color-scheme: dark)').matches;
  });

  useEffect(() => {
    localStorage.setItem(DARK_MODE_KEY, String(isDarkMode));
  }, [isDarkMode]);

  useEffect(() => {
    if (typeof window === 'undefined') return;

    const stored = localStorage.getItem(DARK_MODE_KEY);
    if (stored !== null) return;

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    const handleChange = (e) => {
      setIsDarkMode(e.matches);
    };

    mediaQuery.addEventListener('change', handleChange);

    return () => {
      mediaQuery.removeEventListener('change', handleChange);
    };
  }, []);

  const toggle = () => {
    setIsDarkMode(prev => !prev);
  };

  return { isDarkMode, toggle };
}

Important behavior here:

  • localStorage takes priority over system preference
  • Once a user explicitly toggles the theme, we stop listening to system changes
  • If you wanted to reset to system preference, you'd clear localStorage
  • The key is stored as a string ('true'/'false') because localStorage only stores strings

# Server-Side Rendering Considerations

In a Next.js or Remix application, localStorage doesn't exist on the server. We need special handling to prevent hydration mismatches:

# TypeScript Version (SSR-Safe)

typescript
import { useState, useEffect } from 'react';

const DARK_MODE_KEY = 'app-dark-mode-preference';

interface UseDarkModeReturn {
  isDarkMode: boolean;
  toggle: () => void;
  isLoaded: boolean;
}

export function useDarkMode(): UseDarkModeReturn {
  // Initialize to false, will hydrate on client
  const [isDarkMode, setIsDarkMode] = useState(false);
  const [isLoaded, setIsLoaded] = useState(false);

  // This effect only runs on the client, after hydration
  useEffect(() => {
    // Determine the actual theme
    let theme = false;

    if (typeof window !== 'undefined') {
      const stored = localStorage.getItem(DARK_MODE_KEY);
      if (stored !== null) {
        theme = stored === 'true';
      } else {
        theme = window.matchMedia('(prefers-color-scheme: dark)').matches;
      }
    }

    // Update state only after client hydration is complete
    setIsDarkMode(theme);
    setIsLoaded(true);

    // Set up system preference listener
    if (typeof window !== 'undefined') {
      const stored = localStorage.getItem(DARK_MODE_KEY);
      if (stored === null) {
        const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

        const handleChange = (e: MediaQueryListEvent) => {
          setIsDarkMode(e.matches);
        };

        mediaQuery.addEventListener('change', handleChange);

        return () => {
          mediaQuery.removeEventListener('change', handleChange);
        };
      }
    }
  }, []);

  // Persist to localStorage
  useEffect(() => {
    if (typeof window !== 'undefined' && isLoaded) {
      localStorage.setItem(DARK_MODE_KEY, String(isDarkMode));
    }
  }, [isDarkMode, isLoaded]);

  const toggle = () => {
    setIsDarkMode(prev => !prev);
  };

  return { isDarkMode, toggle, isLoaded };
}

# JavaScript Version (SSR-Safe)

javascript
import { useState, useEffect } from 'react';

const DARK_MODE_KEY = 'app-dark-mode-preference';

export function useDarkMode() {
  const [isDarkMode, setIsDarkMode] = useState(false);
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    let theme = false;

    if (typeof window !== 'undefined') {
      const stored = localStorage.getItem(DARK_MODE_KEY);
      if (stored !== null) {
        theme = stored === 'true';
      } else {
        theme = window.matchMedia('(prefers-color-scheme: dark)').matches;
      }
    }

    setIsDarkMode(theme);
    setIsLoaded(true);

    if (typeof window !== 'undefined') {
      const stored = localStorage.getItem(DARK_MODE_KEY);
      if (stored === null) {
        const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

        const handleChange = (e) => {
          setIsDarkMode(e.matches);
        };

        mediaQuery.addEventListener('change', handleChange);

        return () => {
          mediaQuery.removeEventListener('change', handleChange);
        };
      }
    }
  }, []);

  useEffect(() => {
    if (typeof window !== 'undefined' && isLoaded) {
      localStorage.setItem(DARK_MODE_KEY, String(isDarkMode));
    }
  }, [isDarkMode, isLoaded]);

  const toggle = () => {
    setIsDarkMode(prev => !prev);
  };

  return { isDarkMode, toggle, isLoaded };
}

SSR-specific improvements:

  • The isLoaded flag prevents rendering content before hydration completes
  • Initialization happens on the client only, not the server
  • No localStorage access during server rendering
  • Components can use isLoaded to avoid rendering theme-dependent content before hydration

In your component:

typescript
export function App() {
  const { isDarkMode, toggle, isLoaded } = useDarkMode();

  // Skip rendering until theme is loaded (prevents flash)
  if (!isLoaded) {
    return null; // Or a skeleton
  }

  return (
    <div className={isDarkMode ? 'dark' : 'light'}>
      <button onClick={toggle}>Toggle Theme</button>
    </div>
  );
}

# Integration with React Context

For a real application, you want all components to access the theme without prop drilling. React Context is the perfect pattern:

# TypeScript Implementation

typescript
import { ReactNode, createContext, useContext } from 'react';
import { useDarkMode as useDarkModeHook } from './useDarkMode';

interface ThemeContextType {
  isDarkMode: boolean;
  toggle: () => void;
  isLoaded: boolean;
}

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

interface ThemeProviderProps {
  children: ReactNode;
}

export function ThemeProvider({ children }: ThemeProviderProps) {
  const darkMode = useDarkModeHook();

  return (
    <ThemeContext.Provider value={darkMode}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextType {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

# JavaScript Implementation

javascript
import { createContext, useContext } from 'react';
import { useDarkMode as useDarkModeHook } from './useDarkMode';

const ThemeContext = createContext(undefined);

export function ThemeProvider({ children }) {
  const darkMode = useDarkModeHook();

  return (
    <ThemeContext.Provider value={darkMode}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

Now components can access the theme anywhere:

typescript
export function Header() {
  const { isDarkMode, toggle } = useTheme();

  return (
    <header className={isDarkMode ? 'dark' : 'light'}>
      <h1>My App</h1>
      <button onClick={toggle}>Toggle</button>
    </header>
  );
}

export function Footer() {
  const { isDarkMode } = useTheme();

  return (
    <footer className={isDarkMode ? 'dark' : 'light'}>
      <p2025 My Company</p>
    </footer>
  );
}

And in your main app:

typescript
export default function App() {
  return (
    <ThemeProvider>
      <Header />
      <main>
        <YourContent />
      </main>
      <Footer />
    </ThemeProvider>
  );
}

# Advanced: Real-Time System Preference Changes

For production applications, handle the edge case where the system preference changes while the app is open:

# TypeScript Implementation (Advanced)

typescript
import { useState, useEffect, useCallback } from 'react';

const DARK_MODE_KEY = 'app-dark-mode-preference';

type ThemeMode = 'light' | 'dark' | 'system';

interface UseDarkModeReturn {
  isDarkMode: boolean;
  toggle: () => void;
  setThemeMode: (mode: ThemeMode) => void;
  themeMode: ThemeMode;
  isLoaded: boolean;
}

export function useDarkMode(): UseDarkModeReturn {
  const [isDarkMode, setIsDarkMode] = useState(false);
  const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
  const [isLoaded, setIsLoaded] = useState(false);

  // Determine if system is in dark mode
  const getSystemPreference = useCallback(() => {
    if (typeof window === 'undefined') return false;
    return window.matchMedia('(prefers-color-scheme: dark)').matches;
  }, []);

  // Initialize theme on client
  useEffect(() => {
    const stored = localStorage.getItem(DARK_MODE_KEY) as ThemeMode | null;
    const mode: ThemeMode = stored || 'system';
    setThemeModeState(mode);

    let theme = false;
    if (mode === 'dark') {
      theme = true;
    } else if (mode === 'light') {
      theme = false;
    } else {
      theme = getSystemPreference();
    }

    setIsDarkMode(theme);
    setIsLoaded(true);
  }, [getSystemPreference]);

  // Listen to system changes only in 'system' mode
  useEffect(() => {
    if (themeMode !== 'system' || !isLoaded) return;

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    const handleChange = (e: MediaQueryListEvent) => {
      setIsDarkMode(e.matches);
    };

    mediaQuery.addEventListener('change', handleChange);
    return () => {
      mediaQuery.removeEventListener('change', handleChange);
    };
  }, [themeMode, isLoaded]);

  // Persist theme mode and update dark mode state
  const setThemeMode = useCallback((mode: ThemeMode) => {
    setThemeModeState(mode);
    localStorage.setItem(DARK_MODE_KEY, mode);

    if (mode === 'dark') {
      setIsDarkMode(true);
    } else if (mode === 'light') {
      setIsDarkMode(false);
    } else {
      setIsDarkMode(getSystemPreference());
    }
  }, [getSystemPreference]);

  const toggle = useCallback(() => {
    setThemeMode(isDarkMode ? 'light' : 'dark');
  }, [isDarkMode, setThemeMode]);

  return { isDarkMode, toggle, setThemeMode, themeMode, isLoaded };
}

# JavaScript Implementation (Advanced)

javascript
import { useState, useEffect, useCallback } from 'react';

const DARK_MODE_KEY = 'app-dark-mode-preference';

export function useDarkMode() {
  const [isDarkMode, setIsDarkMode] = useState(false);
  const [themeMode, setThemeModeState] = useState('system');
  const [isLoaded, setIsLoaded] = useState(false);

  const getSystemPreference = useCallback(() => {
    if (typeof window === 'undefined') return false;
    return window.matchMedia('(prefers-color-scheme: dark)').matches;
  }, []);

  useEffect(() => {
    const stored = localStorage.getItem(DARK_MODE_KEY);
    const mode = stored || 'system';
    setThemeModeState(mode);

    let theme = false;
    if (mode === 'dark') {
      theme = true;
    } else if (mode === 'light') {
      theme = false;
    } else {
      theme = getSystemPreference();
    }

    setIsDarkMode(theme);
    setIsLoaded(true);
  }, [getSystemPreference]);

  useEffect(() => {
    if (themeMode !== 'system' || !isLoaded) return;

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    const handleChange = (e) => {
      setIsDarkMode(e.matches);
    };

    mediaQuery.addEventListener('change', handleChange);
    return () => {
      mediaQuery.removeEventListener('change', handleChange);
    };
  }, [themeMode, isLoaded]);

  const setThemeMode = useCallback((mode) => {
    setThemeModeState(mode);
    localStorage.setItem(DARK_MODE_KEY, mode);

    if (mode === 'dark') {
      setIsDarkMode(true);
    } else if (mode === 'light') {
      setIsDarkMode(false);
    } else {
      setIsDarkMode(getSystemPreference());
    }
  }, [getSystemPreference]);

  const toggle = useCallback(() => {
    setThemeMode(isDarkMode ? 'light' : 'dark');
  }, [isDarkMode, setThemeMode]);

  return { isDarkMode, toggle, setThemeMode, themeMode, isLoaded };
}

This advanced version lets users choose between three options:

  • 'light' - Always light mode
  • 'dark' - Always dark mode
  • 'system' - Follow OS preference and update when it changes

# Practical Example: Theme Provider Setup

Here's a complete, production-ready setup for a Next.js or React app:

# TypeScript Complete Setup

typescript
// hooks/useDarkMode.ts
import { useState, useEffect, useCallback } from 'react';

const DARK_MODE_KEY = 'app-dark-mode-preference';

type ThemeMode = 'light' | 'dark' | 'system';

interface UseDarkModeReturn {
  isDarkMode: boolean;
  toggle: () => void;
  setThemeMode: (mode: ThemeMode) => void;
  themeMode: ThemeMode;
  isLoaded: boolean;
}

export function useDarkMode(): UseDarkModeReturn {
  const [isDarkMode, setIsDarkMode] = useState(false);
  const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
  const [isLoaded, setIsLoaded] = useState(false);

  const getSystemPreference = useCallback(() => {
    if (typeof window === 'undefined') return false;
    return window.matchMedia('(prefers-color-scheme: dark)').matches;
  }, []);

  useEffect(() => {
    const stored = localStorage.getItem(DARK_MODE_KEY) as ThemeMode | null;
    const mode: ThemeMode = stored || 'system';
    setThemeModeState(mode);

    let theme = false;
    if (mode === 'dark') {
      theme = true;
    } else if (mode === 'light') {
      theme = false;
    } else {
      theme = getSystemPreference();
    }

    setIsDarkMode(theme);
    setIsLoaded(true);
  }, [getSystemPreference]);

  useEffect(() => {
    if (themeMode !== 'system' || !isLoaded) return;

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    const handleChange = (e: MediaQueryListEvent) => {
      setIsDarkMode(e.matches);
    };

    mediaQuery.addEventListener('change', handleChange);
    return () => {
      mediaQuery.removeEventListener('change', handleChange);
    };
  }, [themeMode, isLoaded]);

  const setThemeMode = useCallback((mode: ThemeMode) => {
    setThemeModeState(mode);
    localStorage.setItem(DARK_MODE_KEY, mode);

    if (mode === 'dark') {
      setIsDarkMode(true);
    } else if (mode === 'light') {
      setIsDarkMode(false);
    } else {
      setIsDarkMode(getSystemPreference());
    }
  }, [getSystemPreference]);

  const toggle = useCallback(() => {
    setThemeMode(isDarkMode ? 'light' : 'dark');
  }, [isDarkMode, setThemeMode]);

  return { isDarkMode, toggle, setThemeMode, themeMode, isLoaded };
}

// contexts/ThemeContext.tsx
import { ReactNode, createContext, useContext } from 'react';
import { useDarkMode } from '@/hooks/useDarkMode';

interface ThemeContextType {
  isDarkMode: boolean;
  toggle: () => void;
  setThemeMode: (mode: 'light' | 'dark' | 'system') => void;
  themeMode: 'light' | 'dark' | 'system';
  isLoaded: boolean;
}

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

interface ThemeProviderProps {
  children: ReactNode;
}

export function ThemeProvider({ children }: ThemeProviderProps) {
  const darkMode = useDarkMode();

  return (
    <ThemeContext.Provider value={darkMode}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextType {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

// components/ThemeToggle.tsx
import { useTheme } from '@/contexts/ThemeContext';

export function ThemeToggle() {
  const { isDarkMode, toggle, themeMode, setThemeMode } = useTheme();

  return (
    <div className="flex items-center gap-2">
      <button
        onClick={toggle}
        className="px-3 py-2 rounded-lg bg-gray-200 dark:bg-gray-700"
        aria-label="Toggle theme"
      >
        {isDarkMode ? '☀️' : '🌙'}
      </button>

      <select
        value={themeMode}
        onChange={(e) => setThemeMode(e.target.value as any)}
        className="px-3 py-2 rounded-lg bg-gray-200 dark:bg-gray-700"
      >
        <option value="light">Light</option>
        <option value="dark">Dark</option>
        <option value="system">System</option>
      </select>
    </div>
  );
}

// app/layout.tsx (Next.js example)
import { ThemeProvider } from '@/contexts/ThemeContext';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

# CSS with dark mode support

css
/* styles/globals.css */

:root {
  --background: #ffffff;
  --foreground: #000000;
}

html.dark {
  --background: #000000;
  --foreground: #ffffff;
}

body {
  background-color: var(--background);
  color: var(--foreground);
}

/* Or using Tailwind CSS (simpler approach) */
/* Just use dark: prefix in your classes */

# FAQ

# Q: Should I store the theme in localStorage or URL query parameters?

A: localStorage is better for most cases. It persists across sessions and doesn't clutter URLs. Query parameters are useful if you want to share a specific theme preference with others (e.g., ?theme=dark), but that's rare. If you need both, you can support both: check URL first, fall back to localStorage, then system preference.

# Q: How do I prevent the flash of wrong theme in Next.js?

A: The isLoaded flag helps, but for zero flash, you need to inject a script in the HTML <head> that runs before React hydrates:

typescript
// lib/theme-script.ts
export const themeScript = `
  (function() {
    const theme = localStorage.getItem('app-dark-mode-preference') || 'system';
    let isDark = false;
    
    if (theme === 'dark') {
      isDark = true;
    } else if (theme === 'system') {
      isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    }
    
    if (isDark) {
      document.documentElement.classList.add('dark');
    }
  })();
`;

Then in your Next.js layout:

typescript
<head>
  <script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>

# Q: What about syncing theme across browser tabs?

A: Use the storage event to listen to localStorage changes in other tabs:

typescript
useEffect(() => {
  const handleStorageChange = (e: StorageEvent) => {
    if (e.key === DARK_MODE_KEY && e.newValue) {
      setThemeMode(e.newValue as any);
    }
  };

  window.addEventListener('storage', handleStorageChange);
  return () => {
    window.removeEventListener('storage', handleStorageChange);
  };
}, []);

# Q: Can I use CSS color-scheme property instead of classes?

A: Yes! color-scheme is more semantic and affects form elements:

css
html {
  color-scheme: light;
}

html.dark {
  color-scheme: dark;
}

This automatically styles inputs, buttons, and other form elements appropriately for each theme.

# Q: What about high contrast mode users?

A: Respect prefers-contrast in addition to prefers-color-scheme:

typescript
const hasHighContrast = window.matchMedia('(prefers-contrast: more)').matches;

You can adjust your color palette accordingly to ensure sufficient contrast.

# Q: Does useIsMounted apply here? Should I check if mounted before setting theme?

A: No need. The theme detection happens in useEffect, which only runs on the client after mounting. Setting localStorage is safe because localStorage doesn't cause memory leak warnings like state updates do.



Questions? Share how you've implemented dark mode in your projects. Do you prefer system preference detection, user override, or something else? What edge cases have you encountered? Leave your thoughts in the comments!

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT