Advanced Features
@theme-kit/core
Deep dives into live theme generation, validation, migration, token resolution, plugin authoring, runtime snapshots, and accessibility profiles.
Live Theme Generation
generateTheme() produces a complete light + dark theme pair from a single seed hex color. It converts the seed to HSL, then derives secondary, muted, accent, border, and ring colors by adjusting saturation and lightness — low saturation for muted tones, shifted hue for accent, and the raw seed for primary/ring.
1import { generateTheme, mergeTokens } from "@theme-kit/core";
2
3const { light, dark } = generateTheme({ seed: "#6366f1", family: "indigo" });
4
5// Override defaults — mergeTokens deep-merges partial tokens into the generated theme
6const customLight = {
7 ...light,
8 tokens: mergeTokens(light.tokens, {
9 radius: { lg: "14px" },
10 colors: { destructive: "#dc2626" },
11 }),
12};The family parameter controls the theme name prefix ("indigo-light" / "indigo-dark"); omit it and the default is "generated". Contrast-aware foregrounds are computed via WCAG relative luminance — primaryForeground gets white or dark ink based on the seed's lightness.
Theme Validation
validateTheme() checks that every required semantic color token is defined. Pass a theme list to resolve extends chains before validation.
import { validateTheme } from "@theme-kit/core";
const result = validateTheme(childTheme, { themes: [baseTheme, childTheme] });
// { valid: boolean, issues: [{ type: "missing", path: "colors.destructive", message: "..." }] }The required color keys are: background, foreground, card, cardForeground, popover, popoverForeground, primary, primaryForeground, secondary, secondaryForeground, muted, mutedForeground, accent, accentForeground, destructive, destructiveForeground, success, successForeground, border, input, ring.
Use in CI:
1import { getBuiltInThemes, validateTheme } from "@theme-kit/core";
2
3const themes = getBuiltInThemes();
4const failures = themes
5 .map((t) => ({ name: t.name, ...validateTheme(t) }))
6 .filter((r) => !r.valid);
7
8if (failures.length > 0) {
9 console.error("Validation failures:", failures);
10 process.exit(1);
11}Theme Migration
Version your themes with a linear migration chain. registerMigration() declares a step between two versions; migrateTheme() walks the chain from the current version to the target.
1import { registerMigration, migrateTheme } from "@theme-kit/core";
2
3registerMigration({
4 from: "1.0.0",
5 to: "2.0.0",
6 description: "Rename primaryColor to primary",
7 remapColors: [{ from: "primaryColor", to: "primary" }],
8});
9
10registerMigration({
11 from: "2.0.0",
12 to: "3.0.0",
13 description: "Add success token",
14 migrate(theme) {
15 return {
16 ...theme,
17 tokens: {
18 ...theme.tokens,
19 colors: {
20 ...theme.tokens?.colors,
21 success: "#22c55e",
22 successForeground: "#ffffff",
23 },
24 },
25 };
26 },
27});
28
29const migrated = migrateTheme(oldTheme, { targetVersion: "3.0.0" });remapColors handles simple key renames; migrate handles arbitrary transforms. The chain is walked linearly (max 20 steps) — the from version of each step must equal the to of the previous one.
Token Resolution Deep Dive
Tokens support three kinds of dynamic values, resolved lazily by resolveTokens():
References — $colors.primary or {colors.primary} point to another token. Circular references throw.
1tokens: {
2 colors: {
3 primary: "#6366f1",
4 ring: "$colors.primary",
5 cardForeground: "{colors.primary}",
6 },
7}Expressions — numeric math evaluated at resolve time.
spacing: { "12": "calc(3rem + 0.75rem)" }Derived colors — contrast() returns black/white by WCAG luminance; auto() derives a readable foreground from a sibling.
1tokens: {
2 colors: {
3 primary: "#6366f1",
4 primaryForeground: "auto()", // white or dark ink based on primary
5 muted: "#f1f5f9",
6 mutedForeground: "contrast(muted)", // black on light, white on dark
7 },
8}Resolution walks the token tree — references first, then expressions, then derived calls. Utilities:
1import { flattenTokens, resolveFlatTokens, resolveTokens, hasTokenReferences } from "@theme-kit/core";
2
3const flat = flattenTokens(myTheme.tokens); // { "colors.primary": "#6366f1", ... }
4const resolved = resolveFlatTokens(flat); // resolves all refs in place
5const deep = resolveTokens(myTheme.tokens); // returns a new ThemeTokens tree
6const hasRefs = hasTokenReferences("$colors.primary"); // truePlugin Authoring
Plugins implement the ThemePlugin interface and are registered via the plugins option or runtime.registry.use().
1import type { ThemePlugin } from "@theme-kit/core";
2
3const watermarkPlugin: ThemePlugin = {
4 name: "watermark",
5 priority: 10, // lower runs first
6
7 onRuntimeCreated(runtime) {
8 // access runtime.store, runtime.selection, etc.
9 },
10
11 transformTokens(tokens, { theme }) {
12 // inject a derived token into every theme
13 return {
14 ...tokens,
15 colors: {
16 ...tokens.colors,
17 watermark: `${tokens.colors?.primary ?? "#000"}22`, // 10% opacity
18 },
19 };
20 },
21
22 onAfterThemeChange({ theme }) {
23 console.log(`[watermark] applied: ${theme.name}`);
24 },
25
26 onDestroy() {
27 // cleanup
28 },
29};Priority controls execution order — lower numbers run first. transformTokens is called for every theme change and receives the merged token tree; it must return the modified tree. Plugins can be added/removed at runtime:
import { createThemeRuntime } from "@theme-kit/core";
const runtime = createThemeRuntime({
plugins: [watermarkPlugin],
});runtime.update()
update() merges partial tokens into the current live theme without replacing it. The merge is deep — nested color objects are merged recursively, then all plugins' transformTokens run, and the result is resolved.
1// live-edit: override just the primary color
2runtime.update({
3 colors: { primary: "#f97316" },
4});
5
6// override multiple groups at once
7runtime.update({
8 colors: { primary: "#f97316", accent: "#eab308" },
9 radius: { lg: "20px" },
10});This triggers the full lifecycle: beforeThemeChange → store update → beforePersist → afterPersist → afterThemeChange.
runtime.snapshot() / restore()
Capture the entire runtime state — current theme, selection, history, and registry — then restore it later. Useful for undo/redo implementations, test fixtures, or time-travel debugging.
1// capture
2const snap = runtime.snapshot();
3// snap: { theme, selection: { mode, family }, history: [...], registry: { themes: [...] } }
4
5// ... user makes changes ...
6
7// restore
8runtime.restore(snap);Both use structuredClone internally, so the snapshot is a deep copy — mutations to the snapshot don't affect the live runtime.
runtime.batch()
Batch coalesces multiple store writes into a single lifecycle cycle. Without batch, each set or update triggers the full event chain.
1runtime.batch(() => {
2 runtime.update({ colors: { primary: "#f97316" } });
3 runtime.selection.setFamily("ocean");
4 runtime.selection.setMode("dark");
5});
6// single beforeThemeChange / afterThemeChange pairIf any intermediate state would be invalid, the batch still completes — there's no rollback. Use it when you know the final state is correct but don't want intermediate renders.
Theme Packs
A theme pack is a named bundle of themes. runtime.use() installs the pack — every theme it contains is tagged pack:<name> in its meta.
1const brandPack = {
2 name: "brand",
3 themes: [
4 defineTheme({ name: "brand-light", meta: { family: "brand", mode: "light" }, tokens: { ... } }),
5 defineTheme({ name: "brand-dark", meta: { family: "brand", mode: "dark" }, tokens: { ... } }),
6 ],
7};
8
9runtime.use(brandPack);
10
11// themes are now available for selection
12runtime.selection.setFamily("brand");
13runtime.selection.setMode("dark");Under the hood, runtime.use() delegates to registry.use(), which registers every theme in the pack and stamps the pack:brand tag. The registry's getThemesByFamily and getFamilies methods immediately reflect the new themes.
Accessibility Profiles
getAccessibilityProfiles() returns pre-built high-contrast and large-text themes. simulateCVD() and simulateThemeForCVD() model how a theme appears under color vision deficiencies.
1import {
2 getAccessibilityProfiles,
3 simulateCVD,
4 simulateThemeForCVD,
5 getCVDLabel,
6 validateThemeContrast,
7} from "@theme-kit/core";
8
9// pre-built accessibility themes
10const profiles = getAccessibilityProfiles();
11// [high-contrast-light, high-contrast-dark, large-text-light, large-text-dark]
12
13// simulate how a color looks under deuteranopia
14const simulated = simulateCVD("#6366f1", "deuteranopia");
15
16// simulate an entire theme
17const deutTheme = simulateThemeForCVD(myTheme, "deuteranopia");
18
19// audit every foreground/background pair
20const audit = validateThemeContrast(myTheme);
21// { valid: boolean, checks: [{ foregroundToken, backgroundToken, ratio,
22// passesAANormal, passesAALarge, passesAAANormal, passesAAALarge }] }CVD types: "protanopia" (red-blind), "deuteranopia" (green-blind), "tritanopia" (blue-blind), "achromatopsia" (total color blindness).
Use validateThemeContrast in CI to catch regressions:
1const result = validateThemeContrast(theme);
2if (!result.valid) {
3 for (const check of result.checks.filter((c) => !c.passesAALarge)) {
4 console.error(
5 `${check.foregroundToken} on ${check.backgroundToken}: ` +
6 `${check.ratio.toFixed(2)}:1 (fails AA large text)`,
7 );
8 }
9 process.exit(1);
10}