DOM Adapters
Bridging runtime and browser
DOM adapters are the bridge between Themvora's runtime store and the live document. They translate theme definitions into CSS variables, HTML attributes, and media-query listeners — keeping the core logic framework-agnostic while the adapters handle the browser.
1What are DOM Adapters?
- Every adapter receives a
ThemeStoreand callsstore.subscribe()to react to theme changes. - Adapters are composable — you can use CSS variables binding without DOM attributes, or system binding without scoped themes.
- Each adapter returns a
destroy()function that unsubscribes and cleans up any injected DOM nodes. - The CSS-variables binding owns the transition pipeline (diff → plan → animate). Other bindings opt in by setting
subscribe: falseand receiving theapplycallback through the shared View Transition.
2CSS Variables Binding
This is the primary adapter. It converts every token in a theme definition into a CSS custom property and keeps it synchronized. The default prefix theme- produces variables like --theme-color-primary and --theme-radius-lg.
import { createThemeRuntime } from "@themvora/core";
import { createCSSVariablesBinding } from "@themvora/core";
const runtime = createThemeRuntime({
defaultTheme: "light",
});
// Writes --theme-color-* custom properties to :root.
// Each theme token becomes a CSS variable automatically.
const cssVars = createCSSVariablesBinding(runtime.store, {
prefix: "theme-", // default prefix
target: document.documentElement,
styleSheet: false, // inline <style> or element.style
transition: {
enabled: true,
duration: 360,
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
useViewTransition: true,
},
});
// A theme switch now produces:
// --theme-color-primary: #6366f1
// --theme-color-background: #ffffff
// --theme-color-foreground: #171717
// …every token in the theme definition.@property so the browser can interpolate between values — enabling smooth CSS-native transitions without per-element transition styles.3DOM Attribute Binding
While CSS variables deliver color values, the DOM binding delivers identity. It sets the data-theme, data-theme-family, and data-theme-mode attributes, toggles the dark class, and writes the color-scheme CSS property so native form controls and scrollbars match the theme.
import { createDOMBinding } from "@themvora/core";
const dom = createDOMBinding(runtime.store, {
target: document.documentElement,
attributeName: "data-theme", // default
});
// On every theme change the binding writes:
// data-theme="plum-dark"
// data-theme-family="plum"
// data-theme-mode="dark"
// class="dark" ← toggled on/off
// color-scheme: dark ← via inline style4System Theme Binding
The system binding watches prefers-color-scheme via matchMedia and updates the store when the OS switches between light and dark. Downstream bindings (CSS variables, DOM attributes) react automatically — no wiring needed.
import { createSystemThemeBinding, getBuiltInThemes } from "@themvora/core";
// `runtime` is the ThemeRuntime from your provider / createThemeRuntime().
// The built-in set is a valid registry, so no theme file is needed.
const themes = getBuiltInThemes();
const system = createSystemThemeBinding(runtime.store, {
lightTheme: themes.find(t => t.name === "light")!,
darkTheme: themes.find(t => t.name === "dark")!,
mediaQuery: "(prefers-color-scheme: dark)",
});
// The binding listens for OS preference changes.
// When the user switches system appearance:
// - "prefers-color-scheme: dark" matches → store.set(darkTheme)
// - "prefers-color-scheme: light" matches → store.set(lightTheme)
//
// Downstream bindings (CSS vars, DOM attrs) react automatically.system mode during SSR using a @media (prefers-color-scheme: dark) fallback, so the first paint already matches the OS — no flash.5Scoped Theme Binding
Scoped bindings apply theme variables and attributes to a specific element rather than :root. Child elements inherit the scoped variables, so a sidebar can be permanently dark while the page stays light. The binding supports local theme definitions, transition inheritance from the parent runtime, and clean teardown.
import { createScopedThemeBinding, getBuiltInThemes } from "@themvora/core";
const sidebar = document.getElementById("sidebar")!;
const scope = createScopedThemeBinding(
getBuiltInThemes(),
sidebar,
"plum-dark", // or { family: "plum", mode: "dark" }
{
prefix: "theme-",
transition: { enabled: true, duration: 200 },
localThemes: [customTokenPack], // optional local overrides
},
);
// The scope element receives:
// data-theme="plum-dark"
// data-mode="dark"
// class="dark"
// --theme-color-* inline variables
// --color-* aliases (Tailwind-style)
//
// Child elements inherit scoped variables — the page theme is
// completely isolated from this subtree.
scope.update("plum-light"); // animate to a new selection
scope.setTransition({ duration: 0 }); // instant swap
scope.destroy(); // clean up variables + attrs6Transition Binding
Every animated theme change flows through three stages:
import {
createThemeDiff,
createTransitionPlan,
runThemeAnimation,
cancelThemeAnimation,
} from "@themvora/core";
// 1. Diff: compare old vs new variable maps
const diff = createThemeDiff(appliedVariables, newVariables);
// 2. Plan: classify changed variables into animated groups
// (colors, radii, shadows) and compute per-group timing.
const plan = createTransitionPlan(diff, {
enabled: true,
duration: 360,
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
}, { reducedMotion: false });
// 3. Animate: apply the plan to the target element.
if (plan) {
runThemeAnimation({
target: document.documentElement,
plan,
swap: () => {
// Apply new CSS variables
applyInlineVariables(element, newVariables);
},
});
}
// The CSS-variables binding owns this entire pipeline.
// You don't call these directly — they run inside
// createCSSVariablesBinding's store subscriber.document.startViewTransition() — a single cross-fade that paints the new theme beneath a snapshot of the old one, eliminating white-shift on light→dark switches.7Disabling Adapters
Not every app needs every adapter. The runtime accepts a dom: false flag to skip attribute binding at the provider level, and individual bindings accept options to disable transitions or unsubscribe from the store.
// Disable DOM attribute binding entirely
const runtime = createThemeRuntime({
defaultTheme: "light",
dom: false, // no data-theme, no data-theme-family, no .dark class
});
// Disable CSS variable output
const cssVars = createCSSVariablesBinding(runtime.store, {
styleSheet: true,
layerName: "themvora",
});
// To skip CSS variables entirely, don't call this binding.
// Disable transitions on a specific binding
const instantAttributes = createDOMBinding(runtime.store, {
transition: { enabled: false }, // instant attribute swap
});
// Combine: let the variables binding drive the pipeline, so the attributes
// binding does not subscribe on its own.
const attributes = createDOMBinding(runtime.store, {
subscribe: false,
});
const instantVariables = createCSSVariablesBinding(runtime.store, {
transition: { enabled: false },
});