Custom Themes
@themvora/core
Themvora ships neutral, preset, brand and accessibility themes. A theme of your own is a plain object of semantic tokens plus metadata — no build step, and nothing to regenerate when you add one.
1Define a theme
import { defineTheme } from "@themvora/core";
export const oceanLight = defineTheme({
name: "ocean-light",
meta: { family: "ocean", mode: "light", label: "Ocean Light" },
tokens: {
colors: {
background: "#f0f9ff",
foreground: "#0c1e2e",
primary: "#0284c7",
primaryForeground: "#ffffff",
secondary: "#e0f2fe",
secondaryForeground: "#0369a1",
muted: "#bae6fd",
mutedForeground: "#075985",
// Surfaces are their own roles, not background reused. A card sits on the
// canvas and a popover sits on top of everything, and both need to be able
// to differ from it — in dark mode especially, where elevation has to come
// from lightness rather than from a shadow.
card: "#ffffff",
cardForeground: "#0c1e2e",
popover: "#ffffff",
popoverForeground: "#0c1e2e",
accent: "#0ea5e9",
accentForeground: "#ffffff",
destructive: "#dc2626",
destructiveForeground: "#ffffff",
success: "#16a34a",
successForeground: "#ffffff",
border: "#7dd3fc",
// The input border is its own role: a form control's edge often wants to
// be firmer than a decorative divider, and ring is the focus colour.
input: "#7dd3fc",
ring: "#0284c7",
},
radius: { lg: "0.5rem" },
},
});family so switching mode stays within your palette. See Core Concepts for how families work.// themes.ts — the same file, continued (defineTheme is imported above).
export const oceanDark = defineTheme({
name: "ocean-dark",
meta: { family: "ocean", mode: "dark", label: "Ocean Dark" },
tokens: {
colors: {
background: "#0c1e2e",
foreground: "#f0f9ff",
primary: "#38bdf8",
primaryForeground: "#0c1e2e",
secondary: "#0369a1",
secondaryForeground: "#e0f2fe",
muted: "#075985",
mutedForeground: "#bae6fd",
// Elevation in dark mode comes from lightness, so the surfaces are
// *lighter* than the canvas rather than shadowed.
card: "#112a3d",
cardForeground: "#f0f9ff",
popover: "#112a3d",
popoverForeground: "#f0f9ff",
accent: "#0ea5e9",
accentForeground: "#0c1e2e",
destructive: "#f87171",
destructiveForeground: "#0c1e2e",
success: "#4ade80",
successForeground: "#0c1e2e",
border: "#0369a1",
input: "#0369a1",
ring: "#38bdf8",
},
radius: { lg: "0.5rem" },
},
});2Extend & compose
import { defineTheme, extendTheme } from "@themvora/core";
const base = defineTheme({
name: "base",
meta: { family: "acme", mode: "light", label: "Acme Base" },
tokens: {
colors: {
background: "#fafafa",
foreground: "#18181b",
primary: "#6366f1",
primaryForeground: "#ffffff",
secondary: "#f4f4f5",
secondaryForeground: "#18181b",
muted: "#f4f4f5",
mutedForeground: "#71717a",
card: "#ffffff",
cardForeground: "#18181b",
popover: "#ffffff",
popoverForeground: "#18181b",
accent: "#f4f4f5",
accentForeground: "#18181b",
destructive: "#dc2626",
destructiveForeground: "#ffffff",
success: "#16a34a",
successForeground: "#ffffff",
border: "#e4e4e7",
input: "#e4e4e7",
ring: "#6366f1",
},
radius: { lg: "10px" },
},
});
export const acmeLight = extendTheme("acme-light", base, {
meta: { family: "acme", mode: "light", label: "Acme Light" },
colors: {
background: "#fafafa",
primary: "#4f46e5",
},
});
export const acmeDark = extendTheme("acme-dark", base, {
meta: { family: "acme", mode: "dark", label: "Acme Dark" },
colors: {
background: "#09090b",
foreground: "#fafafa",
primary: "#818cf8",
ring: "#818cf8",
},
});3Register with the runtime
import { createThemeRuntime } from "@themvora/core";
import { oceanLight, oceanDark } from "./themes";
const themes = [oceanLight, oceanDark];
createThemeRuntime({
themes,
defaultTheme: "ocean-light",
initialMode: "system",
});import { Themvora } from "@themvora/core/vanilla";
import { oceanLight, oceanDark } from "./themes";
const kit = new Themvora({ themes: [oceanLight, oceanDark] });
kit.setFamily("ocean");
kit.setMode("light");
kit.update({ colors: { primary: "#06b6d4" } });
kit.destroy();4Generate from a seed
import { generateTheme, validateTheme } from "@themvora/core";
const { light, dark } = generateTheme({
seed: "#6366f1",
family: "indigo",
});
// Validates the pair together: contrast is checked against the sibling
// theme, so a token that only fails in one mode is still reported.
const result = validateTheme(dark, { themes: [light, dark] });
Generation styles
A style is not a separate algorithm. Every style runs the same generator and the same solver and produces the same semantic contract; a style only modulates a fixed, enumerated set of parameters — surface chroma, primary chroma, the secondary and accent relationships, neutral temperature, border strength and dark-mode depth. Omitting style means balanced, so an existing { seed, family } call keeps working unchanged.
balancedthe reference — moderate chroma, moderate surfacessoftlighter and quieter, with more tinted surfacesvibranthigher primary chroma, split-complementary accenteditorialdarker primary, stronger borders, complementary accentminimalzero surface chroma, restrained primarybrandpreserves the seed's own lightness — the strongest anchor
import { generateTheme } from "@themvora/core";
// The style modulates the palette without changing the contract.
const { light, dark } = generateTheme({
seed: "#6366f1",
family: "indigo",
style: "editorial",
});
// "AA" is the default and the only level implemented today.
const { light: aa } = generateTheme({ seed: "#6366f1", contrast: "AA" });What the generator guarantees
- Deterministic. The same seed, style and options produce byte-identical output. There is no randomness and no time input.
- Light and dark are independent. The dark theme is generated for dark, not inverted from the light one, so the brand hue survives the mode change.
- The contrast contract is met before you get the theme. The published contract is 4.5:1 for text and 3:1 for interactive boundaries and focus rings. The generator aims slightly higher internally so the guarantee survives a small edit, and every semantic pair is solved rather than left to chance.
- Provenance is recorded.
meta.generationcarries the algorithm version, the seed, and the style. The algorithm version is separate from the theme schema version, so a future generator can ship without silently rewriting themes you already generated.
5Presets & brand themes
Default presets
Nine curated families — oat, berry, mint, citrus, cocoa, plum, iris, sky, graphite — each with light + dark. Grab them via getPresetThemes() or restyle with getPresetThemes(overrides).
import { getPresetThemes } from "@themvora/core";
// All nine families, light + dark.
const themes = getPresetThemes();
// Or restyle a family as you take it.
const restyled = getPresetThemes({
plum: {
light: { tokens: { colors: { primary: "#6d28d9" } } },
},
});Brand presets
Brand-inspired starting points — Apple, GitHub, Vercel, Slack, Discord — via getBrandPresets(). Each starts from a colour the brand publishes in its guidelines or design system, pins that value as the identity anchor, and generates the surfaces, borders, interaction roles and status colours around it with the same engine as any other theme. They are examples, not official brand themes.
import { getBrandPresets } from "@themvora/core";
const themes = getBrandPresets();
const brand = themes.filter((t) => t.meta?.family === "github");
createThemeRuntime({ themes: brand, defaultTheme: "github-light" });Overrides are yours
Presets are generated artifacts, so a preset is not a frozen token list you have to maintain — it is a recipe you can restyle. Overrides are applied after generation and are never rewritten for you: the generated baseline satisfies the contract, but an override is free to break it, and it is your call. Run validateTheme() to find out whether it did.
Accessibility profiles
High-contrast and large-text variants ship via getAccessibilityProfiles() and are tagged "accessibility" for filtering.
6Use your custom theme
import { createRoot } from "react-dom/client";
import { ThemeProvider } from "@themvora/react";
import { oceanLight, oceanDark } from "./themes";
import { App } from "./App";
const themes = [oceanLight, oceanDark];
createRoot(document.getElementById("root")!).render(
<ThemeProvider themes={themes} defaultTheme="ocean-light">
<App />
</ThemeProvider>,
);import { useProvidedThemes, useSetThemeFamily } from "@themvora/react";
export function App() {
const families = useProvidedThemes();
const setFamily = useSetThemeFamily();
return (
<main>
<select
value={families[0]?.family ?? ""}
onChange={(e) => setFamily(e.target.value)}
>
{families.map((f) => (
<option key={f.family} value={f.family}>
{f.family}
</option>
))}
</select>
</main>
);
}oceanLight and oceanDark themes come from section 1. Register both, set ocean-light as the default, and mode toggling stays inside your family. See Framework Guides for a deeper walkthrough of each integration.