Navigated to /docs/ui/theme-runtime

Theme runtime

Control light, dark, and system modes through a small store-backed controller without adding a UI provider.

Theme controller API

TSCreate: src/components/ThemeSwitcher.tsx
tsimport { createTavo, TavoController } from "@tavojs/core";
import { Button, Inline, Stack, Text } from "@tavojs/ui";
import {
  createThemeController,
  mountThemeController,
  subscribeTheme,
  type ThemeController,
  type ThemeMode,
} from "@tavojs/ui/theme";

type State = { mode: ThemeMode; resolvedMode: "light" | "dark" };

class ThemeSwitcherController extends TavoController {
  private theme: ThemeController | undefined;

  onMount() {
    // Keep this aligned with defaultTheme in tavo-ui.config.json.
    const theme = createThemeController("system");
    this.theme = theme;
    const stop = mountThemeController(theme);
    const unsubscribe = subscribeTheme(
      theme,
      ({ mode, resolvedMode }) => this.model.patch({ mode, resolvedMode }),
      { immediate: true },
    );
    return () => {
      unsubscribe();
      stop();
      this.theme = undefined;
    };
  }

  setMode(mode: ThemeMode) {
    this.theme?.setMode(mode);
  }
}

// Mount one instance in the application shell, outside changing route content.
export const ThemeSwitcher = createTavo<
  Record<string, never>,
  State,
  ThemeSwitcherController
>({
  model: () => ({ mode: "system", resolvedMode: "light" }),
  controller: ThemeSwitcherController,
  view: ({ state, controller }) => (
    <Stack>
      <Inline>
        {(["light", "dark", "system"] as const).map((mode) => (
          <Button
            aria-pressed={state.mode === mode}
            onClick={() => controller?.setMode(mode)}
          >
            {mode}
          </Button>
        ))}
      </Inline>
      <Text>Current appearance: {state.resolvedMode}</Text>
    </Stack>
  ),
});
API / optionTypeDefaultBehavior
createThemeController(mode)ThemeController"system"Reads a valid saved choice, creates the state store, and exposes mode actions.
createThemeControllerFromConfig(config)ThemeControllerconfig default or systemOptional convenience when browser code deliberately imports the theme config. The plugin does not pass its build-time config into runtime code.
mountThemeController(controller)cleanup functionSynchronizes the document and starts watching system preference.
getThemeSnapshot(controller)ThemeSnapshotcurrent stateReturns mode, resolvedMode, setMode, and toggleMode.
subscribeTheme(controller, listener, options)unsubscribe functionimmediate: falsePublishes a complete snapshot after state changes.

Mode, persistence, and system behavior

  • The document attribute is data-tavo-theme. Explicit light or dark sets it; system removes it so generated media-query CSS can decide.

  • The saved key is tavo-ui.theme. A valid saved light, dark, or system value wins over the default passed to createThemeController.

  • System resolves with prefers-color-scheme and updates only while the selected mode remains system.

  • toggleMode chooses the explicit opposite of resolvedMode, so it leaves system mode.

  • Blocked or unavailable local storage does not stop theme switching.

  • Call both cleanup functions when the application shell unmounts or replaces the controller.

  • createThemeController accepts a second options argument. Use ownerDocument for an iframe document and persistence: false for previews that must not read or write saved preference.

Preview theme edits in the browser

Use createLiveThemeController for a theme editor or design preview that changes colors, scale, or tokens without rebuilding. It generates a live style element; ordinary light/dark switching only needs createThemeController. Call this function after the preview mounts and call its stop function when the preview is removed.

TSCreate: src/start-theme-preview.ts
tsimport {
  createLiveThemeController,
  mountLiveThemeController,
  type TavoUiThemeConfig
} from "@tavojs/ui/theme";

export function startThemePreview(config: TavoUiThemeConfig, ownerDocument: Document) {
  const theme = createLiveThemeController(config, {
    ownerDocument,
    persistence: false
  });
  const stop = mountLiveThemeController(theme);

  function setPrimary(primary: string) {
    const result = theme.patchConfig({ color: { light: { primary } } });
    return result; // Check applied, warnings, and error before showing success.
  }

  return { theme, setPrimary, stop };
}
  • Creation validates the initial config and can throw. Later invalid updates return applied=false and retain the previous working theme while recording error in the store.

  • setConfig replaces the config; patchConfig deeply merges a partial config; updateConfig receives a cloned config; setProperty updates a dot-separated path; resetConfig restores the initial config.

  • Successful updates increment revision and expose warnings. Read getLiveThemeSnapshot or subscribeLiveTheme to update editor feedback; keep expensive persistence on committed changes.

  • Live CSS uses :root and data-tavo-theme for color modes. breakpoints and output are build-time settings and cannot be changed through live updates.

  • ownerDocument targets a same-origin iframe or another document. styleHost chooses where the style is inserted, but does not scope :root CSS to that element. Use an iframe for an isolated preview.

  • persistence=false avoids changing the saved user preference. nonce attaches an application-provided CSP nonce to the live style element.

  • The mount cleanup disposes the controller, removes its style, and releases its theme-attribute ownership. Unsubscribe your own listeners too; create a fresh controller for a new preview.