Web Components
HTML-first theming for any page: import the package once, then write custom elements and CSS variables.
@themvora/webWeb Componentsv2.0.0StableSSR:NoZero-flash:Partial1Install
pnpm add @themvora/core @themvora/web2Set up
<!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>
<!-- Render-blocking on purpose: a CSS file imported from main.ts is
injected by JavaScript, so in dev the first paint happens before it
exists and the page flashes unstyled. -->
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<!-- No controls, no script beyond the module import: the provider applies
the theme and ordinary HTML reads the variables. -->
<themvora-provider default-theme="light" initial-mode="system">
<h1>Hello, themed world</h1>
<p>Current theme: <strong id="theme-name"></strong></p>
</themvora-provider>
<script type="module" src="/src/main.ts"></script>
</body>
</html>// The whole setup. This one import registers the elements and injects their
// stylesheet, so the markup in index.html upgrades on its own. The page's own
// stylesheet is a <link> in index.html, not an import here — see the comment
// in that file.
import "@themvora/web";
// Optional: read the current theme in application code. The runtime's helpers
// are getters, so subscribe and re-read.
import { useThemeRuntime } from "@themvora/web";
const runtime = useThemeRuntime();
const readout = document.querySelector<HTMLElement>("#theme-name");
if (!readout) {
throw new Error("index.html must contain a #theme-name element.");
}
const render = () => {
readout.textContent = runtime.store.get().name;
};
runtime.store.subscribe(render);
render();default-theme chooses the starting theme and initial-mode="system" follows the visitor's OS instead of the theme's own mode. Give the pre-paint script the same defaultTheme and initialMode, or the script and the runtime resolve different themes and the page flashes.3Add a small UI
Design system
ActiveTokens resolve at runtime, so this card follows whichever theme is active.
<div class="themvora-example-card">
<div class="themvora-example-header">
<h3 class="themvora-example-title">Design system</h3>
<span class="themvora-example-status">Active</span>
</div>
<p class="themvora-example-description">
Tokens resolve at runtime, so this card follows whichever theme is active.
</p>
<p class="themvora-example-meta">Updated 2 minutes ago</p>
<button class="themvora-example-button" type="button">View themes</button>
</div>Plain HTML. The custom elements upgrade around it; the card itself needs no JavaScript because it only reads CSS variables.
4Style it with Themvora tokens
/* 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;
}.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 `body` rule uses exactly the two foundational colours — `background` and `foreground`. Every component rule reads one of the 21 colours `validateTheme()` requires, so any valid theme emits it. Radius and shadow are optional and carry fallbacks; no typography token is read, so a theme without a typography group still renders correctly.
5Run and choose a theme
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Themvora</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<themvora-provider default-theme="light" initial-mode="system">
<h1>Themvora</h1>
<p>Current theme: <strong id="theme-name"></strong></p>
<p>
<themvora-toggle></themvora-toggle>
<themvora-select type="family"></themvora-select>
</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">View themes</button>
</section>
</themvora-provider>
<script type="module" src="/src/main.ts"></script>
</body>
</html>Theme configuration
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Themvora</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<!-- The provider owns the runtime. Every option is an attribute: a theme
list, the starting theme, and the starting mode. -->
<themvora-provider themes="light dark plum-light plum-dark" default-theme="light" initial-mode="system">
<!-- src/main.ts renders the active theme here. Keep the id when you
replace this markup: that module throws without it. -->
<p>Current theme: <strong id="theme-name"></strong></p>
<themvora-toggle></themvora-toggle>
<themvora-select type="mode"></themvora-select>
<themvora-select type="family"></themvora-select>
</themvora-provider>
<script type="module" src="/src/main.ts"></script>
</body>
</html>themes accepts either a list of registered names (themes="light dark plum-dark") or a JSON array of theme definitions. Removing themes, default-theme, initial-mode or initial-family stops that attribute contributing without resetting the live selection; transition and scheduled are the exception, where removing the attribute turns the feature off. Invalid values are reported once through the standard diagnostic pipeline and ignored, never thrown.What's Available
@themvora/web ships 24 exports in 5 categories. Click an export to see what it does and jump to the code that uses it.
Three pages cover this package, and they do different jobs: this index says what exists, the examples page shows each export in code with the problem it solves, and the API reference gives exact signatures and return types.
Use Cases
Short snippets you can drop into an existing app. For the same problems worked through end to end — problem, APIs, expectations, limitations — see the Web Components examples.
Define the elements
One call registers every themvora custom element.
import { defineCustomElements } from "@themvora/web";
defineCustomElements();Provider + toggle + select
Compose the elements in plain HTML — no framework required.
<themvora-provider default-theme="light">
<themvora-toggle></themvora-toggle>
<themvora-select></themvora-select>
</themvora-provider>Scope a subtree
themvora-scope applies a theme to its children.
<themvora-scope theme="plum-dark">
<section>Always plum-dark here</section>
</themvora-scope>Imperative runtime access
Grab the runtime and drive it from any script.
import { useThemeRuntime } from "@themvora/web";
// Resolves the first <themvora-provider> in the document and throws a clear
// error if none has initialised yet. Pass an element instead with
// getProviderRuntime(providerEl) when there is more than one provider.
const runtime = useThemeRuntime();
runtime.selection.setMode("dark");
runtime.update({ colors: { primary: "#6366f1" } });
console.log(runtime.store.get().name);Smooth theme transitions
Enable CSS transitions on theme changes for a polished user experience.
<themvora-provider
default-theme="light"
transition='{"enabled": true, "duration": 300, "easing": "ease-in-out"}'
>
<my-app></my-app>
</themvora-provider>Scoping, and a pre-paint script without the Vite plugin
Both are optional. The first pins a subtree to a theme and offers a native <select>; the second is how to get the blocking bootstrap when your build is not Vite.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Themvora</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<themvora-provider default-theme="light" initial-mode="system">
<p>Current theme: <strong id="theme-name"></strong></p>
<themvora-select type="mode"></themvora-select>
<!-- theme="cocoa-dark" names one registered theme, so this subtree keeps
that theme whatever the rest of the page selects. A bare family name
("cocoa") also resolves, to that family's theme for the current mode.
Nested scopes override their ancestor, never the reverse. -->
<themvora-scope theme="cocoa-dark">
<section class="themvora-example-card">
<h2 class="themvora-example-title">Scoped section</h2>
<p class="themvora-example-description">
This subtree is always themed "cocoa-dark".
</p>
</section>
<themvora-scope theme="berry-light">
<p>And this one is always "berry-light".</p>
</themvora-scope>
</themvora-scope>
</themvora-provider>
<script type="module" src="/src/main.ts"></script>
</body>
</html>A <themvora-select type="mode"> and a theme="cocoa-dark" scope in plain markup, with no script of its own.
// Zero flash without the Vite plugin — for any other server or bundler.
// Writes a classic script that applies the saved theme before the first
// paint. Run it before you build, then load the output from <head> in
// index.html, above the main.ts module script:
//
// <script src="/theme-bootstrap.js"></script>
import { writeFileSync } from "node:fs";
import { createThemeBootstrapScript, getBuiltInThemes } from "@themvora/core";
writeFileSync(
"public/theme-bootstrap.js",
createThemeBootstrapScript({
themes: getBuiltInThemes(),
defaultTheme: "light",
initialMode: "system",
}),
);A build step that writes the blocking bootstrap to a file, for any server or bundler that is not the Vite plugin.
The complete runnable app behind this guide is examples/apps/web — listed with the other example apps.
Related documentation
Every export in @themvora/web, with code and use cases
Build your own color palettes and semantic tokens
Complete @themvora/web documentation
Upgrade from previous versions
Check framework and library version support