|Kit

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:

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):

AttributeMatches useTheme() option
data-storage-keystorageKey
data-default-color-schemedefaultColorScheme
data-force-color-schemeforceColorScheme
data-attributeattribute — 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 });

On this page