---
title: Dark mode
description: "Color tokens as light-dark() pairs switched by color-scheme: follows the OS with no JS, and a manual toggle without a flash."
sidebar:
  order: 3
---

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](/docs/installation#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](/docs/installation#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.

### With a cookie

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:

```ts
// 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](/docs/customization#themes).
