Functional BytesFunctional Bytes

Dark Mode Toggle in React


Quick Notes

  • Use global class to indicate when dark mode is enabled
  • For SPAs, the global class can be applied at the root component level
  • For NextJS, set the global class on the body in the _document.tsx
  • When using styled components, leverage the ThemeProvider
  • Set the default dark/light mode based on the prefers-color-scheme media query to honor user's preference
  • When the mode is changed, store the user's selection in localStorage

Examples

Set the initial Mode

// Constants for the dark mode CSS class and local storage key
export const DARK_MODE_CLASS = 'dark_mode';
export const DARK_MODE_STORAGE_KEY = 'dark_mode';
if (
  localStorage?.getItem(DARK_MODE_STORAGE_KEY}) ??
  window.matchMedia('(prefers-color-scheme: dark)').matches
) {
  document.body.classList.add(DARK_MODE_CLASS);
}

To ensure this happens as early as possible in NextJS:

File: _document.tsx

class CustomDocument extends Document {
  render() {
    return (
      <Html lang="en">
        <Head />
        <body>
          <Main />
          <NextScript />
          <Script id="dark_mode_init" strategy="afterInteractive">
            {`
            if (
              localStorage?.getItem('${DARK_MODE_STORAGE_KEY}') ??
              window.matchMedia('(prefers-color-scheme: dark)').matches
            ) {
              document.body.classList.add('${DARK_MODE_CLASS}');
            }
            `}
          </Script>
        </body>
      </Html>
    );
  }
}

Toggling the Mode

// Constants for the dark mode CSS class and local storage key
export const DARK_MODE_CLASS = 'dark_mode';
export const DARK_MODE_STORAGE_KEY = 'dark_mode';
export function toggleDarkMode() {
  if (document.body.classList.contains(DARK_MODE_CLASS)) {
    document.body.classList.remove(DARK_MODE_CLASS);
    localStorage?.setItem(DARK_MODE_STORAGE_KEY, '');
  } else {
    document.body.classList.add(DARK_MODE_CLASS);
    localStorage?.setItem(DARK_MODE_STORAGE_KEY, 'true');
  }
}

Hook Tracking the Mode in state using a MutationObserver

export default function useIsDarkMode() {
  const [isDarkMode, setIsDarkMode] = useState<boolean>(false);

  useEffect(() => {
    const observer = new MutationObserver(() => {
      setIsDarkMode(document.body.classList.contains(DARK_MODE_CLASS));
    });
    observer.observe(document.body, {
      attributes: true,
      attributeFilter: ['class'],
      characterData: false,
      childList: false,
      subtree: false,
    });

    // Set the initial value
    // For NextJS this has to be done within the effect since
    // it must be run client side - for SSR projects it can be done
    // in the state initialization
    setIsDarkMode(document.body.classList.contains(DARK_MODE_CLASS));

    return () => {
      observer.disconnect();
    };
  }, []);

  return isDarkMode;
}

Applying Styles Using CSS Variables

body {
  --background-color: #ffffff;
  --text-color: #000000;

  /**
   * The variables can be used anywhere below body
   * (even in components using css modules)
   */
  color: var(--text-color);
  background-color: var(--background-color);
}

body.dark_mode {
  --background-color: #000000;
  --text-color: #ffffff;
}

Applying Styles Directly in CSS/SCSS Modules

.my_component {
  // Light mode styles
  background-color: #ffffff;
  color: #000000;
}

:global(body.dark_mode) {
  .my_component {
    /** Dark mode styles */
    background-color: #ffffff;
    color: #000000;
  }
}

References