Dark mode

How the light and dark themes switch, the D hotkey, forcing a scheme, and native control colours.

Every colour token is defined twice: once on :root for light and once under .dark for dark. Switching the theme is only a matter of toggling that class on <html>, and RootProvider handles it for you.

How switching works#

RootProvider wraps a next-themes provider configured with:

  • attribute="class": the active theme is expressed as a dark class on <html>. The stylesheet also honours [data-theme="dark"], so a data-theme attribute works as an alternative.
  • enableSystem: "system" follows the operating system preference and switches automatically when it changes.
  • disableTransitionOnChange: CSS transitions are suspended for the moment of the switch so the whole page flips at once instead of fading piece by piece.
  • defaultTheme: taken from theme.defaultTheme in the config, "system" when unset.

The visitor's choice persists in local storage. The script that reads it runs before React hydrates, which is why <html> needs suppressHydrationWarning:

app/layout.tsx
import { RootProvider } from "pzzadocs/ui";
import "pzzadocs/styles.css";
import config from "@/docs.config";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <RootProvider theme={config.theme} search={config.search}>
          {children}
        </RootProvider>
      </body>
    </html>
  );
}

No flash on load

The theme class is applied before the first paint, and the token overrides from your config are emitted by ThemeStyles on the server. Both the scheme and your custom colours are correct on the very first frame.

Default theme#

docs.config.ts
theme: {
  defaultTheme: "dark",
}

"light", "dark" or "system" (the default). The default applies only until the visitor picks a theme; after that their stored choice wins on every visit.

The toggle and the D hotkey#

The header renders a ThemeToggle button that cycles light and dark. Pressing D does the same from anywhere on the page. The hotkey is ignored while the focus is in an input, textarea, select or editable element, and while the search dialog is open, so typing a d never flips the theme. Turn it off with:

docs.config.ts
theme: {
  hotKey: false,
}

Forcing a scheme#

To render part of the page in a fixed scheme regardless of the visitor's choice, put data-theme="dark" on an ancestor element. The stylesheet's dark selectors match .dark and [data-theme="dark"] alike, and so does the <style> emitted by ThemeStyles, so your colour overrides apply inside that subtree too:

tsx
<section data-theme="dark">
  {/* Always dark, with the same tokens the rest of the site uses in dark mode. */}
</section>

Note that there is no equivalent for forcing light inside a dark page: the light values are the :root defaults, so a subtree can only opt into dark.

Native controls#

color-scheme: light is set on html and color-scheme: dark on html.dark and html[data-theme="dark"]. Scrollbars, form controls, the search input and other browser-rendered UI follow the active scheme without extra styling.

Overriding dark tokens#

Dark values can be set from the config or from CSS. Both land under the same selector:

docs.config.ts
theme: {
  colors: {
    dark: { background: "#000000", sidebar: "#000000" },
  },
}
app/globals.css
.dark {
  --pd-background: #000000;
  --pd-sidebar: #000000;
}

See CSS overrides for how the two approaches interact and when to prefer each.