-
Notifications
You must be signed in to change notification settings - Fork 27
Theming Infrastructure
Astryx theming is built on a single principle: a theme config is the codified representation of a design system. The defineTheme() function takes a declarative config and produces outputs consumable by two different paths.
For related docs, see:
- API Conventions — Component API rules (naming, structure, composition)
- Night Watch Component Auditor — Automated nightly enforcement of theming and API conventions
⚠️ Keep in sync. The Night Watch Component Auditor enforces theming conventions automatically. When updating rules here, update the corresponding auditor check. When the auditor finds a gap not documented here, add it.
defineTheme() input
┌───────────────────────────────┐
│ name: 'ocean' │
│ color: { accent: '#0077B6' } │
│ typography: { scale, body } │
│ motion: { fast, medium } │
│ tokens: { --color-*, ... } │
│ components: { button: {...} }│
│ fonts: [{ family, url }] │
│ icons: { ... } │
└──────────┬────────────────────┘
│
┌──────────▼────────────────────┐
│ Higher-Order Transforms │
│ │
│ color ───────► accent, bg, │
│ surface, border│
│ text tokens │
│ typography ──► text/heading │
│ size tokens │
│ motion ──────► duration │
│ min/max tokens │
│ │
│ Explicit tokens override │
│ generated values (highest │
│ precedence) │
└──────────┬────────────────────┘
│
┌──────────▼────────────────────┐
│ DefinedTheme │
│ │
│ name: string │
│ tokens: Record<string,string>│
│ components: ComponentStyles │
│ fonts: ThemeFontSource[] │
│ icons: IconRegistry │
│ css: string (generated) │
└──────────┬────────────────────┘
│
┌─────────────┴───────────────┐
│ │
┌──────────▼───────────┐ ┌───────────▼───────────┐
│ Runtime Path │ │ Built Path │
│ │ │ │
│ <Theme │ │ xds theme build │
│ theme={ocean}> │ │ → ocean.css │
│ │ │ → ocean.js │
│ Injects <style> at │ │ → ocean.d.ts │
│ runtime with CSS │ │ │
│ custom properties │ │ Static CSS file with │
│ on :scope │ │ all tokens + @scope │
│ │ │ component overrides + │
│ Good for: dev, │ │ variant type defs │
│ dynamic themes, │ │ │
│ theme switching │ │ Good for: production,│
└──────────────────────┘ │ CDN delivery, SSR, │
│ zero runtime cost │
└───────────────────────┘
Both paths produce the same CSS custom properties. Components don't know or care which path delivered the tokens.
| Group | Prefix | Examples |
|---|---|---|
| Color | --color-* |
--color-accent, --color-text-primary, --color-border
|
| Spacing | --spacing-* |
--spacing-1 through --spacing-12
|
| Size | --size-* |
--size-xs through --size-2xl
|
| Radius | --radius-* |
--radius-0 through --radius-4, --radius-rounded
|
| Shadow | --shadow-* |
--shadow-sm, --shadow-md, --shadow-lg
|
| Duration | --duration-* |
--duration-fast, --duration-medium
|
| Ease | --ease-* |
--ease-default, --ease-emphasized
|
| Typography |
--font-*, --text-*, etc. |
--font-body, --text-base, --leading-normal
|
All color tokens use light-dark() for automatic dark mode:
'--color-accent': 'light-dark(#0064E0, #2694FE)'Four tokens cover "gray" backgrounds. Choosing the right one depends on what the element is, not what shade you want:
| Token | Role | Use for |
|---|---|---|
--color-secondary |
Neutral fill for self-contained elements | Buttons (default), badges, tokens, kbd, avatar fallback, pagination dots, status dots, selected nav items, icon containers in menus |
--color-muted |
Background for containers with content inside | Sections, code blocks, table zebra stripes, disabled input fills, featured cards |
--color-track |
Slim channel affordances read against body luminance | Slider/ProgressBar tracks, Switch off-state, Spinner/CircularProgress rails |
--color-overlay-hover |
Translucent hover/active feedback | Hover on ghost buttons, list/menu item highlights, table row hover. Always applied via @media (hover: hover) or keyboard-highlight state |
Decision tree:
- Is it a hover, active, or keyboard-highlighted state? →
--color-overlay-hover - Is it a slim track/rail (slider, progress, switch)? →
--color-track - Is it a container with other content inside (text, icons, controls)? →
--color-muted - Is it a self-contained element sitting on a surface? →
--color-secondary
--color-washis reserved for page-level backgrounds only (AppShell body, nav areas). It should never appear on individual components — use--color-mutedor--color-secondaryinstead.
Note:
--color-overlay-hoverand--color-mutedmay have similar hex values in some themes. They are semantically different — overlay-hover is translucent and composites on top of any background; muted is a resting fill for content areas. Themes can (and do) differentiate them independently.
Instead of manually setting every token, themes can use scale configs that generate related tokens from a few parameters:
color: { accent: '#0064E0', neutralStyle: 'cool', contrast: 'standard' }Derives a full color palette from a single accent hex using the HCT perceptual color model — accent shades, backgrounds, surfaces, borders, and text colors for both light and dark modes. Status colors and fixed tokens (on-dark/on-light) use defaults.
typography: { scale: { base: 14, ratio: 1.2 } }Generates --text-* and heading sizes using a geometric progression. Typography also configures font families, weights, and roles (body, heading, code).
radius: { base: 4, multiplier: 1 }Generates --radius-1 through --radius-4. Setting multiplier: 0 gives a sharp/brutalist look.
motion: { fast: 175, medium: 410, ratio: 0.75 }Generates duration min/max variants.
Precedence: Explicit tokens always override scale-generated values.
The expected cascade for any visual property on a component, from lowest to highest priority:
-
Component base — the default look from
stylex.create()in the component source -
Theme base overrides —
defineThemecomponent overrides targetingbase - Component variant/prop styles — size, color, orientation from component props
-
Theme variant overrides —
defineThemeoverrides targeting specific variants or states -
Consumer overrides —
xstyle,className, or external CSS from the builder
Every theming decision should be evaluated against this model. A mechanism that breaks this ordering (e.g. a theme base override winning over a consumer's xstyle) is a bug, not a feature.
Current implementation: CSS layers partially express this (@layer stylex < @layer xds.theme < unlayered consumer styles), but layers can't distinguish between levels 1/3 or 2/4 within the same layer. Component CSS vars sidestep the cascade entirely — they don't participate in specificity or layer conflicts. This is an area of active work.
Astryx provides three mechanisms for per-component theming, each for a different use case.
Every component and its visually distinct sub-elements render stable CSS class names. Theme authors override any CSS property directly on these targets.
Rule: xdsThemeProps should only be applied on visible elements that have base styles. Do not apply it on structural wrappers (display: contents, layout containers) or elements that exist only for positioning/event handling.
defineTheme({
name: 'meta',
components: {
// Base styles for all buttons
button: {
base: { fontWeight: '600', borderRadius: 'var(--radius-rounded)' },
// Variant-specific — targets .astryx-button.secondary
'variant:secondary': { backgroundColor: 'rgba(0,0,0,0.06)' },
},
// Sub-element: radio circle
radio: {
base: { borderWidth: '2px', borderColor: 'var(--color-border-emphasized)' },
// State-specific — targets .astryx-radio.checked
checked: { borderColor: 'var(--color-accent)', backgroundColor: 'var(--color-accent)' },
disabled: { opacity: '0.5' },
},
// Sub-element: switch track
switch: {
base: { backgroundColor: 'var(--color-gray-background)' },
checked: { backgroundColor: 'var(--color-positive)' },
},
// Sub-element: calendar day
'calendar-day': {
today: { fontWeight: '700', color: 'var(--color-accent)' },
selected: { backgroundColor: 'var(--color-accent)', color: 'var(--color-accent-text)' },
},
// Sub-element: progressbar fill per variant
'progressbar-fill': {
'variant:positive': { backgroundColor: 'var(--color-positive)' },
'variant:warning': { backgroundColor: 'var(--color-warning)' },
},
// Sub-element: banner icon per status
'banner-icon': {
'status:error': { color: 'var(--color-negative)' },
},
},
});Key syntax:
-
base→ targets the class always -
variant:valueorprop:value→ targets the class + variant class (e.g..astryx-button.secondary) -
stateName→ targets the class + state class (e.g..astryx-radio.checked) -
state+state→ compound state (e.g..astryx-radio.checked.disabled) -
variant:value+state→ variant + state (e.g..astryx-button.destructive.disabled)
Combining variants and states:
defineTheme({
name: 'meta',
components: {
button: {
// All destructive buttons
'variant:destructive': { backgroundColor: 'var(--color-negative)' },
// Destructive + disabled
'variant:destructive+disabled': { backgroundColor: 'var(--color-muted)', opacity: '0.5' },
},
radio: {
// Checked + disabled
'checked+disabled': { borderColor: 'var(--color-border)', backgroundColor: 'var(--color-muted)' },
},
},
});These generate @scope rules targeting stable .astryx-* class names, scoped under the theme's data-astryx-theme attribute.
Components expose CSS custom properties for values that participate in calc() expressions or need to cascade to children. There are two kinds:
Some component vars are marked private: true in their doc file. These are internal implementation details managed by the derived var expansion pipeline. Theme authors don't set them directly — they write standard CSS properties instead:
defineTheme({
name: 'rounded',
components: {
// borderRadius is expanded into the card's internal radius var automatically
// padding is expanded into container layout tokens
card: { base: { borderRadius: '20px', padding: '24px' } },
'dropdown-menu': { base: { borderRadius: '16px', padding: '8px' } },
},
});The pipeline reads derived entries in each component's .doc.mjs and the compiled derivedVarRegistry.ts to know which CSS properties expand into which internal vars. xds theme build errors if a theme tries to set a private var directly.
Concentric radius falls out naturally — child elements read the parent's internal radius and padding vars via calc(), and theme authors never need to know about it.
Some vars don't map to any standard CSS property. These are marked as public in their doc file and set directly in component overrides:
defineTheme({
name: 'meta',
components: {
button: {
base: {
'--button-press-scale': 'scale(0.95)',
'--button-disabled-opacity': '0.4',
},
},
},
});Run npx xds component <Name> to see which vars a component exposes. The CLI shows public vars that can be set directly and derived CSS properties that expand into private vars.
Container components (Card, Section, Dialog) have expand: 'container' in their derived entries. Writing padding on these components expands into downstream container tokens that drive padding, edge-to-edge dividers, and layout bleed. Padding longhands (paddingBlock, paddingInline, etc.) are also supported. See Container Padding System for the full token flow.
Component CSS variables follow this pattern:
--[_]<component>[-<part>]-<property>
-
<component>— lowercase component name without Astryx prefix, matchingxdsThemePropsoutput (e.g.button,dropdown-menu,segmented-control) -
<part>(optional) — sub-element within the component (e.g.composerinchat-composer,icon-onlyinbutton-icon-only) -
<property>— what the var controls (e.g.radius,padding,opacity) -
--_prefix — private vars use the underscore prefix; public vars omit it
Examples:
-
--_card-radius— private, Card component, radius property -
--_chat-composer-padding— private, Chat component, composer part, padding property -
--button-press-scale— public, Button component, press-scale property
- Name the var following the convention above
- Add it to StyleX styles with
private: truein the doc if it's derivable from a standard CSS property - Document it in the component's
.doc.mjstheming.vars[] - If derivable, add a
derivedentry in the doc andderivedVarRegistry.ts - The consistency test catches drift between source, docs, and registry
Themes add new prop values through components — the same field used for overrides. Any prop:value key where the value isn't a built-in gets treated as a new variant:
defineTheme({
name: 'meta',
components: {
button: {
// Override an existing variant
'variant:secondary': { backgroundColor: 'rgba(0,0,0,0.06)' },
// Add a new variant
'variant:primary-muted': {
backgroundColor: 'light-dark(#F2F4F6, #28292C)',
color: 'var(--color-text-primary)',
},
},
banner: {
// Add a new status (Banner's extensible axis is status, not variant)
'status:neutral': {
backgroundColor: 'var(--color-muted)',
color: 'var(--color-text-secondary)',
},
},
},
});One API for overrides and extensions. There is no separate variants field — components handles both. The CLI detects unknown values by reading the component's .doc.mjs file and generates TypeScript module augmentations for them.
Extensible prop pattern. All visual props use the Map interface pattern (ButtonVariantMap, BannerStatusMap, etc.), making any prop axis extensible via module augmentation:
// Generated by `xds theme build`
declare module '@xds/core/Button' {
interface ButtonVariantMap {
'primary-muted': true;
}
}
declare module '@xds/core/Banner' {
interface BannerStatusMap {
'neutral': true;
}
}Pseudo-class overrides. Component overrides support pseudo-class selectors for interaction states, eliminating the need for CSS custom property escape hatches:
components: {
radio: {
base: {
borderColor: '#8F9296',
':hover': { borderColor: 'color-mix(in srgb, #8F9296, black 20%)' },
':focus-visible': { outline: '2px solid var(--color-ring-focus)' },
},
},
}Runtime theme limitation: Runtime themes get correct behavior but no TypeScript autocomplete. Build-time themes via xds theme build are the primary path for full type safety.
Components like toasts, tooltips, and dark popovers render on surfaces with a different luminance than the page. MediaTheme handles the token inversion so children render correctly.
-
color-schemeis the primary mechanism — settingcolor-scheme: darkon the media element makes alllight-dark()tokens resolve to their dark-side values automatically. - A small set of tokens need explicit overrides (text/icon primary use
var(--color-on-dark)instead of dark-mode grey, accent collapses to on-color). - Parent theme's component overrides flow through — structural styling (border-radius, font-weight, text-transform) is preserved. Only color tokens change.
- Themes can further customize components on media surfaces via
onDark.components/onLight.components.
defineTheme({
name: 'default',
tokens: { ... },
components: {
button: {
'variant:secondary': {
backgroundColor: 'light-dark(rgba(5, 54, 89, 0.1), rgba(223, 226, 229, 0.2))',
},
},
},
// On-media overrides — same shape as the main theme (tokens + components)
onDark: {
// tokens: { ... }, // override defaults if needed
components: {
button: {
'variant:secondary': {
backgroundColor: 'color-mix(in srgb, white 20%, transparent)',
},
},
},
},
onLight: {
components: {
button: {
'variant:secondary': {
backgroundColor: 'color-mix(in srgb, black 10%, transparent)',
},
},
},
},
});On-media rules live in a separate @scope block with the same to ([data-astryx-theme]) boundary as the main theme rules:
@scope ([data-astryx-theme="default"]) to ([data-astryx-theme]) {
[data-astryx-media="dark"] {
color-scheme: dark;
--color-text-primary: var(--color-on-dark);
--color-icon-primary: var(--color-on-dark);
--color-accent: var(--color-on-dark);
}
[data-astryx-media="dark"] .astryx-button.secondary {
background-color: color-mix(in srgb, white 20%, transparent);
}
[data-astryx-media="light"] {
color-scheme: light;
--color-text-primary: var(--color-on-light);
--color-icon-primary: var(--color-on-light);
--color-accent: var(--color-on-light);
}
[data-astryx-media="light"] .astryx-button.secondary {
background-color: color-mix(in srgb, black 10%, transparent);
}
}// Dark surface (e.g. toast on a light page)
<div style={{ backgroundColor: 'var(--color-background-inverted)' }}>
<MediaTheme mode="dark">
<Button label="Undo" variant="ghost" />
<Link href="#">View details</Link>
</MediaTheme>
</div>The component renders display: contents with data-astryx-media + color: var(--color-text-primary) for inherited text color. No runtime token computation — everything is resolved via CSS.
These tokens must be absolute values, not mode-relative light-dark() tuples:
-
--color-on-darkshould always be light (e.g. white) — it's "the color to use ON a dark surface" -
--color-on-lightshould always be dark (e.g. near-black) — it's "the color to use ON a light surface"
If a theme defines these as light-dark() tuples, the color-scheme flip on the media element will resolve to the wrong side.
Auto-detects whether an image is predominantly dark or light:
function ImageCard({ src }: { src: string }) {
const mode = useImageMode(src);
return (
<div style={{ backgroundImage: `url(${src})` }}>
<MediaTheme mode={mode ?? 'dark'}>
<Text>Auto-detected text color</Text>
</MediaTheme>
</div>
);
}Uses OffscreenCanvas with 10×10 sampling and perceptual brightness (BT.709 luma). Supports regional sampling via normalized coordinates.
Each component documents its theming targets in its .doc.mjs file. Targets have variants (from props) and states (from runtime state), both expressed as CSS class names.
// Example: Button has variant and size as visual props
components: {
button: {
base: { fontWeight: '600' },
'variant:secondary': { backgroundColor: 'rgba(0,0,0,0.06)' },
'size:sm': { padding: '4px 8px' },
},
// Sub-elements have their own targets
'calendar-day': {
today: { fontWeight: '700', color: 'var(--color-accent)' },
selected: { backgroundColor: 'var(--color-accent)' },
},
}Run npx xds component <Name> for the full theming section of any component — targets, variants, states, vars, and defineTheme examples. The doc files are the source of truth; this wiki intentionally avoids exhaustive lists that go stale.
Set the active theme so the CLI includes custom variants automatically:
{
"xds": { "theme": "./src/theme.ts" }
}Or: XDS_THEME=@xds/theme-meta npx xds component Button
Resolution order: file path → package name → @xds/theme-{name} convention.
Themes declare font dependencies via a fonts field:
defineTheme({
name: 'meta',
fonts: [
{ family: 'Figtree', url: 'https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600;700' },
],
tokens: { '--font-body': 'Figtree, -apple-system, sans-serif' },
});Loading (layered approach):
-
Best: Consumer loads fonts in
<head>— no FOUC -
Good:
xds theme buildprints a warning with the<link>snippet -
Fallback:
<Theme>injects<link>at runtime — late but correct, with a dev warning
// Vite / plain HTML
<link rel="stylesheet" href={theme.fonts[0].url} />
// Next.js
import { Figtree } from 'next/font/google';Themes with system font stacks have no fonts field — no action needed.
Feature-specific tokens beyond the core set (syntax highlighting, dataviz palettes). Live in domainTokens/ and syntax/ for tree-shaking — importing core components doesn't pull these in.
| Domain | Prefix | Token Count | Package |
|---|---|---|---|
| Syntax highlighting | --color-syntax-* |
14 | @xds/core/theme/syntax |
| Data visualization | --color-data-* |
TBD | @xds/core/theme |
Code components (CodeBlock, CodeEditor) use 14 syntax tokens for coloring. These are independent from the core theme — they can be configured at multiple levels.
| Token | Role | Default (derived from palette) |
|---|---|---|
--color-syntax-keyword |
Control flow (if, return, const) |
var(--color-text-accent) |
--color-syntax-string |
String literals | var(--color-text-green) |
--color-syntax-comment |
Comments (passive, subdued) | var(--color-text-secondary) |
--color-syntax-number |
Numeric literals | var(--color-text-orange) |
--color-syntax-function |
Function/method names | var(--color-text-blue) |
--color-syntax-type |
Types, interfaces, classes | var(--color-text-purple) |
--color-syntax-variable |
Identifiers (neutral) | var(--color-text-primary) |
--color-syntax-operator |
Operators (=, +, =>) |
var(--color-text-cyan) |
--color-syntax-constant |
Built-in constants (true, null) |
var(--color-text-orange) |
--color-syntax-tag |
HTML/JSX tags | var(--color-text-red) |
--color-syntax-attribute |
HTML/JSX attributes | var(--color-text-teal) |
--color-syntax-property |
Object properties | var(--color-text-cyan) |
--color-syntax-punctuation |
Brackets, semicolons | var(--color-text-disabled) |
--color-syntax-background |
Code block surface | var(--color-background-muted) |
Key design decision: Defaults derive from the theme's named palette via var() references. Any theme that defines its color ramp gets coherent syntax highlighting for free — no explicit syntax configuration needed.
Syntax tokens resolve in this order (highest priority wins):
1. SyntaxTheme provider (closest ancestor)
2. Theme tokens (--color-syntax-* in defineTheme)
3. Built-in defaults (var() references to palette)
Every theme gets syntax highlighting by default. The var() fallbacks resolve to the theme's named palette:
// This theme gets syntax highlighting automatically —
// keywords use the theme's accent, strings use green, etc.
const myTheme = defineTheme({
name: 'my-theme',
tokens: {
'--color-text-accent': '#e11d48',
'--color-text-green': '#16a34a',
// ... the named palette drives syntax colors
},
});Override individual syntax tokens alongside other theme tokens:
const myTheme = defineTheme({
name: 'my-theme',
tokens: {
// Regular theme tokens
'--color-accent': ['#0077B6', '#48CAE4'],
// Explicit syntax overrides
'--color-syntax-keyword': 'light-dark(#c678dd, #c678dd)',
'--color-syntax-string': 'light-dark(#98c379, #98c379)',
'--color-syntax-background': 'light-dark(#fafafa, #282c34)',
},
});This is the approach used by the default and neutral themes — they set all 14 syntax tokens explicitly for precise control over the code highlighting palette.
Wrap a region of the app to apply a different syntax theme. Useful for coding tools where users pick their preferred theme:
import {SyntaxTheme} from '@xds/core/theme/syntax';
import {dracula} from '@xds/theme-syntax';
// User picks "Dracula" in settings
<SyntaxTheme theme={userPreferredSyntaxTheme}>
<CodeEditor />
<CodePreview />
</SyntaxTheme>The provider sets CSS custom properties on a wrapper div. Child code components inherit via cascade — no context reads needed.
Create your own with defineSyntaxTheme():
import {defineSyntaxTheme} from '@xds/core/theme/syntax';
const cyberpunk = defineSyntaxTheme({
name: 'cyberpunk',
tokens: {
keyword: '#ff2a6d',
string: '#05d9e8',
comment: '#4a5568',
number: '#d1f7ff',
function: '#ff6ac1',
type: '#7efff5',
variable: '#e2e8f0',
operator: '#ff9e64',
constant: '#d1f7ff',
tag: '#ff2a6d',
attribute: '#7efff5',
property: '#05d9e8',
punctuation: '#718096',
background: '#0d0221',
},
});@xds/theme-syntax ships 11 presets mapped to the 14-token architecture. All original themes are MIT licensed.
| Dark Themes | Light Themes |
|---|---|
| One Dark Pro | GitHub Light |
| Dracula | Solarized Light |
| Monokai | One Light |
| Nord | Catppuccin Latte |
| Tokyo Night | Tokyo Night Light |
| Catppuccin Mocha |
import {dracula, nord, githubLight} from '@xds/theme-syntax';See THIRD_PARTY_LICENSES.md in the package for full attribution.
┌─────────────────────────────────────────────────────┐
│ @xds/core │
│ │
│ theme/syntax/ │
│ tokens.ts 14 CSS custom property defaults│
│ defineSyntaxTheme API for custom themes │
│ SyntaxTheme Provider component │
│ useSyntaxTheme Context hook │
│ │
│ theme/domainTokens/ Aggregator for defineTheme │
│ │
│ CodeBlock/ Consumes --color-syntax-* │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ @xds/theme-syntax Separate package │
│ │
│ 11 community presets (6 dark, 5 light) │
│ All MIT licensed │
│ THIRD_PARTY_LICENSES.md │
└─────────────────────────────────────────────────────┘
Syntax tokens are not StyleX vars — they're plain CSS custom properties set via var() fallbacks, inline styles (from SyntaxTheme), or theme token overrides. This keeps them tree-shakeable and independent of the StyleX compilation step.
Import components from @xds/core, theme from @xds/theme-default. StyleX compiler extracts and deduplicates styles at build time.
If using theme component overrides, enable useCSSLayers: true in your StyleX plugin config. This establishes the correct cascade:
-
@layer stylex— component base styles -
@layer xds.theme— theme overrides - Unlayered — consumer styles (always win)
Without useCSSLayers, StyleX output is unlayered and will beat layered theme overrides regardless of specificity (per the CSS cascade spec).
// vite.config.ts
stylexPlugin({ useCSSLayers: true })Import pre-built CSS: @xds/core/dist/xds.css + @xds/theme-default/dist/theme.css. StyleX output is already layered — theme overrides work out of the box.
Link to CSS files directly. No bundler required. Layers are pre-configured.
<Theme> sets a data-astryx-theme attribute on its root element. Switching themes is a single attribute change — all components respond instantly via CSS custom properties.
<Theme theme={isDark ? darkTheme : lightTheme} mode="system">
<App />
</Theme>For light-dark() tokens, the mode prop controls color-scheme on the scope element. Mode "system" follows prefers-color-scheme.