Theming
Persist a light/dark/system choice and keep data-theme in sync — for Next.js (RSC) and Vite SPAs alike.
This page covers the runtime theme manager — persisting a user's choice and switching
data-theme on <html> at runtime. The CSS contract itself (data-theme, data-color, and how
components resolve their colors from it) is documented on Colors.
useTheme() persists an explicit "light" | "dark" | "system" choice and keeps data-theme in
sync with it — resolving "system" against the OS preference and following it live if it changes
while the app is open. Avoiding a flash of the wrong theme needs a script that runs before
your app's JS does; how you deliver that differs by framework.
Quick start
React server components (Next.js / Tanstack Start / Remix / ...)
Render ThemeScript from a Server Component, as early as possible (the first child of <body>),
and mark the root <html> suppressHydrationWarning — the script sets data-theme before React
hydrates it:
// app/layout.tsx
import { ThemeScript } from "@42/ui-react/theme-script";
export default function Layout({ children }: LayoutProps<"/">) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeScript />
{children}
</body>
</html>
);
}Then build a toggle from useTheme() wherever you need one. This is the actual component this docs
site uses (apps/docs/components/theme-toggle.tsx) — a light/dark pill matching the RTL switch
beside it:
"use client";
import { cn, useTheme } from "@42/ui-react";
import { Moon, Sun } from "lucide-react";
export function ThemeToggle({ className }: { className?: string }) {
const { resolvedTheme, setMode } = useTheme({ attribute: ["class", "data-theme"] });
const next = resolvedTheme === "dark" ? "light" : "dark";
return (
<button
type="button"
onClick={() => setMode(next)}
aria-label={`Switch to ${next} mode`}
className={cn(
"inline-flex items-center overflow-hidden rounded-full border p-1",
className,
)}
>
<Sun className="dark:opacity-40" />
<Moon className="opacity-40 dark:opacity-100" />
</button>
);
}Which icon is visible is decided by Tailwind's dark: variant — keyed off the same data-theme
attribute the blocking anti-FOUC script sets before first paint (see Colors for how
dark: is wired) — not by resolvedTheme in JSX. A server can't know the visitor's real
preference, so anything rendered from resolvedTheme is necessarily wrong in the raw
server-rendered HTML, before any JS/hydration runs at all — a flash no hook-timing fix can close,
since it happens before React even mounts. Tying the highlight to the DOM attribute directly means
the browser's own CSS engine paints it correctly from the very first frame, same as the page
background already does. resolvedTheme is still read above, but only to decide what clicking
switches to — never to decide what gets rendered.
The attribute: ["class", "data-theme"] above isn't needed for the icon itself (that's still
data-theme-driven, like the rest of this page) — this docs site specifically also needs the .dark
class kept in sync for Fumadocs' own chrome (search dialog, sidebar), which reads a literal .dark
class rather than data-theme. See Driving a .dark class too below.
Client-only React SPA (Vite)
There's no server render step, so a React ThemeScript component can't help — by the time React
could insert a <script>, first paint has already happened. The script needs to land in
index.html itself, genuinely blocking (not type="module", which Vite treats as deferred). Three
ways to get it there, in order of preference:
@42/vite-plugin-theme (recommended).
Injects the script into index.html automatically, at
both vite dev and vite build time — no manual HTML editing, correct in both dev and production:
// vite.config.ts
import { defineConfig } from "vite";
import { themeInitPlugin } from "@42/vite-plugin-theme";
export default defineConfig({
plugins: [themeInitPlugin()], // accepts the same options as buildThemeInitScript()
});The static asset
Copied into public/. @42/ui-react/theme-init.js is a prebuilt,
dependency-free script. A hardcoded <script src="/node_modules/..."> only works in vite dev —
Vite's dev server serves node_modules directly, but a production build doesn't copy or resolve raw
node_modules paths, so that tag would 404 after a real build. Copy the file into your own
public/ directory instead (Vite serves public/ verbatim in both dev and prod):
// scripts/copy-theme-init.mjs
import { copyFileSync } from "node:fs";
copyFileSync(
import.meta.resolve("@42/ui-react/theme-init.js").replace("file://", ""),
"public/theme-init.js",
);<!-- index.html -->
<script src="/theme-init.js" data-default-color-scheme="dark"></script>It's configured via data-* attributes on its own tag (no build step needed once copied):
| Attribute | Matches useTheme() option |
|---|---|
data-storage-key | storageKey |
data-default-color-scheme | defaultColorScheme |
data-force-color-scheme | forceColorScheme |
data-attribute | attribute — comma-separated, e.g. "class,data-theme" |
Manual paste, as a last resort
(non-Vite bundlers, fully custom setups) — the literal output of buildThemeInitScript():
<script>
(function () {
try {
var k = "ui-theme",
m = ["light", "dark", "system"];
var s = localStorage.getItem(k),
v = null;
if (s) {
try {
v = JSON.parse(s);
} catch (e) {
v = null;
}
}
if (m.indexOf(v) === -1) v = "system";
var d =
v === "system"
? window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: v;
document.documentElement.setAttribute("data-theme", d);
} catch (e) {}
})();
</script>Regenerate this block (import { buildThemeInitScript } from '@42/ui-react') whenever you upgrade
the package, in case its internals change.
useTheme() works identically after any of these — same hook, no Vite-specific API.
API reference
useTheme(options?)
Prop
Type
Return value:
Prop
Type
setMode also accepts a useState-style updater resolved against the current mode — handy for
cycling through all three values on a single button:
const NEXT = { light: "dark", dark: "system", system: "light" } as const;
<button onClick={() => setMode((mode) => NEXT[mode])}>{mode}</button>;The updater runs once, synchronously, against mode at call time — it isn't queued/batched the way
React's own setState updater can be. It's also never called while forceColorScheme is set,
matching setMode's regular no-op behavior there.
ThemeScript / buildThemeInitScript(options?) / themeInitPlugin(options?)
Prop
Type
themeInitPlugin (from @42/vite-plugin-theme) and ThemeScript share this same option shape;
buildThemeInitScript is the underlying string generator both call.
Forcing a color scheme
forceColorScheme pins mode/resolvedTheme to a fixed value and turns setMode into a no-op —
useful for a page that must render one scheme regardless of the visitor's preference (a themed
marketing page, an embed, a screenshot/preview route).
There's no provider holding this configuration in one place, so it must be passed identically to
every useTheme() call site and <ThemeScript> (or the pasted Vite script) — otherwise the
pre-hydration script and the hook can disagree on first load. This isn't a gap from the kit lacking
a provider: Mantine, which has one (MantineProvider), documents the exact same requirement for its
own forceColorScheme — it must be set on both the provider and ColorSchemeScript, since the
script always runs before any provider exists.
// app/layout.tsx
<ThemeScript forceColorScheme="dark" />"use client";
const { resolvedTheme, setMode } = useTheme({ forceColorScheme: "dark" });
// resolvedTheme is always "dark"; setMode is a no-op while forced.Driving a .dark class too
data-theme is the kit's own contract, but other CSS you don't control might key off a literal
.dark class instead — Tailwind's default dark strategy, another component library, or (as on
this very docs site) Fumadocs' bundled styles, which read .dark rather than data-theme. Pass
attribute (mirroring next-themes' option of the same name) to have @42/ui-react drive that too,
instead of hand-rolling your own mirror script/MutationObserver:
// app/layout.tsx
<ThemeScript attribute={["class", "data-theme"]} />"use client";
const { resolvedTheme, setMode } = useTheme({ attribute: ["class", "data-theme"] });Like storageKey/defaultColorScheme/forceColorScheme, attribute must be passed identically to
both <ThemeScript> (or the pasted Vite script) and every useTheme() call site — there's no
provider holding this configuration in one place. attribute: "class" alone (no "data-theme") is
supported too, for pages that genuinely don't need the kit's own contract — resolvedTheme reads the
.dark class directly in that case.
Disabling transitions
If any of your CSS transitions a color/background/border property, switching themes makes it
visibly animate through the swap — so transitions are suppressed by default for one frame
around every applied change (clicking a toggle, the OS preference flipping while in "system"
mode, and regaining focus on a tab where the theme changed elsewhere). Pass keepTransitions to
opt back into normal transition behavior:
const { resolvedTheme, setMode } = useTheme({ keepTransitions: true });