# Theming (/docs/theming)



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](/docs/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 [#quick-start]

## React server components (Next.js / Tanstack Start / Remix / ...) [#react-server-components-nextjs--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:

```tsx
// app/layout.tsx
import { ThemeScript } from "@42-central/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:

```tsx
"use client";
import { cn, useTheme } from "@42-central/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](/docs/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](#driving-a-dark-class-too) below.

## Client-only React SPA (Vite) [#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-central/vite-plugin-theme` (recommended). [#42-centralvite-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:

```npm
npm install -D @42-central/vite-plugin-theme
```

```ts
// vite.config.ts
import { defineConfig } from "vite";
import { themeInitPlugin } from "@42-central/vite-plugin-theme";

export default defineConfig({
  plugins: [themeInitPlugin()], // accepts the same options as buildThemeInitScript()
});
```

### The static asset [#the-static-asset]

Copied into `public/`. `@42-central/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):

```js
// scripts/copy-theme-init.mjs
import { copyFileSync } from "node:fs";
copyFileSync(
  import.meta.resolve("@42-central/ui-react/theme-init.js").replace("file://", ""),
  "public/theme-init.js",
);
```

```html
<!-- 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 [#manual-paste-as-a-last-resort]

(non-Vite bundlers, fully custom setups) — the literal output of `buildThemeInitScript()`:

```html
<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-central/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 [#api-reference]

### `useTheme(options?)` [#usethemeoptions]

<TypeTable
  type="{
  storageKey: {
    type: &#x22;string&#x22;,
    default: &#x22;'ui-theme'&#x22;,
    description:
      &#x22;The localStorage key. Must match <ThemeScript storageKey> / the pasted script.&#x22;,
  },
  defaultColorScheme: {
    type: &#x22;'light' | 'dark' | 'system'&#x22;,
    default: &#x22;'system'&#x22;,
    description: &#x22;Fallback used when nothing is stored yet.&#x22;,
  },
  forceColorScheme: {
    type: &#x22;'light' | 'dark'&#x22;,
    description:
      &#x22;Pins the applied theme and turns setMode into a no-op. See Forcing a color scheme below.&#x22;,
  },
  keepTransitions: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description:
      &#x22;Transitions are suppressed around every applied change unless this is true. See Disabling transitions below.&#x22;,
  },
  attribute: {
    type: &#x22;'data-theme' | 'class' | ('data-theme' | 'class')[]&#x22;,
    default: &#x22;'data-theme'&#x22;,
    description:
      &#x22;Which DOM mechanism(s) to keep in sync. See Driving a .dark class too below.&#x22;,
  },
}"
/>

Return value:

<TypeTable
  type="{
  mode: {
    type: &#x22;'light' | 'dark' | 'system'&#x22;,
    description:
      &#x22;The user's explicit, persisted choice — 'system' included as a real value.&#x22;,
  },
  resolvedTheme: {
    type: &#x22;'light' | 'dark'&#x22;,
    description:
      &#x22;The live, applied value — 'system' already resolved. Read this to swap an icon.&#x22;,
  },
  setMode: {
    type: &#x22;(mode: Mode | ((prev: Mode) => Mode)) => void&#x22;,
    description:
      &#x22;Persists the choice (cross-tab/same-tab synced) and applies data-theme immediately. Accepts a value or a useState-style updater.&#x22;,
  },
}"
/>

`setMode` also accepts a `useState`-style updater resolved against the current `mode` — handy for
cycling through all three values on a single button:

```tsx
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?)` [#themescript--buildthemeinitscriptoptions--themeinitpluginoptions]

<TypeTable
  type="{
  storageKey: {
    type: &#x22;string&#x22;,
    default: &#x22;'ui-theme'&#x22;,
  },
  defaultColorScheme: {
    type: &#x22;'light' | 'dark' | 'system'&#x22;,
    default: &#x22;'system'&#x22;,
  },
  forceColorScheme: {
    type: &#x22;'light' | 'dark'&#x22;,
  },
  attribute: {
    type: &#x22;'data-theme' | 'class' | ('data-theme' | 'class')[]&#x22;,
    default: &#x22;'data-theme'&#x22;,
  },
  nonce: {
    type: &#x22;string&#x22;,
    description: &#x22;Forwarded to the rendered <script nonce>.&#x22;,
  },
}"
/>

`themeInitPlugin` (from `@42-central/vite-plugin-theme`) and `ThemeScript` share this same option shape;
`buildThemeInitScript` is the underlying string generator both call.

## Forcing a color scheme [#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.

```tsx
// app/layout.tsx
<ThemeScript forceColorScheme="dark" />
```

```tsx
"use client";
const { resolvedTheme, setMode } = useTheme({ forceColorScheme: "dark" });
// resolvedTheme is always "dark"; setMode is a no-op while forced.
```

## Driving a `.dark` class too [#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-central/ui-react` drive that too,
instead of hand-rolling your own mirror script/`MutationObserver`:

```tsx
// app/layout.tsx
<ThemeScript attribute={["class", "data-theme"]} />
```

```tsx
"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 [#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:

```tsx
const { resolvedTheme, setMode } = useTheme({ keepTransitions: true });
```
