Runtime switching

Runtime theming is the theme controller: create it once, subscribe to snapshots, apply them to the DOM, and call setters when the user or OS changes preferences. This page covers the full controller surface from @sometic/theme.

Overview

createThemeController keeps preferences in an @sometic/store (optionally persistent) and derives a ThemeSnapshot whenever preferences or system signals change. Your UI layer stays thin: bind snapshot → DOM (or framework state).

When to use

  • Light / dark / system toggles
  • Density, RTL, high contrast, reduced motion controls
  • Scoped themes on a panel or embed
  • SSR-safe create + client hydrate + apply

When not to use

  • Static CSS-only sites with no runtime mode → hand-written variables may be enough
  • Class merging without tokens → @sometic/styling
  • Persistence adapter details alone → Theme store

Installation

See Installation.

Usage

Full client bootstrap

ts
import { createThemeController, applyThemeToElement } from "@sometic/theme";
import { lightTheme, darkTheme } from "@sometic/theme/presets";
import { createWebStorageAdapter } from "@sometic/store/persistent";

const theme = createThemeController({
    themes: [lightTheme, darkTheme],
    defaultThemeId: lightTheme.id,
    lightThemeId: lightTheme.id,
    darkThemeId: darkTheme.id,
    mode: "system",
    persist: true,
    storageKey: "sometic-theme",
    storage: createWebStorageAdapter("localStorage"),
});

await theme.hydrated;

const root = document.documentElement;
let previous = theme.get().cssVariables;

function paint(snapshot: ReturnType<typeof theme.get>): void {
    applyThemeToElement(root, snapshot, { previousVariables: previous });
    previous = snapshot.cssVariables;
}

paint(theme.get());

const stop = theme.subscribe((snapshot) => {
    paint(snapshot);
});

// later
theme.setMode("dark");
theme.setDensity("compact");
theme.setDirection("rtl");
theme.setHighContrast("system");
theme.setReducedMotion(true);

// teardown
stop();
theme.dispose();

Scoped theme on a container

ts
const panel = document.querySelector("#settings-preview");
if (panel instanceof HTMLElement) {
    applyThemeToElement(panel, theme.get());
    theme.subscribe((snapshot) => {
        applyThemeToElement(panel, snapshot);
    });
}

Variables and data-* / dir live on that element; descendants inherit custom properties.

System preference without the controller

ts
import {
    getSystemColorScheme,
    subscribeSystemColorScheme,
    getPrefersReducedMotion,
    getPrefersMoreContrast,
} from "@sometic/theme/system";

getSystemColorScheme(); // "light" | "dark" | "no-preference"
const stop = subscribeSystemColorScheme((scheme) => {
    console.log(scheme);
});

The controller already subscribes when mode / flags are "system". Use these helpers for custom UI chrome.

Contrast helpers at runtime

ts
import { meetsWcagContrast, pickContrastingColor } from "@sometic/theme/contrast";

meetsWcagContrast("#111827", "#ffffff", "AA", "normal"); // true
pickContrastingColor("#2563eb", "#ffffff", "#111827"); // picks better of light/dark

Hex only today (#rgb / #rrggbb). Non-hex strings fail closed.

How it works

mermaid
flowchart LR
  prefs[Preference store]
  system[System matchMedia]
  snap[Snapshot store]
  dom[applyThemeToElement]
  prefs --> snap
  system --> snap
  snap --> dom
  1. Preferences update via setters or hydrate.
  2. System listeners update scheme / contrast / motion when relevant flags are "system".
  3. Snapshot rebuilds tokens → CSS variables → attributes.
  4. Subscribers receive (snapshot, previous).
  5. dispose stops listeners and disposes both stores.

hydrated resolves immediately when persist is not enabled. With persistence, await it before trusting restored prefs. Details: Theme store · Store.

API

createThemeController(options)

Returns ThemeController.

Options

OptionTypeDefault
themesreadonly ThemeDefinition[]required
defaultThemeIdstringrequired
lightThemeIdstringdefaultThemeId
darkThemeIdstringdefaultThemeId
mode"light" | "dark" | "system""system"
densityThemeDensity"comfortable"
direction"ltr" | "rtl""ltr"
highContrastboolean | "system"false
reducedMotionboolean | "system""system"
prefixstring"sometic"
persistbooleanfalse
storageStorageAdaptermemory when persisting
storageKeystring"sometic-theme"

Controller members

MemberSignature / notes
get()() => ThemeSnapshot
subscribe(listener)(snapshot, previous) => void → unsubscribe
registerTheme / unregisterThemeRegistry mutations
setMode / setTheme / setDensity / setDirectionPreference setters
setHighContrast / setReducedMotionFlag setters
hydratedPromise<void>
dispose()Idempotent cleanup (Disposable)

ThemeSnapshot

FieldDescription
preferencesCurrent ThemePreferences
resolvedColorScheme"light" or "dark"
resolvedThemeIdActive theme id
tokensActive ThemeTokens
cssVariablesFlat --… map
attributesAttribute map for apply

applyThemeToElement(element, snapshot, options?)

Writes CSS variables and attributes. Optional previousVariables removes stale properties. Clears inactive contrast/motion attributes.

System (@sometic/theme/system)

ExportRole
getSystemColorSchemeCurrent preference
getPrefersReducedMotionprefers-reduced-motion: reduce
getPrefersMoreContrastprefers-contrast: more
subscribeSystemColorSchemeChange listener
subscribePrefersReducedMotionChange listener
subscribePrefersMoreContrastChange listener

Contrast (@sometic/theme/contrast)

ExportRole
parseHexColor#rgb / #rrggbb → RGB or undefined
relativeLuminanceWCAG relative luminance
contrastRatioRatio between two RGB colors
meetsWcagContrastAA/AAA × normal/large
pickContrastingColorChoose light or dark foreground for a background

Edge cases

CaseBehavior
SSR importSafe; no import-time window
No matchMediaScheme falls back toward light; motion/contrast false
persist: true without web storageMemory adapter only (tests / ephemeral)
Double disposeSafe no-op
Subscribe after disposeAvoid; dispose tears down stores
Concurrent embedsCreate separate controllers; do not share one global singleton unless you own that lifecycle
Hex-only contrastSoft limit: extend later for rgb() / OKLCH; do not assume support

FAQ

Does theme require React?

No. Controllers are framework-agnostic.

How does mode: "system" work?

Resolves light/dark via prefers-color-scheme, selecting lightThemeId / darkThemeId. OS changes rebuild the snapshot while mode stays system.

What about hydrate races?

Await hydrated before first paint if you persist. See Theme store.

Reduced motion / high contrast?

Flags accept true | false | "system". Resolved true values appear as data-reduced-motion / data-high-contrast on apply.

Why soft honesty on contrast?

Ship hex parsing now. Broader CSS color parsing is a deliberate non-goal for the current surface.