Vanilla
Framework-free theming
No React, no Vue, no Svelte — just a Themvora class. Drop it into any Vite or plain-JS page and get mode switching, family selection, CSS variable binding, and localStorage persistence out of the box.
Prerequisites
Runtime
Node.js 18+ and a package manager (npm, pnpm, yarn, or bun)
Project
A Vite project — the vanilla TypeScript template works as-is — or any page that can load a module script
1Install
One package. The vanilla entry has no framework peer dependencies.
pnpm add @themvora/core2Set up
Create the instance in the entry module. Themvora resolves the starting theme, writes its tokens onto the page, and restores any saved selection.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Themvora</title>
</head>
<body>
<!-- Themvora writes the active theme's CSS custom properties onto <html>,
so any element in the document can read them. -->
<main id="app">
<h1>Hello, themed world</h1>
<p>Current theme: <strong id="theme-name"></strong></p>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>import "./style.css";
import { Themvora } from "@themvora/core/vanilla";
// Omitting themes uses the built-in set. initialMode follows the OS on a first
// visit, and every later change is persisted to localStorage.
const kit = new Themvora({ defaultTheme: "light", initialMode: "system" });
const readout = document.querySelector<HTMLElement>("#theme-name");
if (!readout) {
throw new Error("index.html must contain a #theme-name element.");
}
const render = () => {
readout.textContent = kit.theme.name;
for (const button of document.querySelectorAll<HTMLElement>("[data-mode]")) {
button.setAttribute("aria-pressed", String(button.dataset.mode === kit.mode));
}
for (const button of document.querySelectorAll<HTMLElement>("[data-family]")) {
button.setAttribute("aria-pressed", String(button.dataset.family === kit.family));
}
};
// Controls are plain buttons carrying data-mode / data-family. Delegating from
// the document means the wiring does not depend on when they are rendered.
document.addEventListener("click", (event) => {
const button = (event.target as HTMLElement).closest<HTMLElement>(
"[data-mode], [data-family]",
);
if (!button) return;
const { mode, family } = button.dataset;
if (mode === "light" || mode === "dark" || mode === "system") {
kit.setMode(mode);
}
if (family) {
kit.setFamily(family);
}
});
// Every change is persisted and emitted as an event.
kit.on("themeChange", render);
kit.on("modeChange", render);
kit.on("familyChange", render);
render();Choosing the starting theme
defaultTheme chooses the starting theme and initialMode: "system" follows the visitor's OS. Omitting themes uses the built-in set, so there is nothing to define to get started.
3Add a small UI
A heading, a card and a button. The controls read the current theme and change it — nothing is styled yet.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Themvora</title>
</head>
<body>
<main id="app">
<h1>Themvora</h1>
<p>Current theme: <strong id="theme-name"></strong></p>
<p id="controls">
<button type="button" data-mode="light">Light</button>
<button type="button" data-mode="dark">Dark</button>
<button type="button" data-mode="system">System</button>
<button type="button" data-family="default">Default</button>
<button type="button" data-family="plum">Plum</button>
</p>
<section class="themvora-example-card">
<div class="themvora-example-header">
<h2 class="themvora-example-title">Themed card</h2>
<span class="themvora-example-status">Active</span>
</div>
<p class="themvora-example-description">
Surface, border and text follow the active theme.
</p>
<p class="themvora-example-meta">Updated 2 minutes ago</p>
<button class="themvora-example-button" type="button">Primary action</button>
</section>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>4Style it with Themvora tokens
Your own classes read the variables the active theme emits, so there is no per-theme CSS to write and nothing to re-render on a switch.
/* Themvora emits its design tokens as CSS custom properties on the root
element, so a stylesheet only has to read them. Nothing here is
theme-specific: these rules render every theme, including ones you add
later. */
html,
body {
background: var(--theme-color-background);
color: var(--theme-color-foreground);
}
.themvora-example-card {
display: grid;
gap: 10px;
max-width: 22rem;
padding: 16px;
border: 1px solid var(--theme-color-border);
border-radius: var(--theme-radius-lg, 12px);
background: var(--theme-color-card);
color: var(--theme-color-cardForeground);
font-family: inherit;
line-height: 1.5;
box-shadow: var(--theme-shadow-sm, 0 1px 2px rgb(0 0 0 / 0.06));
}
.themvora-example-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.themvora-example-title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.themvora-example-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--theme-color-success);
}
.themvora-example-status::before {
content: "";
width: 6px;
height: 6px;
border-radius: 9999px;
background: currentColor;
}
.themvora-example-description {
margin: 0;
font-size: 13px;
color: var(--theme-color-mutedForeground);
}
.themvora-example-meta {
margin: 0;
font-size: 12px;
color: var(--theme-color-mutedForeground);
}
.themvora-example-button {
justify-self: start;
padding: 8px 14px;
border: 0;
/* Presets and generated themes emit --theme-radius-lg and nothing else on
the radius scale, so the smaller step is derived from it rather than
assumed — the same proportion the utility-class mapping uses. */
border-radius: calc(var(--theme-radius-lg, 12px) * 0.8);
background: var(--theme-color-primary);
color: var(--theme-color-primaryForeground);
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.themvora-example-button:hover {
filter: brightness(1.08);
}
.themvora-example-button:focus-visible {
outline: 2px solid var(--theme-color-ring);
outline-offset: 2px;
}Why this never needs a second stylesheet
.themvora-example-card and its children read Themvora tokens, so switching the active theme repaints them — no re-render, and no per-theme CSS.The Themvora class
The whole runtime surface is a few getters, setters, methods and events on the instance.
reading state
// The full active theme definition (name, tokens, mode, …)
const current = kit.theme;
// The current mode: "light", "dark", or "system"
const mode = kit.mode;
// The current family: "default", "plum", "mint", …
const family = kit.family;
// Every registered theme
const themes = kit.themes;switching themes
// Switch mode — persisted to localStorage automatically
kit.setMode("dark");
kit.setMode("light");
kit.setMode("system");
// Switch family
kit.setFamily("plum");
kit.setFamily("mint");
// Toggle between light and dark (ignores "system")
kit.toggleTheme();subscribing to changes
// Fires on every theme change
const unsubscribe = kit.on("themeChange", (theme) => {
console.log("New theme:", theme.name);
});
// Fires when only the mode changes
kit.on("modeChange", (mode) => {
console.log("Mode:", mode);
});
// Fires when only the family changes
kit.on("familyChange", (family) => {
console.log("Family:", family);
});
// Later — clean up
unsubscribe();custom target and manual tokens
// By default Themvora binds CSS variables to <html>. To target one element
// instead (the "!" satisfies strict narrowing):
const scoped = new Themvora({ target: document.getElementById("app")! });
// A flat map of the current theme's custom properties
const vars = scoped.toCSSVariables();
// → { "--theme-color-primary": "#0d162e", … }
// Merge partial tokens into the active theme at runtime
scoped.update({ colors: { primary: "#10b981" } });persistence
// Mode and family are written to localStorage on every change and restored
// when a new instance is created.
kit.setMode("dark");
kit.setFamily("plum");
// ^ already persisted under the "theme-selection" key — no extra call needed
// To read the saved selection before instantiating:
const saved = JSON.parse(localStorage.getItem("theme-selection") ?? "{}");
console.log(saved.mode, saved.family); // "dark", "plum"your own themes
import { defineTheme } from "@themvora/core";
import { Themvora } from "@themvora/core/vanilla";
const brand = defineTheme({
name: "brand-light",
meta: { family: "brand", mode: "light", label: "Brand Light" },
tokens: { colors: { primary: "#6d28d9", primaryForeground: "#ffffff" } },
});
// Passing themes replaces the built-in set, so list everything you want.
const kit = new Themvora({ themes: [brand], defaultTheme: "brand-light" });When to use it
When a framework adapter is overkill.
- A static HTML page, CMS template or server-rendered view that does not use a JavaScript framework.
- The lightest possible integration — one import, one class, no build step required.
- Theme controls embedded in an admin panel, docs site or widget.
- The runtime is identical to the one the framework adapters use internally: same token resolution, same persistence, same events.