Light, dark and system modes with ThemeProvider and useTheme.
Every token has a light and a dark value, and xiod-ui/styles switches between them through Tailwind's dark: variant. Components use the tokens, so they need nothing else: once the page is in dark mode, everything is.
For a site that is always dark, put class="dark" on <html> and stop there.
To follow the system setting and let users choose, wrap your app in ThemeProvider from xiod-ui/theme-provider. It starts on "system", follows prefers-color-scheme, and remembers the user's choice in localStorage.
1import { ThemeProvider } from "xiod-ui/theme-provider";23export default function RootLayout({4 children,5}: {6 children: React.ReactNode;7}) {8 return (9 <html lang="en">10 <body>11 <ThemeProvider>{children}</ThemeProvider>12 </body>13 </html>14 );15}That is the whole setup. The saved mode and palette apply before the page first paints, server-rendered pages included, so there is no flash of light mode, no script of your own, and no suppressHydrationWarning on <html>.
useTheme returns the selected theme ("light", "dark" or "system"), the resolvedTheme actually applied (never "system"), and setTheme. While the mode changes, transitions are paused so colours switch at once instead of animating.
1"use client";23import { Moon, Sun } from "xiod-icons";4import { Button } from "xiod-ui/button";5import { useTheme } from "xiod-ui/theme-provider";67export function ThemeToggle() {8 const { resolvedTheme, setTheme } = useTheme();910 return (11 <Button12 variant="outline"13 size="icon"14 aria-label="Toggle dark mode"15 onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}16 >17 <Sun className="hidden dark:block" />18 <Moon className="dark:hidden" />19 </Button>20 );21}To offer "System" as a third choice, call setTheme("system").
The icon is chosen with dark: classes rather than from resolvedTheme, and that is deliberate. The server can't know which mode a visitor chose, so the HTML it sends is rendered with resolvedTheme at its default; the value is only right once React has hydrated. Styles follow the real mode from the first paint, so anything visual belongs in dark: classes. Read resolvedTheme in event handlers, or render what depends on it after mount.
A dark-mode visitor never sees the page in light mode, even before your JavaScript loads. On a server-rendered page, the provider includes a tiny inline script that reads the saved mode and palette while the browser is still parsing, before anything paints. It marks the choice on an empty <style> element at the top of <body>, which the stylesheet reads. <html> is left exactly as the server rendered it, so React has no mismatch to warn about.
Once React has hydrated and the browser is idle, the provider moves the mode onto <html> as the dark class and removes the marker.
Two things follow from this:
dark variant: dark: classes in markup, and @variant dark in CSS. Both follow the marker, so they apply from the first paint. A plain .dark { … } or html.dark … rule still works, but only from the moment React hydrates, so it can briefly show the light version. To change dark token values, see Customization.1.article-figure {2 @variant dark {3 filter: invert(1);4 }5}1import { headers } from "next/headers";23export default async function RootLayout({4 children,5}: {6 children: React.ReactNode;7}) {8 const nonce = (await headers()).get("x-nonce") ?? undefined;910 return (11 <html lang="en">12 <body>13 <ThemeProvider nonce={nonce}>{children}</ThemeProvider>14 </body>15 </html>16 );17}Upgrading from 1.1.0 or earlier
Delete any theme script you added to <head> to avoid the flash, and remove suppressHydrationWarning from <html> if nothing else needs it. The provider does both jobs now.
| Prop | Default | What it does |
|---|---|---|
defaultTheme | "system" | The mode before the user has chosen one. |
enableSystemTheme | true | Follow prefers-color-scheme while the mode is "system". |
attribute | "class" | How the mode is written to <html>. Any other value is set as an attribute. |
storageKey | "theme" | The localStorage key for the mode. |
defaultPalette | — | The palette before the user has chosen one. See Themes. |
paletteAttribute | "data-palette" | The attribute the palette is written to. |
paletteStorageKey | "palette" | The localStorage key for the palette. |
nonce | — | The nonce for the inline script, when a Content Security Policy requires one. |
Keep attribute as "class" unless you have a reason to change it: the dark: variant and the dark token values key off the dark class.