Skip to content

Dark mode

Color tokens as light-dark() pairs switched by color-scheme: follows the OS with no JS, and a manual toggle without a flash.

Dark mode lives in the tokens. Every color in lib/tokens.stylex.ts is a light-dark(light, dark) pair, and the browser picks a side from the CSS color-scheme of the page. There is no dark theme to apply and no per-component dark styling: set the scheme on <html> and every component follows. Scrollbars and native form controls follow it too.

TSX
// lib/tokens.stylex.ts
export const colors = stylex.defineVars({
  background: 'light-dark(oklch(1 0 0), oklch(0.145 0 0))',
  foreground: 'light-dark(oklch(0.145 0 0), oklch(0.985 0 0))',
  // ...
});

Follow the system

lib/themes.ts exports colorScheme, a style for <html>. By default it sets color-scheme: light dark, so the page follows the OS setting from the first paint, with no JavaScript:

TSX
// app/layout.tsx
import * as stylex from '@stylexjs/stylex';

import { colorScheme, page } from '@/lib/themes';

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

Put it on <html>, not a wrapper: dialogs, popovers, menus, and toasts portal to <body> and inherit the scheme from there. page gives <body> the background and text color from the tokens, so the page switches with the components. madeui init applies both; see page styles.

Add a manual toggle

colorScheme also reads a data-theme attribute on the same element: data-theme="dark" or data-theme="light" forces a mode, and no attribute means “follow the system”. A toggle only writes that attribute, so no class names change.

Restore a saved choice before the first paint with an inline script in <head>. Add suppressHydrationWarning to <html>, because the script sets an attribute the server did not render:

TSX
// app/layout.tsx
const restoreTheme = `try{var t=localStorage.getItem('theme');if(t==='light'||t==='dark')document.documentElement.dataset.theme=t}catch(e){}`;

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning {...stylex.props(colorScheme)}>
      <head>
        <script dangerouslySetInnerHTML={{ __html: restoreTheme }} />
      </head>
      <body {...stylex.props(page)}>{children}</body>
    </html>
  );
}

The toggle writes the attribute and the saved choice from its click handler. It reads the current mode with useSyncExternalStore, so it needs no effect and renders a placeholder on the server:

TSX
'use client';

import { useSyncExternalStore } from 'react';
import * as stylex from '@stylexjs/stylex';
import { Moon, Sun } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { icon } from '@/lib/stylex-utils';

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

function getMode(): Mode {
  const theme = document.documentElement.dataset.theme;
  return theme === 'light' || theme === 'dark' ? theme : 'system';
}

function subscribe(onChange: () => void) {
  const media = matchMedia('(prefers-color-scheme: dark)');
  // Another tab changed the saved mode: apply it here too.
  const onStorage = (e: StorageEvent) => {
    if (e.key !== 'theme') return;
    if (e.newValue === 'light' || e.newValue === 'dark') document.documentElement.dataset.theme = e.newValue;
    else delete document.documentElement.dataset.theme;
    onChange();
  };
  window.addEventListener('themechange', onChange);
  window.addEventListener('storage', onStorage);
  media.addEventListener('change', onChange);
  return () => {
    window.removeEventListener('themechange', onChange);
    window.removeEventListener('storage', onStorage);
    media.removeEventListener('change', onChange);
  };
}

function isDark() {
  const mode = getMode();
  return mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
}

function setMode(mode: Mode) {
  const html = document.documentElement;
  try {
    if (mode === 'system') localStorage.removeItem('theme');
    else localStorage.setItem('theme', mode);
  } catch {}
  if (mode === 'system') delete html.dataset.theme;
  else html.dataset.theme = mode;
  window.dispatchEvent(new Event('themechange'));
}

export function ThemeToggle() {
  // null on the server and during hydration, the real value right after.
  const dark = useSyncExternalStore(subscribe, isDark, () => null);

  return (
    <Button
      variant="ghost"
      size="icon"
      aria-label="Toggle theme"
      onClick={() => setMode(dark ? 'light' : 'dark')}
    >
      {dark === null ? null : dark ? (
        <Sun {...stylex.props(icon.md)} />
      ) : (
        <Moon {...stylex.props(icon.md)} />
      )}
    </Button>
  );
}

The button flips between light and dark. For a three-way menu, add an item that calls setMode('system') to clear the saved choice and follow the OS again.

In a Vite app or another single-page setup, put the same script in index.html. The colorScheme and page classes go on <html> and <body> from the entry module, which init sets up (see page styles).

With next-themes

next-themes works when it only writes the attribute: <ThemeProvider attribute="data-theme" enableColorScheme={false}>. Its own color-scheme handling sets an inline style, which the next section explains to avoid.

If your app already renders dynamically (an authenticated dashboard), you can store the choice in a cookie and render <html data-theme={…}> on the server, with no script. Reading cookies in the root layout makes every route dynamic, so don’t do this for a static site.

Keep color-scheme in CSS

Set color-scheme only through colorScheme (a stylesheet rule), never through an inline style. For browsers older than 2024, bundlers lower light-dark() into a fallback: Next.js does this with its default browser targets, and Vite does it through the StyleX plugin’s CSS step. That fallback reacts only to color-scheme set by stylesheet rules, so an inline style.colorScheme is ignored and the page stays light.

A section that is always dark takes colorScheme and data-theme="dark" on its own element (the attribute only applies where colorScheme is). The fallback resolves every color on <html>, so such a section only works when light-dark() stays native. In Next.js 16.2 or later, keep it native with:

TypeScript
// next.config.ts
const nextConfig = {
  experimental: { lightningCssFeatures: { exclude: ['light-dark'] } },
};

or target browsers that support light-dark() in your browserslist.

More themes

Brand themes are stylex.createTheme calls in lib/themes.ts. Their values can be light-dark() pairs too, so one theme covers both modes. See Customization.

Was this page helpful?