Theming
Understand Blenx's three-layer theme architecture—contract, tokens, themes—and learn how to create custom themes without fighting the framework.
Theme Architecture#
Blenx organizes theming in three layers: contract, tokens, and themes. This separation enforces a clean boundary between design decisions and component implementations.
contract.css.ts → createThemeContract (variable names only, the "API")
tokens.css.ts → design token primitives (raw values: spacing, fonts, shadows)
light-theme.css.ts → createTheme (maps values to contract variables for light/dark modes)
The Contract#
The contract (contract.css.ts) uses createThemeContract() from vanilla-extract to declare every theme variable without assigning any values. It is a schema, not a theme. Components import only from the contract—never from a theme file. This means a component's styles say "use text.primary" instead of "use #223042". The component does not know or care what value resolves at runtime.
The contract exposes two top-level objects: semanticVars (named color tokens) and tokenVars (design tokens like spacing, fonts, shadows, etc.).
// contract.css.ts
import { createThemeContract } from "@vanilla-extract/css";
export const semanticVars = createThemeContract({
background: { default: null, subtle: null },
surface: { default: null, raised: null, overlay: null, floating: null },
text: { primary: null, secondary: null, disabled: null, inverse: null },
border: { default: null, subtle: null, strong: null },
focus: { ring: null },
color: {
primary: {
default: null,
hover: null,
active: null,
bg: null,
bgHover: null,
bgActive: null,
fg: null,
text: null,
textActive: null,
border: null,
},
// ... secondary, neutral, success, warning, danger, info
},
});
The nested keys become CSS custom property names (--text-primary, --color-primary-bg, etc.). Every variable must be assigned a value by a theme before it resolves to something meaningful.
The Tokens#
Design tokens (tokens.css.ts) are raw primitive values: colors, spacing, font sizes, radii, shadows, etc. They are plain const objects, not CSS. Tokens serve as the source of truth for raw values but are never referenced directly in component styles—they are consumed by theme definitions.
// tokens.css.ts
export const borderRadius = {
small: "4px",
medium: "8px",
large: "12px",
full: "999px",
} as const;
export const fontSize = {
small: "14px",
medium: "16px",
large: "18px",
} as const;
export const spacing = {
none: "0px",
small: "12px",
medium: "16px",
large: "24px",
// ...
} as const;
Guideline: Component styles reference only contract variables. Raw token values belong in theme definition files only.
The Themes#
Themes are created with createTheme() from vanilla-extract. Each theme maps every contract variable to a concrete value.
// light-theme.css.ts
import { createTheme } from "@vanilla-extract/css";
import { themeContract } from "./contract.css";
export const [themeClass, themeVars] = createTheme(themeContract, {
primary: "#1c1917",
contentPrimary: "#1c1917",
contentSecondary: "#57534e",
surface: "#ffffff",
background: "#f5f3ef",
border: "#e2ddd6",
borderRadius: "12px",
// ...
});
createTheme() returns a tuple: [themeClass, themeVars]. themeClass is a CSS class name that, when applied to an element, sets all contract CSS variables to the provided values. themeVars is a typed object mirroring the contract shape, useful for scoped overrides.
Multiple themes coexist by producing different CSS classes:
export const [themeClass, themeVars] = createTheme(themeContract, {
/* light values */
});
export const darkThemeClass = createTheme(themeContract, {
/* dark values */
});
Design Tokens vs Semantic Tokens#
Design tokens are raw values: #ffffff, 16px, 0.5. Semantic tokens are contextual names: contentPrimary, borderRadius, surface. The distinction matters because semantic tokens encode intent, not value.
A button uses themeContract.primary for its background. When you introduce a "high contrast" theme, themeContract.primary maps to a different hex value. The button changes without being touched. If the button had used #1c1917 directly, you would need to override every instance.
Guideline: Component styles should reference only semantic tokens from the contract. Raw values belong in theme definition files only.
Color System#
Light and dark themes each define a full palette. The contract exposes semantic groups:
text.primary/text.secondary/text.disabled/text.inverse— text colorsbackground.default/background.subtle— page background layerssurface.default/surface.raised/surface.overlay/surface.floating— component surface layersborder.default/border.subtle/border.strong— border colorscolor.primarythroughcolor.info— each withdefault,hover,active,bg,bgHover,bgActive,fg,text,textActive,bordersub-keysfocus.ring— focus outline color
The dark theme inverts these relationships. Components never detect dark mode—they reference semanticVars.text.primary and the active theme class provides the correct value.
Typography Scale#
The tokenVars contract defines fontSize, fontWeight, lineHeight, letterSpacing, and font as CSS custom properties. Default values live in tokenVarsDefaults from @blenx-dev/theme/tokens. Individual themes reference these in createTheme:
createTheme(tokenVars, {
fontSize: { md: "16px" },
});
Components read the resolved value through tokenVars.fontSize.md. Font families are defined in tokenVarsDefaults, not in components. This lets you swap from "DM Sans" to your brand font by editing one file.
Radius, Shadows, and Spacing#
Layout tokens follow the same discipline:
borderRadius— consistent corner rounding viathemeContract.borderRadiusshadowSm,shadowMd,shadowLg,shadowXl— elevation hierarchy- Spacing is handled through
Stack,Grid, andBoxprops, not magic numbers
Never reference border-radius: 8px in a component file. Use themeContract.borderRadius. If your design system pivots from rounded to sharp corners, you change one line instead of fifty.
Theme Switching#
Blenx uses a zustand store (useThemeStore) to manage the active theme, persisted to localStorage under the key "theme". An inline <script> in the document <head> reads this value and sets color-scheme on <html> before React hydrates to prevent flash of unstyled content.
The theme toggle swaps classes on <html>:
document.documentElement.classList.remove(lightTheme, darkThemeClass);
document.documentElement.classList.add(nextTheme);
A <meta name="color-scheme" content="dark light"> tag ensures native elements respect the theme.
Using Contract Variables in Components#
Components reference the contract inside style() calls:
import { style } from "@vanilla-extract/css";
import { semanticVars, tokenVars } from "@blenx-dev/theme/contract";
export const root = style({
backgroundColor: semanticVars.surface.default,
color: semanticVars.text.primary,
border: `1px solid ${semanticVars.border.default}`,
borderRadius: tokenVars.borderRadius.md,
});
Apply styles with standard className:
<div className={root}>...</div>
Creating Custom Themes#
To add a brand theme:
- Create a new theme with
createTheme(contract, { ... }) - Import the resulting class in your app
- Apply the class to
<html>(or a subtree) viaclassList
// my-brand-theme.css.ts
import { createTheme } from "@vanilla-extract/css";
import { semanticVars } from "@blenx-dev/theme/contract";
export const brandTheme = createTheme(semanticVars, {
background: { default: "#faf9f6", subtle: "#f0ede6" },
surface: {
default: "#ffffff",
raised: "#fefcf9",
overlay: "rgba(0,0,0,0.4)",
floating: "#ffffff",
},
text: { primary: "#1a1a2e", secondary: "#4a5568", disabled: "#a0aec0", inverse: "#ffffff" },
border: { default: "#e2e8f0", subtle: "#edf2f7", strong: "#cbd5e0" },
focus: { ring: "#4A90D9" },
color: {
primary: {
default: "#ff6b35",
hover: "#e85d2c",
active: "#d04f23",
bg: "#fff0ea",
bgHover: "#ffe1d5",
bgActive: "#ffd2c0",
fg: "#ffffff",
text: "#ff6b35",
textActive: "#e85d2c",
border: "#ff6b35",
},
},
});
Then apply it:
import { brandTheme } from "./my-brand-theme.css";
document.documentElement.classList.add(brandTheme);
You can scope themes by applying the theme class to any container element. Theme inheritance works through CSS cascade—child elements resolve to the nearest ancestor with a theme class.
Common Pitfalls#
- Hardcoding values in components. Resist typing
color: '#333'in a component file. It creates a leaky abstraction that bypasses theming. Use a contract variable or add a new one to the contract. - Putting component-specific values in the contract. If only one component needs a token, keep it local. The contract is for shared design language, not per-component quirks.
- Mutating theme objects.
createThemeoutput is statically extracted at build time. Do not programmatically merge or override theme objects at runtime—vanilla-extract does not support it. - Forgetting to apply the theme class. Contract variables without an active theme class produce empty string values. Your app root must add the theme class to the document element.
- Semantic vs token contracts. Use
semanticVarsfor named color tokens (background, surface, text, border, color roles) andtokenVarsfor design tokens (spacing, fonts, shadows, radii, durations). The two contracts serve different purposes and are imported from the same module:@blenx-dev/theme/contract.