Architecture
@themvora/core — runtime, layers & data flow
Themvora's heart is a single framework-agnostic runtime that wires the store, registry, selection, persistence, broadcast, DOM bindings, history, lifecycle, and plugins together into one cohesive system.
1One Runtime
The heart of the library is a single framework-agnostic runtime that wires the store, registry, selection, persistence, broadcast, DOM bindings, history, lifecycle, and plugins together.
import { createThemeRuntime } from "@themvora/core";
const runtime = createThemeRuntime({
themes, // default: built-in themes
defaultTheme, // fallback theme name
initialMode, // "light" | "dark" | "system"
initialFamily, // e.g. "plum"
persistence, // localStorage adapter by default
broadcast, // BroadcastChannel adapter by default
dom, // DOM attribute binding options (false to disable)
cssVariables, // CSS variable binding options (false to disable)
transition, // smooth transitions
scheduled, // sunset/sunrise auto switching
plugins, // lifecycle + token transform plugins
});Once created, the runtime immediately:
- Resolves the initial theme from
defaultTheme/initialMode/initialFamily(or persisted selection). - Registers every theme in the registry.
- Wires the store, selection controller, persistence, and broadcast.
- Applies DOM attributes and CSS variables via the bindings.
2Runtime API
`runtime.store`
The reactive store holding the active theme. get() reads it, set() replaces it, subscribe() reacts to changes, and batch() coalesces multiple writes into a single notification.
const theme = runtime.store.get(); // current theme
const unsubscribe = runtime.store.subscribe((t) => {
console.log("theme changed:", t.name);
});
runtime.store.set(myTheme); // apply immediately
runtime.store.batch(() => { // coalesce several writes
runtime.store.set(a);
runtime.store.set(b);
});
unsubscribe();`runtime.registry`
Every registered theme, powering dynamic theming. Register, unregister, replace, look up, and group themes by family.
runtime.registry.register(myTheme); // add a theme
runtime.registry.registerMany([a, b, c]); // add several
runtime.registry.unregister("plum-dark"); // remove by name
runtime.registry.replace("plum-dark", newDark); // swap in place
runtime.registry.get("plum-light"); // find by name
runtime.registry.has("plum-light"); // boolean
runtime.registry.list(); // all themes
runtime.registry.getFamilies(); // ["default", "plum", ...]
runtime.registry.getThemesByFamily("plum"); // plums only`runtime.selection`
Mode + family resolution. Handles persistence and broadcast for you, and keeps the store in sync.
runtime.selection.setMode("dark"); // "light" | "dark" | "system"
runtime.selection.setFamily("mint"); // switch palette
runtime.selection.toggleTheme(); // flip light <-> dark
runtime.selection.getSelection();
// { mode: "dark", family: "mint" }`runtime.themes`
A read-only list of all registered themes — handy for rendering pickers, galleries, or walking families.
for (const theme of runtime.themes) {
console.log(theme.name, theme.meta?.family, theme.meta?.mode);
}
const plums = runtime.themes.filter(
(t) => t.meta?.family === "plum",
);`runtime.update(tokens)`
Live theme editing: merge partial tokens into the active theme and re-apply. The perfect primitive for theme studios and design-time tweaking.
runtime.update({
colors: {
primary: "#6366f1",
accent: { hover: "#4f46e5" },
},
radius: { lg: "16px" },
});`runtime.use(pack)`
Install a theme pack at runtime. A pack is a named bundle of themes; every theme in it is stamped with a pack:<name> tag.
runtime.use({
name: "brand",
themes: [
{ name: "apple-light", meta: { family: "apple", mode: "light" }, tokens },
{ name: "apple-dark", meta: { family: "apple", mode: "dark" }, tokens },
],
});`runtime.batch(cb)`
Run a callback atomically — intermediate state changes are suppressed until the callback finishes.
runtime.batch(() => {
runtime.update({ colors: { primary: "#000" } });
runtime.selection.setMode("dark");
runtime.selection.setFamily("plum");
});
// subscribers fire exactly once, with the final theme`runtime.snapshot()` / `runtime.restore(snapshot)`
Serialize the full runtime state — theme, selection, history, and registry — and restore it later. Ideal for time travel and demo replay.
const snapshot = runtime.snapshot();
// { theme, selection, history: [], registry: { themes } }
// ... make changes ...
runtime.restore(snapshot); // back to exactly how it was`runtime.history`
Built-in undo/redo, capped at 50 steps by default. Records full theme snapshots with timestamps.
runtime.history.undo(); // step back
runtime.history.redo(); // step forward
runtime.history.jump(2); // jump to any point in time
runtime.history.canUndo(); // boolean
runtime.history.canRedo(); // boolean
runtime.history.getHistory(); // HistoryEntry[] { theme, timestamp }
runtime.history.clear(); // wipe the timeline`runtime.lifecycle`
A typed event bus for the theme pipeline. Subscribe with on() (returns an unsubscribe function) and react to typed payloads.
const off = runtime.lifecycle.on("beforeThemeChange", ({ current, next }) => {
console.log(`leaving ${current.name}, entering ${next.name}`);
});
runtime.lifecycle.on("afterApply", ({ theme }) => {
document.title = `Theme — ${theme.name}`;
});
off(); // unsubscribe`runtime.destroy()`
Full teardown: unsubscribes every listener, removes DOM/CSS bindings, closes the broadcast channel, and clears registry, history, and lifecycle.
runtime.destroy();3Configuration
The application's Themvora configuration is a single object at the project root, theme.config.ts, declared through defineThemvoraConfig. It is consumed by two things, and neither of them is a second declaration: the runtime (through your framework provider) and the pre-paint bootstrap (through a build integration such as the Vite plugin or the Astro integration).
// theme.config.ts — at the project root
import { defineThemvoraConfig } from "@themvora/core";
import { themes } from "./src/themes";
export default defineThemvoraConfig({
themes,
defaultTheme: "mint-light",
initialMode: "system",
initialFamily: "mint",
});Both consumers read that file. The provider takes no theme props, because the build integration has already put the configuration in the page for it:
// main.tsx — the runtime, through your framework provider
import { createRoot } from "react-dom/client";
import { ThemeProvider } from "@themvora/react";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<ThemeProvider>
<App />
</ThemeProvider>,
);And the build integration derives the blocking script from the same file, so the two cannot disagree:
// vite.config.ts — the pre-paint bootstrap, through a build integration
import { themvoraVitePlugin } from "@themvora/core/vite";
export default defineConfig({
plugins: [react(), themvoraVitePlugin()],
});This exists to remove a class of bug rather than to add a feature. When the registry, the fallback theme and the initial mode are declared twice — once for the provider and once for whatever emits the pre-paint script — the two drift. A drift is visible: the script paints one theme, the runtime corrects it a frame later, and any theme-name readout contradicts the control that set it. This is the shape that drifts:
// Declared twice — the plugin's copy and the provider's copy drift apart
themvoraVitePlugin({
themes,
defaultTheme: "mint-light",
initialMode: "system",
});
<ThemeProvider
themes={themes}
defaultTheme="mint-light"
initialMode="system"
/>And this is the shape that cannot:
// Declared once — both consumers read it
themvoraVitePlugin();
<ThemeProvider>
<App />
</ThemeProvider>The split is not arbitrary — it follows the shipped type. ThemeBootstrapConfig is a Pick of ThemvoraConfig, and both integrations transport exactly that projection to the runtime:
| Key | Runtime | Bootstrap | Transported | Notes |
|---|---|---|---|---|
| themes | ✓ | ✓ | ✓ | The registry both sides resolve from. |
| defaultTheme | ✓ | ✓ | ✓ | Fallback when nothing is persisted. |
| initialMode | ✓ | ✓ | ✓ | `system` → `prefers-color-scheme`. |
| initialFamily | ✓ | ✓ | ✓ | Family resolved before first paint. |
| storageKey | ✓ | ✓ | ✓ | The key both sides read. |
| prefix | ✓ | ✓ | ✓ | CSS variable prefix. |
| transition | ✓ | — | ✓ | Nothing to animate before paint, but the client runtime honours it. |
| scrollbar | ✓ | — | ✓ | Runtime overlay options. |
| persistence | ✓ | — | — | A live adapter — not serializable. Pass to the provider. |
| plugins | ✓ | — | — | Not serializable — pass to the provider. |
| adapters | ✓ | — | — | Not serializable — pass to the provider. |
Every serializable key reaches the provider through the transported configuration, so a runtime option declared once in theme.config.ts — including transition — is honoured in the browser as well. A key that does not cross is not ignored, it is replaced by a default, so the only ones left behind are the three that cannot be serialized; pass those to the provider directly. A build integration is an optimization, not a requirement: with no integration at all, a provider alone still themes the app, using the built-in neutral themes when no registry is supplied. What it gives up is the pre-paint guarantee.
4Layers
get, set, subscribe, batch.@media (prefers-color-scheme: dark) fallback.5How a Theme Change Flows
setFamily("plum")
→ selection.setFamily()
→ persistence.set({ mode, family }) // save
→ broadcast.post({ mode, family }) // sync other tabs
→ resolve theme for family + mode
→ lifecycle.emit("beforeThemeChange")
→ plugins.onBeforeThemeChange
→ store.set(theme)
→ lifecycle.emit("afterThemeChange")
→ DOM binding: data-theme, data-theme-mode, .dark class
→ CSS variables binding: --theme-* variables
→ lifecycle.emit("beforeApply" / "afterApply")
→ history records a snapshot6Bootstrap: Zero Flash
Before any JS runs, a blocking inline script reads the persisted selection, resolves the effective mode (system → prefers-color-scheme), and applies CSS variables + DOM effects.
import { createThemeBootstrapScript, buildThemeCssMap } from "@themvora/core";
const cssMap = buildThemeCssMap(themes); // name + family:mode → variables
const script = createThemeBootstrapScript({
themes,
defaultTheme: "light",
initialMode: "system",
storageKey: "theme-selection",
prefix: "theme-",
});
// inject `script` into <head> before first paintThe @themvora/next provider does all of this automatically — it reads cookies on the server, renders the resolved theme, and emits the blocking script.