Troubleshooting
Common issues and fixes
Run into something unexpected? This page covers the most common issues developers encounter when integrating Themvora 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.
import { createThemeBootstrapScript, getBuiltInThemes } from "@themvora/core";
export default function RootLayout({ children }) {
return (
<html
lang="en"
data-theme="mint-light"
data-theme-mode="light"
data-theme-family="mint"
data-theme-selection-mode="light"
data-theme-selection-family="mint"
data-theme-ready="true"
>
<head>
<script
dangerouslySetInnerHTML={{
__html: createThemeBootstrapScript({
themes: getBuiltInThemes(),
defaultTheme: "mint-light",
}),
}}
/>
</head>
<body>{children}</body>
</html>
);
};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.import { createThemeRuntime, createPersistencePlugin } from "@themvora/core";
const runtime = createThemeRuntime({
defaultTheme: "light",
plugins: [createPersistencePlugin({ key: "my-app-theme" })],
});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).import { ThemeProvider } from "@themvora/react";
export default function RootLayout({ children }) {
return (
<ThemeProvider
defaultTheme="light"
cssVariables={{ prefix: "theme-" }}
dom={{}}
>
{children}
</ThemeProvider>
);
}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. Two things keep them in agreement:
defaultTheme must match the server-side resolution, and the server must render every attribute the script writes onto <html> — data-theme, data-theme-mode, data-theme-family, data-theme-selection-mode, data-theme-selection-family and data-theme-ready. React reports an attribute that is in the DOM but absent from the rendered props as a mismatch, so leaving any of them out warns on every load in development.Tip
On the App Router you do not have to do either by hand:
ThemeProvider from @themvora/next renders <html> itself and renders every attribute the bootstrap script writes, so there is nothing for React to report. The snippet below is the manual @themvora/core +@themvora/react setup, where <html> is yours.// 1. Resolve the theme server-side the same way the runtime does
// 2. Pass it as defaultTheme to the provider
// 3. Render the attributes the bootstrap script writes, so React finds nothing
// the script added that the server did not already declare
import { createThemeBootstrapScript, getBuiltInThemes } from "@themvora/core";
import { ThemeProvider } from "@themvora/react";
const themes = getBuiltInThemes();
const resolvedTheme = themes.find((t) => t.name === "mint-light")!;
const family = resolvedTheme.meta?.family;
export default function RootLayout({ children }) {
return (
<html
lang="en"
data-theme={resolvedTheme.name}
data-theme-mode={resolvedTheme.meta?.mode ?? "light"}
{...(family ? { "data-theme-family": family } : {})}
data-theme-selection-mode="light"
{...(family ? { "data-theme-selection-family": family } : {})}
data-theme-ready="true"
>
<head>
<script
dangerouslySetInnerHTML={{
__html: createThemeBootstrapScript({
themes: getBuiltInThemes(),
defaultTheme: resolvedTheme,
}),
}}
/>
</head>
<body>
<ThemeProvider defaultTheme={resolvedTheme}>
{children}
</ThemeProvider>
</body>
</html>
);
}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.
// Ensure the ThemeProvider is ABOVE the router so the runtime
// survives client-side navigation without re-mounting.
import { ThemeProvider } from "@themvora/react";
import { createThemeBootstrapScript, getBuiltInThemes } from "@themvora/core";
export default function RootLayout({ children }) {
return (
<html
lang="en"
data-theme="light"
data-theme-mode="light"
data-theme-selection-mode="light"
data-theme-ready="true"
>
<head>
<script
dangerouslySetInnerHTML={{
__html: createThemeBootstrapScript({
themes: getBuiltInThemes(),
defaultTheme: "light",
}),
}}
/>
</head>
<body>
<ThemeProvider defaultTheme="light">
{/* AppRouter / layout from next/navigation lives here */}
{children}
</ThemeProvider>
</body>
</html>
);
}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.import { ThemeScope } from "@themvora/react";
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<ThemeScope theme="corporate-light">
{/* Only this subtree uses the scoped theme */}
<WidgetPanel />
<AnalyticsCard />
</ThemeScope>
</div>
);
}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.const runtime = createThemeRuntime({
defaultTheme: "light",
transition: {
enabled: true,
preset: "smooth",
duration: 360,
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
},
});8Build Error: Module Not Found
Your bundler throws a Module Not Found error for @themvora packages.
This typically happens when
@themvora/core and the framework integration (e.g. @themvora/react) are on different versions. The packages share internal modules and must be the exact same version. Reinstall both packages at the latest version.# Ensure @themvora/core and the framework integration share
# the exact same version — mismatched versions cause module
# resolution failures.
npm install @themvora/core@latest @themvora/react@latest
# Or with pnpm
pnpm add @themvora/core@latest @themvora/react@latest9Accessibility 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 @themvora/core to audit every token pair at build time and catch failures before they reach production.import { validateThemeContrast } from "@themvora/core";
// validateThemeContrast checks one theme on its own; pass a themes option
// (e.g. getBuiltInThemes()) to resolve `extends` chains first.
const result = validateThemeContrast(theme);
if (!result.valid) {
console.error("Failing token pairs (AA large text):");
result.checks
.filter((check) => !check.passesAALarge)
.forEach((check) => {
console.error(
` ${check.foregroundToken} on ${check.backgroundToken}:`,
);
console.error(` ratio ${check.ratio.toFixed(2)}:1`);
});
process.exit(1);
}10Multi-Window Out of Sync
Changing the theme in one tab does not update other open tabs.
Themvora 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.import { createThemeRuntime, createMultiWindowSync } from "@themvora/core";
const runtime = createThemeRuntime({
defaultTheme: "light",
// BroadcastChannel is used by default. If it is blocked or unavailable
// the sync falls back to a SharedWorker, then to window "storage" events
// (works in all browsers that support localStorage). Prefer "sharedworker"
// to start from the worker strategy:
broadcast: createMultiWindowSync({ prefer: "auto" }),
});