Vite Plugin
Optional: zero-flash before the bundle loads
The themvoraVitePlugin injects a synchronous bootstrap script into your index.html at build time so the persisted theme is applied on the very first frame — before the JavaScript bundle even loads.
1Configuration
Themvora's Vite plugin discovers theme.config.ts and injects the bootstrap — see the 7-step pipeline in Section 5.
import { ThemeProvider, useTheme } from "@themvora/react";
function Header() {
const { theme, toggleTheme } = useTheme();
return <button onClick={toggleTheme}>{theme.name}</button>;
}
export function App() {
return (
<ThemeProvider>
<Header />
</ThemeProvider>
);
}Declaring the same values twice is the bug this architecture removes:
themvoraVitePlugin({
themes,
initialMode: "system",
});
<ThemeProvider themes={themes} initialMode="system">
<App />
</ThemeProvider>Read the configuration from one place instead:
themvoraVitePlugin();
<ThemeProvider>
<App />
</ThemeProvider>See Architecture → Configuration for the full contract, including a table of which keys each consumer reads.
2Create theme.config.ts
Put it at the project root — the same level as vite.config.ts. themes can come from anywhere: a local file, a package, or the built-in set.
import { defineThemvoraConfig } from "@themvora/core";
import { themes } from "./src/themes";
export default defineThemvoraConfig({
themes,
defaultTheme: "mint-light",
initialMode: "system",
initialFamily: "mint",
});import { defineTheme } from "@themvora/core";
export const themes = [
defineTheme({
name: "mint-light",
meta: { family: "mint", mode: "light", label: "Mint Light", order: 10 },
tokens: { colors: { background: "#f8fafc", foreground: "#0f172a" } },
}),
defineTheme({
name: "mint-dark",
meta: { family: "mint", mode: "dark", label: "Mint Dark", order: 20 },
tokens: { colors: { background: "#0f172a", foreground: "#f8fafc" } },
}),
];| Key | What it decides |
|---|---|
| themes | The registry both the runtime and the pre-paint script resolve from. Required. |
| defaultTheme ? | The theme painted when nothing is persisted. Defaults to the first theme in the registry. |
| initialMode ? | The mode on a first visit. `system` follows the OS preference; omitting it keeps `defaultTheme`'s own mode. |
| initialFamily ? | The family on a first visit, when the registry groups themes into families. |
| storageKey ? | The localStorage key holding the selection. Both consumers read this one key. Defaults to `theme-selection`. |
| prefix ? | The CSS custom property prefix written by the bindings. Defaults to `theme-`. |
The full config key reference is on the Architecture page.
The bootstrap needs a registry, a fallback theme, an initial mode/family, a storage key and a prefix — nothing else can affect a frame painted before the runtime exists. Keys like transition, persistence, scrollbar, plugins and adapters are never serialized — pass them to the provider directly.
No file at all is also valid: the plugin and provider both fall back to built-in neutral themes.
3Register the Plugin
Your framework's own plugin is untouched — Themvora's is appended. Astro and Nuxt register their own integration instead; both discover the same theme.config.ts.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { themvoraVitePlugin } from "@themvora/core/vite";
export default defineConfig({
plugins: [react(), themvoraVitePlugin()],
});@themvora/core/vite, not the main entry point. This keeps the core bundle clean when you only need the runtime.examples/apps/react — theme.config.ts, vite.config.ts, the provider with no props, and the dev prerender. A real source tree, read from the repository.4Why a Vite Plugin?
Since @themvora/react (and Vue, Svelte, Solid) 1.2.0, the providers are flash-proof out of the box — they inject the blocking bootstrap themselves before first paint. The vite plugin is only needed if you want the theme applied on the absolute first frame before the bundle loads (relevant for slow bundles or network-sensitive previews).
- No runtime overhead — the script is injected at build time and does not ship as a separate chunk. It runs once and is never re-evaluated.
- SSR-compatible — the SSR framework packages (Next.js, Nuxt, Remix) emit this same bootstrap into the server-rendered HTML, so the client script and the server markup agree instead of fighting over the first frame.
- Tree-shakeable — only the theme definitions you pass in are included in the bootstrap output. No unused code reaches the client.
- The browser paints the default theme.
- The bundle loads and the app hydrates.
- The runtime reads the persisted selection and swaps.
- The visitor saw the wrong theme for that whole gap.
- The script is injected at the top of the head at build time.
- It runs synchronously, before anything is painted.
- It writes the theme and its variables onto the root element.
- The first paint is already the right theme.
5What It Does
The plugin flattens your themes into CSS-variable tables, generates a self-contained IIFE that resolves the theme before any other script runs, and injects it head-prepend with enforce: "pre". End to end:
- Fills in any element marked
data-tv-readout="theme" | "label" | "mode" | "family" | "var:<name>". The script owns that element's text and watches for readouts that appear later so a prerendered label is corrected before paint. - Reconciles values into elements marked
data-tv-pressed="mode" | "family"(asaria-pressed) and anydata-tv-select<select>.
theme.config.tsThe application's configuration, at the project root.themvoraVitePlugin()Discovers the file and projects it with `toBootstrapConfig` — the serializable keys, which is what the pre-paint script and the client runtime both read.buildBootstrapPlan()internalTurns the registry into the CSS-variable tables and the resolution plan.serializeThemeBootstrapScript()internalSerializes the plan into the self-contained IIFE.index.html <head>The script is injected head-prepend with `enforce: "pre"`, so it runs before any other head script.The browser resolves the themeThe script reads the persisted selection and resolves `system` against `prefers-color-scheme`.ThemeProvider initializesThe runtime starts from the same configuration, so it confirms what the script painted instead of correcting it.
The two steps marked internal are shared implementation detail, not API — they are absent from the public export surface. The public entry point for the same pipeline is createThemeBootstrapScript, for an app that manages its own <head>.
Vite runs in the build/dev pipeline; provider props are runtime application state. The plugin does not inspect React/Vue/Solid components to discover configuration — both the plugin and the provider read the same project-level theme.config.ts instead. Nothing is parsed, so nothing can be missed by a dynamic prop expression.
What ends up in the served HTML:
<!doctype html>
<html lang="en">
<head>
<!-- injected by themvoraVitePlugin() -->
<script id="themvora-bootstrap">
(function () {
// ... resolves mode from localStorage + prefers-color-scheme,
// writes CSS variables and data-theme attributes to <html>
})();
</script>
<title>My App</title>
</head>
<body>…</body>
</html>6Options
import { themvoraVitePlugin } from "@themvora/core/vite";
themvoraVitePlugin({
config: "./src/theme.config.ts",
scrollbar: true,
syncFirstRender: true,
ssrEntry: "/src/entry-server.tsx",
ssrContainer: "root",
});configOptional. Normally omitted: the plugin discovers theme.config.ts at the project root. Pass a path when the file lives elsewhere, or the config object itself. The object wins outright, and the deprecated inline theme options are ignored when it is present.scrollbarInjects the pre-paint scrollbar script so the native bar is hidden from the first frame. Defaults to false.syncFirstRenderCommits React's first render synchronously, so the first painted frame is the app rather than an empty root. Defaults to true; ignored when React is absent.ssrEntryModule exporting `render(): string`. Prerenders #root in the dev server, where unbundled modules would otherwise leave it empty for seconds. Defaults to "/src/entry-server.tsx"; pass null to opt out. Builds prerender separately.ssrContainerid of the element ssrEntry's markup is injected into. Defaults to "root".7The first render
React's concurrent scheduling means the first render is synchronous. The plugin's bootstrap covers the gap between the HTML and the first commit. See the React docs for details.
8SSR Integration
The Vite plugin only transforms index.html, which SSR frameworks never serve. Use their own package, which emits the same bootstrap into the server response.
import type { ReactNode } from "react";
import { ThemeProvider } from "@themvora/next";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<ThemeProvider defaultTheme="light">
{children}
</ThemeProvider>
);
}export default defineNuxtConfig({
modules: ["@themvora/nuxt"],
themvora: {
defaultTheme: "light",
initialMode: "system",
},
});import type { ReactNode } from "react";
import { ThemeHead, ThemeProvider } from "@themvora/remix";
export function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<ThemeHead mode="system" />
</head>
<body>
<ThemeProvider defaultTheme="light" initialMode="system">
{children}
</ThemeProvider>
</body>
</html>
);
}data-theme, data-theme-mode, data-theme-family, data-theme-selection-mode, data-theme-selection-family and data-theme-ready — so React finds nothing the script added that the server did not already declare. Its hooks also return the server's values for the hydration render, then switch to the live ones. If you hand-roll the script instead of using the framework package, declaring those attributes is yours to do.9Troubleshooting
Move them to theme.config.ts and call themvoraVitePlugin() with no theme options. The theme options on the plugin are a deprecated escape hatch — when a config is discovered they are ignored, so leaving them in place means editing values that no longer take effect.
Discovery looks for theme.config.ts (then .tsx, .mts, .mjs, .js, .cjs) in Vite's root — config.root, or process.cwd() when unset. If the file lives elsewhere, name it: themvoraVitePlugin({ config: "./src/theme.config.ts" }).
They should be reading the same object. Both the plugin's bootstrap and the provider's runtime come from the discovered theme.config.ts, so a disagreement means one of them was given an override — provider props win over the transported config. Drop the override and the source of truth is single again.
The discovered config is resolved once and memoized for the life of the Vite process, so `vite dev` keeps serving the bootstrap it derived on startup. Restart the dev server. (A production build re-derives it every time.)
Each one resolves its own root, so each discovers its own theme.config.ts. Two Vite configs sharing one root share one configuration; a config with an explicit root gets that root's file.
Two warnings mean discovery returned null — the bootstrap silently fell back to built-in themes rather than failing the build:
[themvora] could not find Vite to load /app/theme.config.ts. Pass `config` explicitly, or declare `vite` as a dependency.
[themvora] could not load /app/theme.config.ts: config must export or return an objectThe first means Vite could not be resolved — declare vite as a dependency, or pass the config object directly. The second means the file was found but Vite rejected its shape; discovery reads the default export.