Zero Flash
@themvora/core — bootstrap & SSR-safe hydration
Themvora prevents flash of incorrect theme on supported SSR frameworks and client-side applications. The server resolves the persisted selection, inlines the CSS variables, and a blocking bootstrap script covers the rest, so the very first paint is already the user's theme.
Zero-flash behavior works as documented for supported SSR frameworks (Next.js, Nuxt, Astro, Remix) and client providers with the blocking bootstrap script. Some edge cases exist around system mode with JavaScript disabled and framework-specific SSR constraints. See Known Limitations for complete details.
1The problem
2How it works
3Zero-flash client bootstrap
defaultTheme picks which theme you start on. initialMode is the opt-in for following the OS, and it must reach both the pre-paint script and the client runtime.
The script and runtime resolve the mode independently. Tell only one and they disagree — measured at 1.5 s of wrong theme on a CPU-throttled Angular build before the runtime corrected it.
Astro: the two halves, kept apart
Astro has no root component to own the first commit, so the bootstrap there is not a runtime at all — it is a tiny synchronous script in <head>. The runtime is created later, when the island mounts. Keeping those two halves apart is the whole design: nothing on the pre-paint path imports a framework, allocates a store, or attaches a listener.
For "system", CSS is the first line of defense — not the script
A server cannot know the visitor's OS preference, so it must not commit to one. An inline style on <html> outranks every stylesheet rule regardless of specificity, so a dark @media (prefers-color-scheme: dark) block would be inert. The fix is to emit both schemes as media blocks and keep only color-scheme: light dark inline.
The builder lives in @themvora/core, and @themvora/astro re-exports it. That is deliberate: the integration, provider.astro and Angular's createBlockingScriptContent all need the same rule, and a private copy in each would only ever disagree in one place — as a wrong first paint on one surface, which is the hardest kind of flash to attribute.
4The core primitives
import {
createThemeBootstrapScript,
buildThemeCssMap,
resolveInitialTheme,
systemModeCSSTemplate,
getBuiltInThemes,
} from "@themvora/core";
// The built-in set — the neutral light/dark pair plus the preset families — is
// a valid registry, so no theme file is needed.
const themes = getBuiltInThemes();
const cssMap = buildThemeCssMap(themes, { prefix: "theme-" });
// 1) The blocking <script> for <head>. It reads the persisted selection,
// resolves family + effective mode ("system" → prefers-color-scheme), and
// writes the CSS variables and data-theme attributes before first paint.
const bootstrapScript = createThemeBootstrapScript({
themes,
defaultTheme: "light",
initialMode: "system",
storageKey: "theme-selection",
});
// 2) Resolve the selection the way the client runtime will, so the server and
// the script agree on the first frame.
const { theme, selection } = resolveInitialTheme({
themes,
defaultTheme: "light",
mode: "system",
});
// 3) A concrete mode means the server already knows the answer: inline that
// theme's variables on <html>. `cssMap` is keyed by theme name, so this
// works for the neutral pair (light/dark) and for families alike.
const resolvedVars = selection.mode === "system" ? null : cssMap[theme.name];
// 4) "system" is the case the server cannot answer, so it must not guess.
// Inlining the light variables and adding a dark media block does NOT work:
// an inline `style` on <html> outranks every stylesheet rule, so the dark
// block never applies. Emit BOTH schemes as media blocks instead, and keep
// only `color-scheme: light dark` inline.
const lightTheme = resolveInitialTheme({ themes, defaultTheme: "light", mode: "light" }).theme;
const darkTheme = resolveInitialTheme({ themes, defaultTheme: "light", mode: "dark" }).theme;
const systemFallback =
selection.mode === "system"
? systemModeCSSTemplate(cssMap[lightTheme.name], cssMap[darkTheme.name])
: null;
5Every framework
The React, Vue, Svelte and Solid providers now inject the blocking bootstrap themselves — you just wrap your app in the provider and the persisted theme is applied before first paint. The core createThemeBootstrapScript and the Vite plugin remain available for custom heads and the pre-bundle first frame.
// Flash-proof out of the box. The provider reads the persisted selection,
// applies it before first paint, and injects a blocking bootstrap <script>
// into <head> — no vite plugin or manual index.html script required.
import { createRoot } from "react-dom/client";
import { ThemeProvider } from "@themvora/react";
// Your application root. The provider owns the runtime; App only renders UI.
function App() {
return <main>Your app</main>;
}
const root = createRoot(document.getElementById("root")!);
root.render(
<ThemeProvider defaultTheme="light" initialMode="system">
<App />
</ThemeProvider>,
);6What's next
7API reference
The full surface, generated from source JSDoc: