Concepts
Theming
Every component in Vesture is written against a single contract: vars, from @vesture/tokens. No component file references a hex code, a pixel value, or a font stack directly — it references vars.color.primary, vars.space.md, vars.font.display, and so on. A theme is just a concrete value for every slot in that contract.
See it in action
This panel is real @vesture/react — the same components documented throughout this site. Toggling the theme here also toggles the theme used in every live demo on the component reference pages, using the same toggle shown beside each preview.
Same components, same code — only the theme class on <html> changed.
Create Security Key
Permissions
// compiler metrics
Active stylesheet
vesture-theme-default.css
CSS variables injected
65 root variables
Runtime script added
0kb — compiled at build time
The contract
@vesture/tokens defines the contract with vanilla-extract's createThemeContract — color, space, radius, shadow, border, font, and motion slots, each just a name with no value:
export const vars = createThemeContract({
color: { background: null, surface: null, primary: null, /* … */ },
space: { xs: null, sm: null, md: null, lg: null, xl: null, "2xl": null },
radius: { none: null, sm: null, md: null, lg: null, full: null },
shadow: { none: null, sm: null, md: null, lg: null, focus: null },
font: { display: null, body: null, mono: null, /* size/weight/line-height */ },
motion: { durationFast: null, durationNormal: null, easing: null },
chart: {
series1: null, /* … series2–series8 */
grid: null, axis: null,
tooltipBackground: null, tooltipText: null, tooltipBorder: null,
emptyState: null
}
});Chart tokens are a group of their own
chartis a slot group like any other — it's enforced by the same createTheme completeness check, and Line, Bar, Area, and Pie charts read it exactly the way Button reads vars.color.primary. What's different is the contentof a couple of its slots, and that's worth calling out explicitly if you're writing a custom theme:
- series1–series8 is a fixed-order palette, not eight independent colors. A chart assigns series1 to the first series, series2 to the second, and so on — never reassigning on filter, so a series keeps its color when a sibling is hidden. A 9th+ series wraps back to series1. This means the eight values need to work as a set: mutually distinguishable from each other (including under common color-vision deficiencies — don't lean on red/green alone to carry meaning between adjacent slots), and distinct from your theme's
success/warning/dangercolors, so a chart series is never mistaken for a status indicator. - grid and axis should recede, not compete. They're chrome, not data — low-contrast against
color.backgroundis correct, unlike most other text/line tokens in the contract which need to stay legible. - tooltipBackground / tooltipText / tooltipBorder exist as their own slots — separate from
color.surfaceRaisedand Popover's styling — purely so a chart's floating tooltip can be themed on its own if you want it to, but the shipped themes just echo the same floating-surface treatment Popover uses (surface + border +shadow.md) for visual consistency. - emptyStateis a single muted color for "no data" placeholder text — both shipped themes just reuse their
color.textMutedvalue rather than inventing a separate gray.
None of this changes howyou write a theme — it's the same createTheme(vars, { …every slot… })call below, chart group included. It's only the palette-design constraints on what values to pick for the eight series slots that are genuinely different from filling in a single color like primary.
Implementing a theme
@vesture/theme-retro is a real second theme, built by a different maintainer than the components themselves, using nothing but that contract:
import { createTheme } from "@vanilla-extract/css";
import { vars } from "@vesture/tokens";
export const retroThemeClass = createTheme(vars, {
color: {
background: "#faf3e8",
surface: "#f2e6d3",
text: "#3b2a1a",
primary: "#c1502e",
// …every slot in the contract needs a value
},
// space, radius, shadow, font, motion…
chart: {
series1: "#1f6fa8", series2: "#1f8a6f", /* … series3–series8 */
grid: "#e2d3ba", axis: "#b5a487",
tooltipBackground: "#fffdf8", tooltipText: "#3b2a1a", tooltipBorder: "#e2d3ba",
emptyState: "#8a7561" // same value as color.textMuted
}
});TypeScript enforces completeness — createThemewon't compile until every slot the contract declares has a value, so a new theme can't accidentally leave a component unstyled.
Structural flourishes beyond tokens
Every token in the contract is a value— a color, a length, a font stack. Some theme ideas aren't values at all: a hover rotation, a fixed tilt, a background texture. For that, @vesture/react/theme-hooksexports a small, curated set of component class names a theme package can target directly with vanilla-extract's globalStyle — deliberately separate from the component prop API, and treated as its own breaking-change surface. Today it covers two components, Button and Card.
@vesture/theme-retro uses it for a hand-stamped hover wobble on Button, and a fixed tilt plus a paper-grain texture on Card — both scoped under retroThemeClass as an ancestor selector, so they only ever apply when the retro theme is active:
These two effects only exist in the retro theme — switch to see them.
A slight stamp-like rotation on hover, on top of the usual color change.
A fixed tilt plus a paper-grain background.
Fixed, not random — a random value here would mismatch between server and client render.
import { globalStyle } from "@vanilla-extract/css";
import { vars } from "@vesture/tokens";
import { button, card } from "@vesture/react/theme-hooks";
import { retroThemeClass } from "./retro-theme.css";
// "${retroThemeClass} .${button}" is a compound selector (two
// classes), so it only matches an element that has both — i.e.
// only when the retro theme is active on an ancestor.
globalStyle(`${retroThemeClass} .${button}:hover:not(:disabled)`, {
transform: "rotate(-1deg)",
transitionProperty: "background-color, border-color, color, box-shadow, transform",
transitionDuration: vars.motion.durationFast,
transitionTimingFunction: vars.motion.easing
});
globalStyle(`${retroThemeClass} .${card}`, {
// Fixed, not Math.random() — a random value here would produce
// a server/client mismatch under SSR.
transform: "rotate(-0.5deg)",
backgroundImage: `url("${paperGrainDataUri}")`
});Because this reaches into @vesture/react's own classes, a theme package that uses it takes @vesture/react as a peer dependency — a change from earlier versions of @vesture/theme-retro, which only depended on @vesture/tokens. If you already have @vesture/reactinstalled (you do, if you're rendering any components), nothing else changes: retroThemeClass is imported and applied to <html> exactly the same way described below.
Applying a theme
Put the theme's class on <html>, not an inner wrapper. Modal, Tooltip, Popover, and DropdownMenu render through a React portal straight into document.body— CSS custom properties only cascade down the DOM tree they're set on, so a class on a wrapper <div> never reaches those portaled nodes. The class on <html> does, because portaled content still lives inside <html>.
import { defaultThemeClass } from "@vesture/tokens";
// or: import { retroThemeClass } from "@vesture/theme-retro";
<html className={defaultThemeClass}>
<body>{children}</body>
</html>Runtime theme switching
Because a theme is just a class name, switching themes at runtime — a light/dark toggle, a per-tenant brand theme, a user preference — is a single classList swap, no re-render of the component tree required:
function toggleTheme(next: "default" | "retro") {
document.documentElement.classList.remove(defaultThemeClass, retroThemeClass);
document.documentElement.classList.add(next === "default" ? defaultThemeClass : retroThemeClass);
}