Core Concepts
@theme-kit/core
The mental model behind Theme Kit: semantic tokens, theme families, modes, the runtime, adapters, scoped themes and zero-flash SSR.
Theme
A theme is a named set of semantic tokens plus metadata. Themes are plain, serializable objects — no classes, no magic.
1interface ThemeDefinition {
2 name: string; // e.g. "plum-dark"
3 extends?: string | string[]; // inherit from another theme
4 meta?: ThemeMeta; // label, family, mode, order, tags, version...
5 tokens?: ThemeTokens; // colors, spacing, radius, shadows, ...
6}A concrete theme looks like this:
1import { defineTheme } from "@theme-kit/core";
2
3const plumDark = defineTheme({
4 name: "plum-dark",
5 meta: {
6 family: "plum",
7 mode: "dark",
8 label: "Plum Dark",
9 order: 20,
10 tags: ["preset"],
11 },
12 tokens: {
13 colors: {
14 background: "#171123",
15 foreground: "#f6f1fb",
16 primary: "#a78bfa",
17 card: "#221a33",
18 border: "#3b2d55",
19 // ... every semantic role
20 },
21 radius: { lg: "14px" },
22 },
23});meta also carries created and updated timestamps (added automatically when a theme is registered), plus version for migrations.
Token Groups
Tokens are grouped by semantic meaning, not raw values:
colors— nested, recursively-addressable:colors.surface.default,colors.primary,colors.accent.hoverspacing,radius,shadows,borderWidths,zIndex,breakpointstypography—fontFamilies,fontSizes,lineHeights
Because tokens are plain nested objects, you can address any leaf by its path — theme.tokens.colors.primary — and merge partial overrides safely.
Mode & Family
- Mode —
"light" | "dark" | "system".systemfollows the OS withprefers-color-scheme. - Family — a named set of themes (e.g.
"plum","mint","apple"). Switching family changes the palette; switching mode changes light/dark within that family.
runtime.selection.setFamily("plum"); // plum light/dark pair
runtime.selection.setMode("dark"); // whichever plum is active, use dark
runtime.selection.toggleTheme(); // flip between light and darkThe active theme is always resolved from family + mode — the library picks the matching theme in the registry.
Semantic Token Groups
Nested tokens produce recursive groups that render as CSS variables:
--theme-color-surface-default
--theme-color-surface-hover
--theme-radius-lg
--theme-spacing-4Every token maps to a --theme-* variable automatically via themeToCSSVariables:
import { themeToCSSVariables } from "@theme-kit/core";
const vars = themeToCSSVariables(plumDark, { prefix: "theme-" });
// { "--theme-color-background": "#171123", "--theme-radius-lg": "14px", ... }The Theme Store
A minimal reactive store that holds the active theme.
1import { createThemeStore } from "@theme-kit/core";
2
3const store = createThemeStore({ initialTheme: myTheme });
4
5store.get(); // current theme
6store.set(nextTheme); // apply
7store.subscribe((theme) => render(theme)); // react to changes
8store.batch(() => { store.set(a); store.set(b); }); // coalesce writes
9store.destroy();Model Builders
Type-safe, zero-cost helpers for composing themes:
1import {
2 defineTheme,
3 extendTheme,
4 composeTheme,
5 mergeThemeDefinitions,
6 mergeTokens,
7 resolveTheme,
8} from "@theme-kit/core";
9
10const base = defineTheme({ name: "base", tokens: { ... } });
11
12// Inherit from a base theme and override pieces
13const plum = extendTheme("plum", base, {
14 meta: { family: "plum" },
15 tokens: { colors: { primary: "#a78bfa" } },
16});
17
18// Merge several definitions into one
19const full = composeTheme("full", base, plum, { tokens: { radius: { lg: "12px" } } });
20
21// Low-level merges
22const mergedTokens = mergeTokens(a.tokens, b.tokens);
23const mergedTheme = mergeThemeDefinitions(a, b);
24
25// Resolve a definition, following `extends` chains
26const resolved = resolveTheme([base, plum], "plum");Theme Registry
Every registered theme lives in a registry — the engine behind dynamic theming, family lookups, and theme packs.
1import { createThemeRegistry } from "@theme-kit/core";
2
3const registry = createThemeRegistry({ themes });
4
5registry.register(theme);
6registry.registerMany([a, b]);
7registry.unregister("plum-dark");
8registry.replace("plum-dark", newDark);
9registry.get("plum-light");
10registry.has("plum-light");
11registry.list();
12
13registry.getFamilies(); // ["default", "oat", "plum", ...]
14registry.getThemesByFamily("plum"); // plum light + dark
15
16registry.use({ name: "brand", themes }); // install a theme packA theme pack is a named bundle of themes; every theme it installs is stamped with a pack:<name> tag in its meta.
The Theme Runtime
createThemeRuntime wires everything together — store, registry, selection, persistence, broadcast, DOM bindings, history, lifecycle, and plugins — into one object.
1import { createThemeRuntime } from "@theme-kit/core";
2
3const runtime = createThemeRuntime({
4 themes,
5 defaultTheme: "light",
6 transition: { enabled: true, duration: 300 },
7 plugins: [createPersistencePlugin(), createHistoryPlugin()],
8});Every capability is documented with snippets in the Architecture section. Key surfaces: store, registry, selection, themes, update, use, batch, snapshot/restore, history, lifecycle, destroy.
Token Resolution
Tokens support references and derived values, resolved lazily at runtime:
- References —
"$colors.primary"or"{colors.primary}"point to another token (with circular-reference detection).
{
tokens: {
colors: { primary: "#6366f1", ring: "$colors.primary" },
},
}- Expressions — numeric math such as
"calc(100% + 2rem)"-style evaluations:
spacing: { "12": "calc(3rem + 0.75rem)" }- Derived colors —
"contrast(#123456)"returns black/white by WCAG luminance;"auto()"derives a foreground from a base token:
colors: {
primary: "#6366f1",
primaryForeground: "auto()", // readable on primary
}Utilities: flattenTokens, resolveFlatTokens, resolveTokens, hasTokenReferences, resolveValueReferences, evaluateExpression.
Theme Generation
Generate a complete light + dark theme pair from a single seed color, deriving secondary, muted, accent, border, and ring colors with HSL math.
import { generateTheme } from "@theme-kit/core";
const { light, dark } = generateTheme({ seed: "#6366f1", family: "indigo" });Theme Validation
Validate that a theme defines all required semantic color tokens, resolving extends chains when a theme list is provided.
import { validateTheme } from "@theme-kit/core";
const result = validateTheme(theme, { themes });
// { valid: boolean, issues: [{ type: "missing", path, message }] }Theme Migration
Version themes with a migration chain. Old theme files automatically upgrade to the latest format.
1import { migrateTheme, registerMigration } from "@theme-kit/core";
2
3registerMigration({
4 from: "1.0.0",
5 to: "2.0.0",
6 remapColors: { primaryColor: "primary" },
7 migrate(theme) { /* arbitrary transforms */ },
8});
9
10const next = migrateTheme(theme, { targetVersion: "2.0.0" });Theme History (Undo / Redo)
History is capped (default 50 steps) and records full theme snapshots with timestamps.
runtime.history.undo(); // step back
runtime.history.redo(); // step forward
runtime.history.jump(i); // jump to any point in time
runtime.history.canUndo(); // / canRedo()
runtime.history.getHistory(); // / clear()Lifecycle Events
runtime.lifecycle.on(event, handler) with typed payloads:
| Event | Payload |
|---|---|
beforeThemeChange | { current, next } |
afterThemeChange | { theme } |
beforePersist | { selection } |
afterPersist | { selection } |
beforeApply | { theme } |
afterApply | { theme } |
const off = runtime.lifecycle.on("afterThemeChange", ({ theme }) => {
console.log("applied", theme.name);
});Plugins
Plugins hook into the lifecycle and can transform tokens.
1interface ThemePlugin {
2 name: string;
3 priority?: number;
4 onBeforeThemeChange?(data): void;
5 onAfterThemeChange?(data): void;
6 onBeforePersist?(data): void;
7 onAfterPersist?(data): void;
8 onBeforeApply?(data): void;
9 onAfterApply?(data): void;
10 transformTokens?(tokens, ctx): ThemeTokens;
11}Official plugins:
createPersistencePlugin()— persist selection to localStoragecreateBroadcastPlugin()— cross-tab synccreateHistoryPlugin()— undo/redocreateAnimationsPlugin()— theme transition animation controlcreateAccessibilityPlugin()— contrast / accessibility enforcementcreateScheduledPlugin()— auto light/dark by solar timecreateDebuggerPlugin()— theme change loggingcreateDevToolsPlugin()— devtools inspector wiringcreateGenerationPlugin()— live theme generation from a seed
Accessibility Toolkit
1import {
2 getContrastRatio,
3 checkContrastPair,
4 validateThemeContrast,
5 simulateCVD,
6 simulateThemeForCVD,
7 getCVDLabel,
8} from "@theme-kit/core";
9
10const ratio = getContrastRatio("#ffffff", "#171123"); // 15.6...
11const ok = checkContrastPair("#fff", "#000", 4.5); // WCAG check
12const audit = validateThemeContrast(theme); // full theme audit
13
14// Color Vision Deficiency simulation (protanopia, deuteranopia, ...)
15const simulated = simulateCVD("#6366f1", "deuteranopia");DOM Adapters
- CSS Variables binding —
createCSSVariablesBinding(store, { prefix, target, transition, styleSheet, layerName }). Writes--theme-*variables inline or into a@layerstylesheet; batches writes and diffs against previously applied variables for minimal DOM churn. - DOM Attribute binding —
createDOMBinding(store, { target, attributeName, transition }). Setsdata-theme,data-theme-family,data-theme-mode, toggles the.darkclass, and setscolor-scheme. - System theme binding —
createSystemThemeBinding(store, { lightTheme, darkTheme })— followsprefers-color-scheme. - Scoped theme binding —
createScopedThemeBinding(themes, target, themeName)— apply a theme to a subtree. - Transitions —
ThemeTransitionOptions:enabled,duration,easing,useViewTransition,properties[](40+ default animated properties). - View Transitions API — native
document.startViewTransitionwhen switching themes.
1import { createCSSVariablesBinding, createDOMBinding } from "@theme-kit/core";
2
3const css = createCSSVariablesBinding(store, { prefix: "theme-" });
4const dom = createDOMBinding(store, {
5 attributeName: "data-theme",
6 transition: { enabled: true, duration: 300 },
7});Multi-Window Sync
Sync theme selection across tabs/windows instantly:
- BroadcastChannel — primary transport (
createThemeSelectionBroadcast) - SharedWorker — inline blob-based worker relay
- StorageEvent fallback —
createStorageEventSync - Auto strategy —
createMultiWindowSync({ prefer: "auto" | "broadcast" | "sharedworker" })picks the best available transport and reports fallbacks - Zero-flicker — transitions are suppressed while applying cross-tab syncs
import { createMultiWindowSync } from "@theme-kit/core";
const sync = createMultiWindowSync({ prefer: "auto" });
// choose the theme in one tab — every other tab follows instantlyScheduled Themes (Solar Time)
Automatically switch between light and dark themes based on actual sunrise/sunset.
1const runtime = createThemeRuntime({
2 scheduled: {
3 // Everything is optional. lightTheme/darkTheme adapt to the currently
4 // selected theme's family (fallback: neutral light/dark), and the
5 // location auto-detects from each visitor's browser timezone.
6 // lightTheme: "mint-light",
7 // darkTheme: "mint-dark",
8 // timeZone: "Asia/Kathmandu",
9 // latitude: 40.7128, longitude: -74.006,
10 checkInterval: 60_000,
11 },
12});calculateSunTimes(date, lat?, lon?) computes NOAA solar events — with no
coordinates it auto-detects the visitor's timezone (via
Intl.DateTimeFormat().resolvedOptions().timeZone). When lightTheme /
darkTheme are omitted, the schedule derives them from the current theme's
family (e.g. plum-dark → plum-light/plum-dark) and falls back to the
neutral light/dark themes, re-resolving when the user switches family.
timeZone / autoDetectLocation / latitude / longitude are all
changeable at runtime with runtime.schedule.set(). skipApplyMs defers
changes briefly after a cross-tab sync.
Persistence
createPersistencePlugin({ key })— full selection persistence (mode + family) as a runtime plugin; default keytheme-selectioncreateThemePersistence({ storage, key })— mode-only persistence (theme-mode), returned as aThemePersistenceAdapterfor manual wiring (not directly assignable to the runtimepersistenceoption)ThemeSelectionPersistenceAdapter— the interface the runtime persistence option expects (get/set/remove/subscribeover{ mode, family }); default runtime persistence stores{ mode, family }undertheme-selectionin localStorage with astorageevent subscription
1import { createPersistencePlugin } from "@theme-kit/core";
2
3// Recommended: full selection (mode + family) persistence
4const runtime = createThemeRuntime({
5 themes,
6 plugins: [createPersistencePlugin({ key: "my-app-theme" })],
7});Bootstrap (Zero Flash of Wrong Theme)
createThemeBootstrapScript({ themes, defaultTheme, initialMode, initialFamily, storageKey, prefix })— generates a blocking inline script that reads the persisted selection, resolves the effective mode (system→prefers-color-scheme), and applies CSS variables + DOM effects before first paint.buildThemeCssMap(themes)— maps theme names andfamily:modekeys to flat CSS variable maps.darkModeCSSTemplate(variables)— a@media (prefers-color-scheme: dark)block so dark-mode users get correct colors even before JS runs.
1import {
2 createThemeBootstrapScript,
3 buildThemeCssMap,
4 darkModeCSSTemplate,
5} from "@theme-kit/core";
6
7const cssMap = buildThemeCssMap(themes);
8const script = createThemeBootstrapScript({ themes, defaultTheme: "light" });
9const fallbackCSS = darkModeCSSTemplate(cssMap["default:dark"] ?? {});Built-in Themes
getBuiltInThemes() bundles everything the library ships:
- Neutral —
light/darkwith a full token scale (spacing, radius, shadows, border widths, z-index, breakpoints, typography) - Preset families — Oat, Berry, Mint, Citrus, Cocoa, Plum, Iris, Sky, Graphite (light + dark)
- Brand presets — Apple, GitHub, Vercel, Slack, Discord (light + dark)
- Accessibility profiles — High Contrast (light/dark) and Large Text (light/dark), tagged
"accessibility"
1import {
2 getBuiltInThemes,
3 getNeutralThemes,
4 getPresetThemes,
5 getBrandPresets,
6 getAccessibilityProfiles,
7} from "@theme-kit/core";
8
9const all = getBuiltInThemes();
10const presets = getPresetThemes(); // nine signature families
11const brands = getBrandPresets(); // five real-world brandsVanilla (No Framework)
@theme-kit/core/vanilla provides the ThemeKit class — framework-free drop-in theming:
1import { ThemeKit } from "@theme-kit/core/vanilla";
2
3const kit = new ThemeKit(); // or ThemeKit.init()
4kit.setMode("dark");
5kit.setFamily("plum");
6kit.toggleTheme();
7kit.update({ colors: { primary: "#07f" } });
8kit.use({ name: "brand", themes: [...] });
9kit.toCSSVariables();
10kit.on("themeChange", (theme) => console.log(theme.name));
11kit.destroy();Events: themeChange, modeChange, familyChange. Exposes .runtime, .registry, .theme, .mode, .family, .themes.
Vite Plugin
@theme-kit/core/vite injects the blocking bootstrap script into index.html (head-prepend) so the persisted theme applies before first paint in client-rendered apps — no manual inline scripts.
1// vite.config.ts
2import { themeKitVitePlugin } from "@theme-kit/core/vite";
3
4export default defineConfig({
5 plugins: [react(), themeKitVitePlugin({ themes: customThemes })],
6});Framework Integrations
The same runtime powers every framework integration:
@theme-kit/react— provider, hooks,ThemeScope,ThemeInspector,ThemeModeButton@theme-kit/next— App Router SSR, cookies, zero-flash hydration@theme-kit/vue,@theme-kit/svelte,@theme-kit/solid,@theme-kit/angular— provider + composables/stores/signals/injectables@theme-kit/web—<theme-kit-provider>,<theme-kit-toggle>,<theme-kit-select>,<theme-kit-scope>custom elements@theme-kit/tailwind— Tailwind CSS v4@thememapping@theme-kit/astro,@theme-kit/nuxt,@theme-kit/remix— island/SSR integrations