Web Components examples
Each export in @themvora/web, with the problem it solves and the code that solves it.
@themvora/webWeb Componentsv2.0.0Stable← Back to the Web Components guide
The Web Components integration is the one that needs no framework. A single side-effect import registers six custom elements and injects their stylesheet once; from then on a theme is markup — attributes on a provider, native controls inside it, and events if you need to react.
Because everything is an attribute, the whole configuration is serialisable and works in static HTML, in a CMS template, in a PHP page, or inside any framework's template language. The imperative helpers exist for the cases where you need the runtime object itself.
1Two script tags, or a bundler
<themvora-provider> around the page. The provider owns the runtime and applies the active theme to the document.<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Themed without a framework</title>
<script type="module" src="/main.js"></script>
</head>
<body>
<themvora-provider initial-mode="system">
<header>
<h1>Hello, themed world</h1>
<themvora-toggle></themvora-toggle>
</header>
<main class="surface">
<p class="muted">This card reads Themvora tokens.</p>
</main>
</themvora-provider>
</body>
</html>import "@themvora/web";Nothing else is required: the built-in neutral themes are the default catalog, so a provider with no attributes is already themed.
2A page with no framework at all
A themed static page with no build step
A marketing page or CMS template needs a working theme switcher but has no framework and no bundler.
import "@themvora/web"<themvora-provider><themvora-toggle><themvora-select><themvora-provider
themes="mint-light mint-dark plum-light plum-dark"
default-theme="mint-light"
initial-mode="system"
>
<header>
<themvora-toggle></themvora-toggle>
<themvora-select type="family" label="Theme family"></themvora-select>
</header>
<main class="surface">
<h1>Static, themed, persisted</h1>
</main>
</themvora-provider>
<script type="module">
import "@themvora/web";
</script>The import registers the elements and the stylesheet; everything else is markup. The configuration lives in attributes, so it survives any template engine that can emit HTML. A working light/dark toggle and family picker, with the choice persisted across reloads, on a page with no application code.
Adopt Themvora inside an existing framework page
The page is rendered by a framework that does not have a Themvora adapter, but one region needs to be themed and switchable.
<themvora-provider><themvora-scope>getProviderRuntime()<themvora-scope theme="cocoa-dark">
<div class="surface">
<h2>Embedded widget</h2>
<themvora-toggle></themvora-toggle>
</div>
</themvora-scope>Custom elements are framework-agnostic: the framework renders the tags, the browser upgrades them, and the scoped element keeps the region's theme independent of the host app. The region is themed and switchable while the rest of the page keeps whatever styling it already had.
Watch out: A <themvora-provider> applies the theme to the document, not only to its subtree. Use <themvora-scope> when the surrounding page must not be repainted.
Keep a canvas in step with the theme
A chart library draws to a canvas and cannot read CSS variables, so it keeps rendering the old palette after a theme change.
runtime.store.subscribe()useThemeRuntime()import { useThemeRuntime } from "@themvora/web";
const runtime = useThemeRuntime();
const canvas = document.querySelector("#chart");
const unsubscribe = runtime.store.subscribe((theme) => {
const colors = theme.tokens?.colors ?? {};
drawChart(canvas, {
background: colors.background,
foreground: colors.foreground,
accent: colors.primary,
});
});The store subscription delivers the new theme definition, whose tokens are the resolved values — exactly what the canvas needs. Re-reading inside the callback avoids caching a stale theme. The canvas repaints with the new palette on every theme change, including one triggered by the toggle or by a schedule.
Watch out: Unsubscribe when the widget is removed; a subscription holds the runtime and the callback alive.
3Elements, attributes, and events
Setup
Registration, the optional build-time helper, and editor support. None of these change what the elements do — they decide how the elements get onto the page.
import "@themvora/web"
One side-effect import that registers all six elements and injects their stylesheet a single time. No manual registration step and no per-element import.
Every page that uses any Themvora element. This is the shortest correct setup.
import "@themvora/web";import "@themvora/web";The custom elements upgrade, the provider initializes, and the page is themed. Importing twice is harmless — registration is idempotent.
Relatedweb-define
defineCustomElements()
The explicit form of the same registration. Call it yourself, or register a single element under your own tag with ThemvoraProvider.define("my-provider").
You need control over when registration happens — a lazy bundle, a micro-frontend that must not register twice, or a tag name that would collide with an existing one.
import { ThemvoraProvider, defineCustomElements } from "@themvora/web";
defineCustomElements();
// Or register one element under your own tag name.
ThemvoraProvider.define("my-provider");Registration happens at the call site instead of at import time. It is idempotent, and a no-op on the server.
Relatedweb-importweb-provider-el
themvoraVitePlugin()
A Vite plugin that injects the blocking pre-paint script into the served HTML, so zero-flash needs no <script> tag of your own. It reads theme.config.ts at the project root, and the provider adopts what the script painted.
A Vite app (or any Vite-based meta-framework) that should paint the persisted theme on the first frame.
import { defineConfig } from "vite";
import { themvoraVitePlugin } from "@themvora/core/vite";
export default defineConfig({
plugins: [
// Reads theme.config.ts and injects the blocking script into the HTML.
themvoraVitePlugin({ scrollbar: true }),
],
});import { defineThemvoraConfig } from "@themvora/core";
import { getBuiltInThemes } from "@themvora/core";
export default defineThemvoraConfig({
themes: getBuiltInThemes(),
defaultTheme: "mint-light",
});The persisted theme is applied before first paint. Pass scrollbar: true as well when the page uses <themvora-scrollbar>, so the same pass hides the native bar.
Relatedweb-importweb-scrollbar-el
VS Code IntelliSense
The package ships html-data.json, an HTML custom-data file covering every element and attribute, so the editor completes tags, suggests attributes and shows hover documentation.
Any project writing these tags by hand. VS Code does not load the file automatically — pointing at it is a one-line settings change.
{
"html.customData": ["./node_modules/@themvora/web/html-data.json"]
}Tag and attribute completion inside .html files, with the attribute's documentation on hover.
Relatedweb-import
Configuration
Every option is an attribute on the provider, so the configuration is visible in the markup and survives a server render. Removing an attribute restores the default; changing one reconfigures the live runtime.
themes
The theme catalog, in two syntaxes: names (themes="light dark plum-dark") resolved against the registered catalog, or definitions (themes='[{"name":"brand","tokens":{…}}]') that register your own.
You ship themes beyond the built-in neutral pair. Use names when the themes come from the catalog, definitions when they come from data.
<themvora-provider themes="light dark plum-light plum-dark">
<themvora-select type="family" label="Theme family"></themvora-select>
</themvora-provider><!-- JSON in the attribute: registered as a theme of its own. -->
<themvora-provider
themes='[{"name":"brand","tokens":{"colors":{"primary":"#635bff"}}}]'
default-theme="brand"
>
<main class="surface">Brand tokens, no build step.</main>
</themvora-provider>The provider registers the catalog and resolves the selection against it. An unknown name is reported and skipped rather than silently constructing a theme.
default-theme
The theme used when nothing is persisted. Changing it re-resolves the selection; removing it does not disturb the live selection.
Every app with more than one theme — it is what a first-time visitor sees.
<themvora-provider default-theme="mint-light">
<themvora-toggle></themvora-toggle>
</themvora-provider>A first visit paints this theme. A returning visitor keeps their persisted choice, because persistence wins over the default.
initial-mode
The starting mode: light, dark or system, case-insensitive. Any other value is reported and ignored, and the mode falls back to the one derived from default-theme.
You want the first visit to follow the OS (system) rather than the default theme's own mode.
<themvora-provider initial-mode="system">
<themvora-toggle></themvora-toggle>
</themvora-provider>system makes the first paint follow prefers-color-scheme; an explicit mode pins it until the visitor changes it.
initial-family
The family used when nothing is persisted. An unregistered family is reported by the runtime (TV_THEME_FAMILY_UNKNOWN) and normalised rather than applied blindly.
A multi-family catalog where the default should be a specific family rather than the first registered theme.
<themvora-provider
themes="mint-light mint-dark plum-light plum-dark"
initial-family="plum"
initial-mode="dark"
>
<themvora-select type="family" label="Theme family"></themvora-select>
</themvora-provider>A first visit starts in that family; the visitor's own choice still wins on later visits.
Relatedweb-themes-attrweb-select-el
transition
JSON in the attribute: true, false, or {"enabled":true,"duration":250,"easing":"ease-in-out"}. Changing it reconfigures the live bindings; removing it returns to no configured transition.
Tune the cross-fade, or turn it off for a page that must repaint instantly.
<themvora-provider
transition='{"enabled":true,"duration":250,"easing":"ease-in-out"}'
>
<main class="surface">Changes cross-fade.</main>
</themvora-provider>Theme changes animate with the configured options. The attribute is read live, so changing it does not require a reload.
Relatedweb-provider-el
scheduled
JSON sunrise/sunset options, for example {"latitude":27.7,"longitude":85.3}. Removing the attribute tears the schedule down.
The page should follow local daylight with no control at all — a kiosk, a lobby display, a marketing page.
<themvora-provider
themes="mint-light mint-dark"
scheduled='{"lightTheme":"mint-light","darkTheme":"mint-dark"}'
>
<main class="surface">Follows daylight.</main>
</themvora-provider>The theme switches at the visitor's local sunrise and sunset. With no coordinates, the browser timezone supplies the location.
Elements
Six elements cover the whole surface. Each one is configured by attributes and needs no script of its own.
<themvora-provider><themvora-scope><themvora-toggle><themvora-select><themvora-scrollbar><themvora-inspector><themvora-provider>
Owns the runtime for a page or subtree and applies the active theme to the document. Every configuration attribute is reactive — changing one reconfigures the runtime without a reload.
Once per page, around the content. More than one provider is allowed; getProviderRuntime(el) is how you address a specific one.
<themvora-provider
themes="mint-light mint-dark"
default-theme="mint-light"
initial-mode="system"
>
<header>
<themvora-toggle></themvora-toggle>
<themvora-select type="family" label="Theme family"></themvora-select>
</header>
<main class="surface">Page content.</main>
</themvora-provider>The document carries the active theme's variables and data-theme. Removing the element tears the runtime down.
<themvora-scope>
Applies a theme to its subtree through the theme attribute, in the light DOM. Accepts an exact theme name or a family name: a family follows the provider's mode, an exact name pins its own.
A preview panel, a pricing block in a fixed palette, or a component gallery — anywhere a region must not follow the page theme.
<themvora-scope theme="cocoa-dark">
<h3>Always cocoa</h3>
<p>This panel keeps its own theme while the page changes.</p>
</themvora-scope><!-- `theme="plum"` is a family: it flips light/dark with the page. -->
<themvora-scope theme="plum">
<div class="surface">Second brand surface.</div>
</themvora-scope>Only the subtree repaints. Re-resolves when the provider's catalog changes, and falls back to the catalog's first theme (reported) when the name is unknown. Removing the attribute destroys the binding so the subtree inherits the surrounding theme.
<themvora-toggle>
A native <button> with built-in sun, moon and monitor icons. It reports three states, not two: data-mode is light, dark or system, and aria-pressed is true, false or mixed.
The default mode control. It needs no icon package, stylesheet or script, and its three-state reporting means a page following the OS reads as system rather than pretending to be light or dark.
<themvora-toggle></themvora-toggle>/* Themvora owns data-mode and aria-pressed, so CSS can key off either. */
themvora-toggle[data-mode="dark"] {
--tv-toggle-icon: var(--theme-color-primary);
}
themvora-toggle[aria-pressed="mixed"] {
opacity: 0.8;
}Activating it pins an explicit mode. The accessible state changes with it, so a token class keyed off aria-pressed stays in step with the visual state.
<themvora-select>
A native <select> for type="mode" or type="family". Options come from the runtime, so a catalog change updates them; label sets the accessible name.
A settings page, or any place where a segmented control would be less clear than a list. The native element brings keyboard and mobile behaviour for free.
<label>
<themvora-select type="family" label="Theme family"></themvora-select>
</label>
<label>
<themvora-select type="mode" label="Color mode"></themvora-select>
</label>The select's value mirrors the live selection, including a selection restored from persistence — so it never shows its first option on a page painted in another theme.
<themvora-scrollbar>
An overlay scrollbar bound to the provider runtime. Every option is an attribute; changing one rebuilds the overlay.
A custom scrollbar is part of the design. It needs the pre-paint class to avoid a native-bar flash — pass scrollbar: true to the Vite plugin, or load the pre-paint script yourself.
<themvora-provider scrollbar>
<themvora-scrollbar thickness="10" auto-hide></themvora-scrollbar>
<main class="surface">Long content…</main>
</themvora-provider>The overlay is theme-aware and follows runtime theme changes. Without the pre-paint pass the browser paints a native scrollbar for a frame before the overlay mounts.
<themvora-inspector>
A floating theme inspector: identity, selection, tokens and resolved CSS variables, live.
Development only — it is the fastest way to answer "which token produced this colour" without adding a debug page.
<!-- Add while developing; remove before shipping. -->
<themvora-inspector></themvora-inspector>A fixed-position panel appears over the page. Keep it out of production markup.
Relatedweb-provider-el
Events
Two DOM events cover lifecycle and catalog changes. Subscriptions cover the cases where you need the state rather than the notification.
theme-ready
Dispatched once by <themvora-provider> after its runtime exists and the first theme is applied. It bubbles and is composed, so a listener on document sees it.
Your script needs the runtime but cannot know whether the provider has upgraded yet — the classic race in a deferred module.
document.addEventListener("theme-ready", (event) => {
// `event.target` is the provider that initialized.
console.log("theme applied on", event.target);
});A document listener fires once per provider initialization, after the theme is applied. Inside it, getProviderRuntime() and useThemeRuntime() are safe to call.
themes-change
Dispatched when the runtime's catalog changed — a themes attribute change, runtime.use(pack), or runtime.restore(). detail.themes names the new catalog.
You rebuild UI that lists themes. A catalog change can leave the active theme untouched, so store.subscribe() alone cannot report it.
document.addEventListener("themes-change", (event) => {
const names = event.detail.themes.map((theme) => theme.name);
renderThemeList(names);
});Fires on a microtask, after the change is applied, so a listener may safely write back to themes without fighting the runtime.
runtime.store.subscribe()
The canonical subscription for the active theme. The callback receives the theme; re-read any other state inside it, because the runtime's helpers are getters rather than subscriptions.
You need to react to the active theme changing — a chart that must rebuild its palette, a canvas that must repaint.
import { useThemeRuntime } from "@themvora/web";
const runtime = useThemeRuntime();
const unsubscribe = runtime.store.subscribe((theme) => {
document.querySelector("#theme-name").textContent = theme.name;
});
// Later, when the widget is removed:
unsubscribe();The callback runs on every theme change until you unsubscribe. It does not report selection changes that resolve to the same theme.
runtime.selection.subscribe()
Fires for a selection change even when it resolves to the same theme — for example system following an OS change, or switching family between two variants of the same palette.
The UI shows the *selection* rather than the theme: a family picker's checked state, a "following the system" hint.
import { useThemeRuntime } from "@themvora/web";
const runtime = useThemeRuntime();
const unsubscribe = runtime.selection.subscribe((selection) => {
document.querySelector("#mode-readout").textContent = selection.mode;
});The callback receives the selection (mode, family). Pair it with store.subscribe() when you need both the selection and the resulting theme.
Imperative
Plain functions for the cases markup cannot express: addressing a specific provider, driving the selection from your own control, or observing the applied mode.
getProviderRuntime(providerEl)
Returns the runtime attached to a specific provider element, or undefined before that element has upgraded.
More than one provider exists on the page — a widget embedded twice, or a micro-frontend. useThemeRuntime() returns the first initialized provider, which would be the wrong one.
import { getProviderRuntime } from "@themvora/web";
const el = document.querySelector("#sidebar-provider");
const runtime = getProviderRuntime(el);
// undefined before the element upgrades.
if (runtime) {
runtime.selection.setMode("dark");
}You get that provider's runtime, or undefined. Checking for undefined is how you avoid the upgrade race without a theme-ready listener.
useThemeRuntime()
Page-level convenience: the runtime of the first initialized provider in document order. Throws when none has initialized.
A single-provider page — the common case — where you just need the runtime in a script.
import { useThemeRuntime } from "@themvora/web";
// Safe inside a `theme-ready` handler, or after the provider has upgraded.
const runtime = useThemeRuntime();
console.log(runtime.themes.map((theme) => theme.name));Returns the runtime, or throws. Use getProviderRuntime() when more than one provider exists, or theme-ready when the script may run before upgrade.
selection.setMode() / setFamily() / toggleTheme()
Documents Mode and family control
The three calls every control ultimately makes. The built-in <themvora-toggle> and <themvora-select> call exactly these, so a custom control needs no other API.
Your own button, a keyboard shortcut, or a control inside a framework that has not adopted a Themvora element.
import { useThemeRuntime } from "@themvora/web";
const runtime = useThemeRuntime();
document.querySelector("#dark").addEventListener("click", () => {
runtime.selection.setMode("dark");
});
document.querySelector("#plum").addEventListener("click", () => {
runtime.selection.setFamily("plum");
});
document.querySelector("#flip").addEventListener("click", () => {
runtime.selection.toggleTheme();
});The selection changes, the document repaints, and every subscribed consumer is notified — identical to using the built-in elements.
Relatedweb-use-runtimeweb-toggle-el
useEffectiveThemeMode() / observeEffectiveThemeMode()
useEffectiveThemeMode() returns the applied mode (light or dark); observeEffectiveThemeMode(listener) subscribes to it and returns an unsubscribe.
A label that states the current appearance, or a non-DOM consumer (a canvas, a chart library) that must follow the appearance rather than the request.
import { observeEffectiveThemeMode, useEffectiveThemeMode } from "@themvora/web";
document.body.dataset.appearance = useEffectiveThemeMode();
const unsubscribe = observeEffectiveThemeMode((selection) => {
repaintCanvas(selection.mode);
});Both track the applied mode, so they move at an OS change and at a sunrise/sunset transition — unlike the selection's mode, which does not.
4Everything on one page
Everything on one page
Provider, controls, a scoped panel, the overlay scrollbar and the inspector — the full element set configured by attributes alone.
<themvora-provider
themes="mint-light mint-dark plum-light plum-dark"
default-theme="mint-light"
initial-mode="system"
transition='{"enabled":true,"duration":250}'
scrollbar
>
<header>
<themvora-toggle></themvora-toggle>
<themvora-select type="family" label="Theme family"></themvora-select>
</header>
<main class="surface">
<h1>Themed, switchable, persisted</h1>
<themvora-scope theme="cocoa-dark">
<p class="muted">This panel keeps its own theme.</p>
</themvora-scope>
</main>
<themvora-scrollbar></themvora-scrollbar>
<themvora-inspector></themvora-inspector>
</themvora-provider>Build your own control
A three-state segmented control built on the runtime, mirroring what the built-in toggle does — including reading the applied mode for the label and the selection for the pressed state.
import { observeEffectiveThemeMode, useThemeRuntime } from "@themvora/web";
const runtime = useThemeRuntime();
const buttons = document.querySelectorAll("[data-mode-option]");
for (const button of buttons) {
button.addEventListener("click", () => {
runtime.selection.setMode(button.dataset.modeOption);
});
}
// The pressed state is the selection; the label is the applied mode.
const unsubscribe = observeEffectiveThemeMode(({ mode }) => {
document.querySelector("#appearance").textContent = mode;
});Not available in this integration
These exports exist in other integrations but not in @themvora/web. Listing them is more useful than inventing an example: if you need one of them here, the workaround is named.
This package has no framework runtime, so there is no useTheme() returning reactive state. Use the elements' attributes and events, or the imperative helpers above.
No element or helper wraps them. The runtime you get from useThemeRuntime() or getProviderRuntime() has history and snapshot() members — reach for those directly if you need them.