Skip to content
alert-triangle

Troubleshooting

Common issues and fixes

Run into something unexpected? This page covers the most common issues developers encounter when integrating Theme Kit and how to resolve them quickly.

1Flash of Unstyled Content (FOUC)

A flash of unstyled or incorrectly-styled content before the theme loads.
The bootstrap script must run before the browser paints any content. If it is placed in the body or loaded asynchronously, the first paint uses the browser default and then snaps to the correct theme — causing a visible flash.
layout.tsx — bootstrap script in <head>
tsx
1import { createThemeBootstrapScript } from "@theme-kit/core";
2import { themes } from "./themes";
3
4export default function RootLayout({ children }) {
5  return (
6    <html lang="en" suppressHydrationWarning>
7      <head>
8        <script
9          dangerouslySetInnerHTML={{
10            __html: createThemeBootstrapScript({
11              themes,
12              defaultTheme: "mint-light",
13            }),
14          }}
15        />
16      </head>
17      <body>{children}</body>
18    </html>
19  );
20};

2Theme Not Persisting

The selected theme resets after a page reload or closes and reopens.
Theme persistence is provided by a persistence adapter. The default adapter uses localStorage under the key theme-selection — if another script writes to the same key, or if the user is in incognito mode with restricted storage, the theme will not persist. Create an explicit adapter with createThemePersistence() and a custom key, and verify that localStorage is accessible.
runtime — persistence plugin with a custom key
ts
1import { createThemeRuntime, createPersistencePlugin } from "@theme-kit/core";
2
3const runtime = createThemeRuntime({
4  themes,
5  defaultTheme: "light",
6  plugins: [createPersistencePlugin({ key: "my-app-theme" })],
7});

3CSS Variables Not Updating

CSS custom properties on <html> or <body> are not reflecting the active theme.
CSS variable injection is handled by the cssVariables binding. If it is set to false (or omitted), the runtime will not write --theme-* variables to the DOM. Ensure the provider is mounted and the binding is configured (it accepts a prefix and a target element).
provider — cssVariables binding enabled
tsx
1import { ThemeProvider } from "@theme-kit/react";
2
3export default function RootLayout({ children }) {
4  return (
5    <ThemeProvider
6      themes={themes}
7      defaultTheme="light"
8      cssVariables={{ prefix: "theme-" }}
9      dom={{}}
10    >
11      {children}
12    </ThemeProvider>
13  );
14}

4Hydration Mismatch

Next.js or React reports a hydration mismatch warning in the console.
During server rendering the theme is resolved from cookies, headers, or the default. On the client the bootstrap script resolves it independently. If these two resolutions disagree, React detects a mismatch. Ensure defaultTheme matches the server-side resolution and add suppressHydrationWarning to <html>.
layout.tsx — matching server & client themes
tsx
1// 1. Resolve the theme server-side the same way the runtime does
2// 2. Pass it as defaultTheme to the provider
3// 3. suppressHydrationWarning on <html> handles the attribute diff
4import { createThemeBootstrapScript } from "@theme-kit/core";
5import { ThemeProvider } from "@theme-kit/react";
6import { themes } from "./themes";
7
8export default function RootLayout({ children }) {
9  return (
10    <html lang="en" suppressHydrationWarning>
11      <head>
12        <script
13          dangerouslySetInnerHTML={{
14            __html: createThemeBootstrapScript({
15              themes,
16              defaultTheme: resolvedTheme,
17            }),
18          }}
19        />
20      </head>
21      <body>
22        <ThemeProvider themes={themes} defaultTheme={resolvedTheme}>
23          {children}
24        </ThemeProvider>
25      </body>
26    </html>
27  );
28}

5Theme Flicker on Navigation

The theme briefly flashes to the default on client-side route changes.
In SPA frameworks the provider must wrap the router. If the provider is inside a route or a layout that unmounts during navigation, the runtime is destroyed and re-created — causing a brief flicker as it re-reads from storage. Move the provider above the router so the runtime persists across all route changes.
layout.tsx — provider wraps the router
tsx
1// Ensure the ThemeProvider is ABOVE the router so the runtime
2// survives client-side navigation without re-mounting.
3import { createThemeBootstrapScript } from "@theme-kit/core";
4import { ThemeProvider } from "@theme-kit/react";
5import { themes } from "./themes";
6
7export default function RootLayout({ children }) {
8  return (
9    <html lang="en" suppressHydrationWarning>
10      <head>
11        <script
12          dangerouslySetInnerHTML={{
13            __html: createThemeBootstrapScript({
14              themes,
15              defaultTheme: "light",
16            }),
17          }}
18        />
19      </head>
20      <body>
21        <ThemeProvider themes={themes} defaultTheme="light">
22          {/* AppRouter / layout from next/navigation lives here */}
23          {children}
24        </ThemeProvider>
25      </body>
26    </html>
27  );
28}

6Scoped Theme Not Working

A ThemeScope has no visible effect on the targeted subtree.
ThemeScope must directly wrap the subtree it should affect. If the scope is a sibling or ancestor at the wrong level, the scoped tokens will not reach the target components. Pass the scope as a theme name, a family, or a { family, mode } object — the themes prop is for local definitions, not theme names. Verify the wrapping hierarchy.
page.tsx — wrapping a subtree with ThemeScope
tsx
1import { ThemeScope } from "@theme-kit/react";
2
3export default function DashboardPage() {
4  return (
5    <div>
6      <h1>Dashboard</h1>
7      <ThemeScope theme="corporate-light">
8        {/* Only this subtree uses the scoped theme */}
9        <WidgetPanel />
10        <AnalyticsCard />
11      </ThemeScope>
12    </div>
13  );
14}

7Transitions Not Animating

Theme switches are instant instead of animated.
Transitions are opt-in. If transition.enabled is false (the default), every theme switch is instant. Set enabled: true and choose a preset to enable animated transitions.
runtime — enabling transitions
ts
1const runtime = createThemeRuntime({
2  themes,
3  defaultTheme: "light",
4  transition: {
5    enabled: true,
6    preset: "smooth",
7    duration: 360,
8    easing: "cubic-bezier(0.4, 0, 0.2, 1)",
9  },
10});

8Build Error: Module Not Found

Your bundler throws a Module Not Found error for @theme-kit packages.
This typically happens when @theme-kit/core and the framework integration (e.g. @theme-kit/react) are on different versions. The packages share internal modules and must be the exact same version. Reinstall both packages at the latest version.
terminal — pin matching versions
bash
1# Ensure @theme-kit/core and the framework integration share
2# the exact same version — mismatched versions cause module
3# resolution failures.
4
5npm install @theme-kit/core@latest @theme-kit/react@latest
6
7# Or with pnpm
8pnpm add @theme-kit/core@latest @theme-kit/react@latest

9Accessibility Audit Failing

Automated accessibility audits flag color contrast failures in your theme.
Theme tokens must meet WCAG contrast ratios for the text sizes where they are used. Use validateThemeContrast() from @theme-kit/core to audit every token pair at build time and catch failures before they reach production.
audit — validate contrast at build time
ts
1import { validateThemeContrast } from "@theme-kit/core";
2
3// validateThemeContrast checks one theme against its registry
4const result = validateThemeContrast(theme, { themes });
5
6if (!result.valid) {
7  console.error("Failing token pairs (AA large text):");
8  result.checks
9    .filter((check) => !check.passesAALarge)
10    .forEach((check) => {
11      console.error(
12        `  ${check.foregroundToken} on ${check.backgroundToken}:`,
13      );
14      console.error(`    ratio ${check.ratio.toFixed(2)}:1`);
15    });
16  process.exit(1);
17}

10Multi-Window Out of Sync

Changing the theme in one tab does not update other open tabs.
Theme Kit uses BroadcastChannel to sync theme changes across tabs, and falls back to a SharedWorker and then to window storage events when it is unavailable (e.g. incognito, third-party iframes). Pass an explicit broadcast adapter from createMultiWindowSync() to control the strategy.
runtime — explicit multi-window sync
ts
1import { createThemeRuntime, createMultiWindowSync } from "@theme-kit/core";
2
3const runtime = createThemeRuntime({
4  themes,
5  defaultTheme: "light",
6  // BroadcastChannel is used by default. If it is blocked or unavailable
7  // the sync falls back to a SharedWorker, then to window "storage" events
8  // (works in all browsers that support localStorage). Prefer "sharedworker"
9  // to start from the worker strategy:
10  broadcast: createMultiWindowSync({ prefer: "auto" }),
11});
Troubleshooting — Theme Kit