Skip to content

DOM Adapters

Bridging runtime and browser

DOM adapters are the bridge between Theme Kit'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?

Adapters subscribe to the theme store and apply changes to the DOM. Each adapter owns one concern — CSS variables, attributes, system preference — so they can be composed independently.
  • Every adapter receives a ThemeStore and calls store.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: false and receiving the apply callback through the shared View Transition.

2CSS Variables Binding

createCSSVariablesBinding() writes theme tokens as --theme-color-* custom properties on :root, enabling any CSS in the page to reference theme values.

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.

core — CSS variables binding
ts
1import { createThemeRuntime } from "@theme-kit/core";
2import { createCSSVariablesBinding } from "@theme-kit/core";
3
4const runtime = createThemeRuntime({
5  themes,
6  defaultTheme: "light",
7});
8
9// Writes --theme-color-* custom properties to :root.
10// Each theme token becomes a CSS variable automatically.
11const cssVars = createCSSVariablesBinding(runtime.store, {
12  prefix: "theme-",        // default prefix
13  target: document.documentElement,
14  styleSheet: false,       // inline <style> or element.style
15  transition: {
16    enabled: true,
17    duration: 360,
18    easing: "cubic-bezier(0.4, 0, 0.2, 1)",
19    useViewTransition: true,
20  },
21});
22
23// A theme switch now produces:
24//   --theme-color-primary: #6366f1
25//   --theme-color-background: #ffffff
26//   --theme-color-foreground: #171717
27//   …every token in the theme definition.
@property registration |On first apply, every color variable is registered with @property so the browser can interpolate between values — enabling smooth CSS-native transitions without per-element transition styles.

3DOM Attribute Binding

createDOMBinding() writes identity attributes so CSS selectors and JavaScript can react to the active theme without reading the store.

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.

core — DOM attribute binding
ts
1import { createDOMBinding } from "@theme-kit/core";
2
3const dom = createDOMBinding(runtime.store, {
4  target: document.documentElement,
5  attributeName: "data-theme", // default
6});
7
8// On every theme change the binding writes:
9//   data-theme="plum-dark"
10//   data-theme-family="plum"
11//   data-theme-mode="dark"
12//   class="dark"               ← toggled on/off
13//   color-scheme: dark         ← via inline style

4System Theme Binding

createSystemThemeBinding() listens to the OS color scheme preference and automatically swaps between a light and dark theme.

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.

core — system theme binding
ts
1import { createSystemThemeBinding } from "@theme-kit/core";
2
3const system = createSystemThemeBinding(runtime.store, {
4  lightTheme: themes.find(t => t.name === "light")!,
5  darkTheme: themes.find(t => t.name === "dark")!,
6  mediaQuery: "(prefers-color-scheme: dark)",
7});
8
9// The binding listens for OS preference changes.
10// When the user switches system appearance:
11//   - "prefers-color-scheme: dark" matches → store.set(darkTheme)
12//   - "prefers-color-scheme: light" matches → store.set(lightTheme)
13//
14// Downstream bindings (CSS vars, DOM attrs) react automatically.
Server-side resolution |Theme Kit resolves the 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

createScopedThemeBinding() isolates a subtree to its own theme, independent of the page-level provider.

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.

core — scoped theme binding
ts
1import { createScopedThemeBinding } from "@theme-kit/core";
2
3const sidebar = document.getElementById("sidebar")!;
4
5const scope = createScopedThemeBinding(
6  themes,
7  sidebar,
8  "plum-dark",  // or { family: "plum", mode: "dark" }
9  {
10    prefix: "theme-",
11    transition: { enabled: true, duration: 200 },
12    localThemes: [customTokenPack],  // optional local overrides
13  },
14);
15
16// The scope element receives:
17//   data-theme="plum-dark"
18//   data-mode="dark"
19//   class="dark"
20//   --theme-color-* inline variables
21//   --color-* aliases (Tailwind-style)
22//
23// Child elements inherit scoped variables — the page theme is
24// completely isolated from this subtree.
25
26scope.update("plum-light");       // animate to a new selection
27scope.setTransition({ duration: 0 }); // instant swap
28scope.destroy();                   // clean up variables + attrs

6Transition Binding

The diff → plan → animate pipeline is the internal engine that makes theme transitions smooth. The CSS variables binding owns it — other adapters opt in through the shared View Transition.

Every animated theme change flows through three stages:

The pipeline
1createThemeDiff() — compares old and new variable maps, groups changes by type (color, radius, shadow)
2createTransitionPlan() — classifies groups, checks prefers-reduced-motion, and computes per-group timing
3runThemeAnimation() — applies @property-registered CSS variables so the browser interpolates natively
core — diff → plan → animate pipeline
ts
1import {
2  createThemeDiff,
3  createTransitionPlan,
4  runThemeAnimation,
5  cancelThemeAnimation,
6} from "@theme-kit/core";
7
8// 1. Diff: compare old vs new variable maps
9const diff = createThemeDiff(appliedVariables, newVariables);
10
11// 2. Plan: classify changed variables into animated groups
12//    (colors, radii, shadows) and compute per-group timing.
13const plan = createTransitionPlan(diff, {
14  enabled: true,
15  duration: 360,
16  easing: "cubic-bezier(0.4, 0, 0.2, 1)",
17}, { reducedMotion: false });
18
19// 3. Animate: apply the plan to the target element.
20if (plan) {
21  runThemeAnimation({
22    target: document.documentElement,
23    plan,
24    swap: () => {
25      // Apply new CSS variables
26      applyInlineVariables(element, newVariables);
27    },
28  });
29}
30
31// The CSS-variables binding owns this entire pipeline.
32// You don't call these directly — they run inside
33// createCSSVariablesBinding's store subscriber.
View Transition integration |When the browser supports it and the user doesn't prefer reduced motion, the CSS-variables binding wraps the swap in 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

Adapters can be selectively disabled. Skip DOM attributes entirely, suppress CSS variable output, or turn off transitions for specific bindings.

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.

core — disabling adapters
ts
1// Disable DOM attribute binding entirely
2const runtime = createThemeRuntime({
3  themes,
4  defaultTheme: "light",
5  dom: false,  // no data-theme, no data-theme-family, no .dark class
6});
7
8// Disable CSS variable output
9const cssVars = createCSSVariablesBinding(runtime.store, {
10  styleSheet: true,
11  layerName: "theme-kit",
12});
13// To skip CSS variables entirely, don't call this binding.
14
15// Disable transitions on a specific binding
16const dom = createDOMBinding(runtime.store, {
17  transition: { enabled: false },  // instant attribute swap
18});
19
20// Combine: attributes + variables, no transition
21const dom = createDOMBinding(runtime.store, {
22  subscribe: false, // driven by CSS-variables binding's pipeline
23});
24const cssVars = createCSSVariablesBinding(runtime.store, {
25  transition: { enabled: false },
26});
DOM Adapters — Theme Kit