Theme runtime
Control light, dark, and system modes through a small store-backed controller without adding a UI provider.
Theme controller API
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 / option | Type | Default | Behavior |
|---|---|---|---|
createThemeController(mode) | ThemeController | "system" | Reads a valid saved choice, creates the state store, and exposes mode actions. |
createThemeControllerFromConfig(config) | ThemeController | config default or system | Optional convenience when browser code deliberately imports the theme config. The plugin does not pass its build-time config into runtime code. |
mountThemeController(controller) | cleanup function | — | Synchronizes the document and starts watching system preference. |
getThemeSnapshot(controller) | ThemeSnapshot | current state | Returns mode, resolvedMode, setMode, and toggleMode. |
subscribeTheme(controller, listener, options) | unsubscribe function | immediate: false | Publishes 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 tocreateThemeController.System resolves with
prefers-color-schemeand updates only while the selected mode remains system.toggleModechooses the explicit opposite ofresolvedMode, 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.
createThemeControlleraccepts a second options argument. UseownerDocumentfor 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.
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.
setConfigreplaces the config;patchConfigdeeply merges a partial config;updateConfigreceives a cloned config;setPropertyupdates a dot-separated path;resetConfigrestores the initial config.Successful updates increment revision and expose warnings. Read
getLiveThemeSnapshotorsubscribeLiveThemeto update editor feedback; keep expensive persistence on committed changes.Live CSS uses :root and
data-tavo-themefor color modes. breakpoints and output are build-time settings and cannot be changed through live updates.ownerDocumenttargets a same-origin iframe or another document.styleHostchooses 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.