Theme System
Theme provider, custom themes, theme build for production/SSR, light/dark mode, and component style overrides.Quick Start
bashnpm install @astryxdesign/theme-neutral
tsximport {Theme} from '@astryxdesign/core';import {neutralTheme} from '@astryxdesign/theme-neutral';function App() {return (<Theme theme={neutralTheme}><YourApp /></Theme>);}
tsximport {Theme} from '@astryxdesign/core';import {neutralTheme} from '@astryxdesign/theme-neutral/built';import '@astryxdesign/theme-neutral/theme.css';function App() {return (<Theme theme={neutralTheme}><YourApp /></Theme>);}
Each theme ships as its own npm package. Install the one you want, then wrap your app in <Theme>. The same pattern works for every theme; just swap the package and import name.
The default import uses runtime style injection, which works everywhere with no build step. The /built import skips injection and relies on the pre-compiled CSS file for better performance and SSR support.
Available Themes
Install the theme package you want with npm install @astryxdesign/theme-{name}, then import its theme object as shown below.
| Theme | Import | Description |
|---|---|---|
| Neutral | import {neutralTheme} from '@astryxdesign/theme-neutral' | Muted, minimal aesthetic with system fonts. A good starting point. |
| Butter | import {butterTheme} from '@astryxdesign/theme-butter' | Golden, buttery surfaces with blue accents; Sarina + Outfit type. |
| Chocolate | import {chocolateTheme} from '@astryxdesign/theme-chocolate' | Warm brown tones and cozy beige; Fraunces + Albert Sans type. |
| Gothic | import {gothicTheme} from '@astryxdesign/theme-gothic' | Dark-only atmospheric theme; deep blue-gray surfaces, distressed display type. |
| Matcha | import {matchaTheme} from '@astryxdesign/theme-matcha' | Earthy green theme with Figtree typography. |
| Stone | import {stoneTheme} from '@astryxdesign/theme-stone' | Warm stone and slate tones; Montserrat + Figtree type. |
| Y2K | import {y2kTheme} from '@astryxdesign/theme-y2k' | Playful Y2K pop; periwinkle body, holographic accents, Poppins + Crimson Text. |
All theme packages export from two subpaths:
- @astryxdesign/theme-{name}: source theme (runtime injection)
- @astryxdesign/theme-{name}/built: pre-built theme (pair with theme.css)
Theme Props
| Prop | Type | Default | Description |
|---|---|---|---|
| theme | DefinedTheme | - | Theme object (required) |
| mode | 'system' | 'light' | 'dark' | 'system' | Color mode. system follows OS preference. |
| children | ReactNode | - | App content |
Creating a Custom Theme
Start from a theme we ship, or write one from scratch with defineTheme. Only override tokens that differ from defaults; omitted tokens use the design system defaults.
bashastryx theme listastryx theme add stone
For an annotated map of the whole surface (every defineTheme field, the token families, and the component override syntax, each with the CLI command that prints its reference), run astryx theme template. It writes theme.template.ts into your project to read and copy from (astryx init --features theme writes it as part of project setup).
defineTheme
defineTheme creates a theme from token overrides and optional scale configs. Scale configs generate tokens from parameters. Explicit token overrides always take precedence over scale-generated values, token by token. One caveat for the accent: overriding --color-accent in tokens re-points the reference tokens (--color-accent-muted, --color-text-accent, --color-icon-accent) but NOT --color-on-accent, which stays baked from the color.accent seed. To give each scheme its own accent with a consistent derived palette, pass a [light, dark] tuple to color.accent instead of overriding the token.
tsximport {defineTheme} from '@astryxdesign/core/theme';const myTheme = defineTheme({name: 'my-theme',// accent: single hex, or [light, dark] tuple to seed each scheme separatelycolor: { accent: ['#7B61FF', '#9B85FF'], neutralStyle: 'cool' },typography: {scale: { base: 14, ratio: 1.2 },body: { family: 'Inter', fallbacks: '-apple-system, sans-serif' },},radius: { base: 4, multiplier: 1 },motion: { fast: 175, medium: 410, ratio: 0.75 },tokens: {// Explicit overrides take precedence over scale-generated values'--color-background-body': ['#FFFFFF', '#0A0A0A'],},components: {button: { 'variant:primary': { color: 'white' } },},});
| Config | Generates | Parameters |
|---|---|---|
| color | --color-accent, --color-background-*, --color-text-*, --color-border, etc. | accent? (hex or [light, dark] tuple; omit for neutral-only), neutralStyle? (warm|cool|neutral), contrast? (standard|high) |
| typography.scale | --text-heading-*-size/weight/leading, --text-body-size/weight/leading | base (px), ratio |
| typography.body/heading/code | --font-family-body, --font-family-heading, --font-family-code | family, fallbacks?, url?, weight? |
| radius | --radius-inner, --radius-element, --radius-container, --radius-page, --radius-chat | base (px), multiplier (0–2) |
| motion | --duration-fast-min/fast/fast-max, --duration-medium-min/medium/medium-max | fast (ms), medium (ms), ratio, easing? |
Extending a Theme
extends lets you derive a new theme from an existing one, inheriting its tokens, component overrides, icons, and fonts. Only specify what you want to change; everything else carries over from the base theme.
tsximport {defineTheme} from '@astryxdesign/core/theme';import {neutralTheme} from '@astryxdesign/theme-neutral';import {myIcons} from './icons';const brandTheme = defineTheme({name: 'brand',extends: neutralTheme,icons: myIcons,tokens: {'--color-accent': ['#7B61FF', '#9B85FF'],},});
| Field | Merge behavior |
|---|---|
| tokens | Base tokens are copied first, then child tokens override on top. |
| components | Deep-merged: child component rules override matching keys from the base. |
| icons | Shallow-merged: child icons override matching names from the base. |
| indicators | Shallow-merged: child indicators override matching names from the base. |
| onDark, onLight | Deep-merged per surface: the base's resolved surface first, then the child's overrides. |
| typography, motion, radius, color | Child config replaces base entirely (these are scale inputs, not additive). |
| mobile, tablet, desktop, wide | Inherited and re-resolved against the child's values, so a variant theme keeps the responsive behaviour it was built on. Where both declare a tier, the child's values win per field. |
Inheritance is resolved when the theme is defined, so an extended theme is flat: astryx theme build emits one self-contained stylesheet holding everything the child inherited, and the base theme's CSS does not need to be loaded next to it. A base that is not a theme (most often an import that missed) is a build error rather than a theme that silently inherits nothing.
Responsive Width Tiers
A theme can say what it looks like at each viewport width. The four tiers — mobile, tablet, desktop, wide — **partition** the width axis, so exactly one matches at any width and no two ever compete. Declaring a tier turns it on; a theme that declares none emits no tier CSS at all.
tsxconst acmeTheme = defineTheme({name: 'acme',typography: {scale: {base: 14, ratio: 1.2}},tokens: {'--spacing-4': '16px'},mobile: {maxWidth: 756, // optional; this is the defaulttokens: {'--spacing-4': '12px'}, // narrow, any pointer'@media (pointer: coarse)': {typography: {scale: {base: 16}}, // narrow AND touch; ratio inherited},},tablet: {extends: 'mobile'}, // start from mobile's values});
| Tier | Matches | Default bound |
|---|---|---|
| mobile | width <= 756px | 756 |
| tablet | 756px < width <= 1024px | 1024 |
| desktop | 1024px < width <= 1440px | 1440 |
| wide | width > 1440px | none — the open top, so it takes no maxWidth |
A tier's value is a partial theme: the same axes as the theme itself (typography, color, radius, motion, tokens, components), resolved through the same pipeline. State only what differs — a scale that sets base and not ratio inherits the theme's ratio. Setting a maxWidth moves both of that tier's boundaries, since a tier's lower bound is always the tier below it. Widths no declared tier covers use the theme's own values.
**extends is value inheritance, not the cascade.** It defaults to the theme's own values; naming another tier starts from that tier's resolved values instead. tablet: {extends: 'mobile'} takes mobile's *values* — mobile's CSS still applies only at mobile widths.
**Precedence.** Tiers partition, so no two tiers can both match and the question never arises. Within a tier, explicit tokens beat values generated from a scale — the same rule the theme itself follows — and a nested pointer refinement wins over the tier it sits in.
Nest '@media (pointer: coarse)' (or '@media (pointer: fine)') for values that also require a pointer type. Keep width and pointer separate: a 16px body floor exists because iOS Safari zooms an input whose text is under 16px — a fact about the finger, true on a phone and an iPad alike and never true of a desktop window dragged narrow. Resizing a window is a layout gesture; the layout reflows and the type holds.
Tiers are plain CSS media queries inside the theme stylesheet, so they render correctly on the server with no hydration flash and need no useMediaQuery. Both distribution modes emit them from the same generator — but only a built theme (astryx theme build) is in the stylesheet at first paint, so prefer the built path for a responsive theme.
Component Style Overrides
The components field in defineTheme uses semantic component keys and style keys, not raw CSS selectors. Use base for all instances, variant:value or stateName for specific props/states, and let the theme pipeline choose the underlying selector. For raw external CSS escape hatches, prefer the data-attribute selector surface documented in astryx docs styling.
tsxcomponents: {// Standard CSS properties are expanded automatically.// borderRadius also sets the internal radius var for concentric math.// padding on container components (card, section, dialog) expands to layout tokens.card: {base: { borderRadius: '20px', padding: '24px' },},button: {base: {borderRadius: '9999px',textTransform: 'uppercase',// Some components have public CSS vars for properties that don't map// to standard CSS. Set these directly. Take the name from// `astryx component <Name>` — a var the component does not define// compiles to CSS that never applies.'--button-focus-offset': '3px',},'variant:ghost': { borderWidth: '2px', borderStyle: 'solid' },},}
Run astryx theme targets for every themeable key in the system (astryx theme targets <Name> to scope it, --json to lint a theme against it), and astryx component <Name> for one component's theming targets, public CSS variables, and which standard CSS properties are supported.
| Guidance | Practices |
|---|---|
| Do | Write standard CSS properties (borderRadius, padding); the pipeline expands them into internal vars. |
| Do | Set public CSS vars directly when no standard property equivalent exists. |
| Don't | Set private CSS vars (prefixed --_) directly. Use standard CSS properties instead. |
Custom Variants
Themes can add new prop values to any component. Any prop:value key where the value isn't a built-in gets treated as a new variant. Use astryx theme build to generate TypeScript augmentations for type safety.
tsxcomponents: {button: {// Override an existing variant'variant:secondary': { backgroundColor: 'rgba(0,0,0,0.06)' },// Add a new variant — generates type augmentation on build'variant:primary-muted': {backgroundColor: 'light-dark(#F2F4F6, #28292C)',color: 'var(--color-text-primary)',},},banner: {// Any extensible prop axis works — not just variant'status:neutral': {backgroundColor: 'var(--color-muted)',color: 'var(--color-text-secondary)',},},}
After building, the new values are type-safe in JSX:
tsx// TypeScript knows about 'primary-muted' after astryx theme build<Button variant="primary-muted" label="Save draft" /><Banner status="neutral" title="Note" />
Custom variants only work when the theme that defines them is active. The component's variant map is extended via module augmentation, with no changes to the component source needed.
Building Themes for Production
astryx theme build compiles a defineTheme file into production-ready artifacts. Recommended for SSR apps (Next.js, Remix) where styles must be present on first paint.
bashastryx theme build ./src/themes/ocean.ts
This generates the following files alongside the source:
| File | Description |
|---|---|
| ocean.css | Pre-compiled CSS with token overrides, component overrides, and prose element styles in @scope rules |
| ocean.js | ES module exporting the theme object with __built: true and pre-resolved token values. Also re-exports the icon registry if the source theme declares one. |
| ocean.d.ts | TypeScript declarations for the theme and icon registry exports |
| ocean.variants.d.ts | (Optional) Module augmentations for custom component prop values found in the theme's component overrides |
The __built: true flag tells Theme to skip runtime <style> injection; the CSS file handles it.
tsximport {oceanTheme} from './themes/ocean';import './themes/ocean.css';<Theme theme={oceanTheme}><App /></Theme>
The build also warns when the theme names font families it does not load (webfonts like Fraunces) and prints the <link>/@font-face to add. The built CSS only sets font-family, so loading the font files stays the app's job. See astryx docs typography for the full recipe.
Runtime vs Built Themes
Themes work in two modes:
| Runtime (source) | Built | |
|---|---|---|
| Import (published theme) | @astryxdesign/theme-{name} | @astryxdesign/theme-{name}/built + theme.css |
| Import (custom theme) | defineTheme() directly | Built .js + .css from astryx theme build |
| How it works | useInsertionEffect injects <style> at hydration | Pre-compiled .css file loaded with the page |
| Component overrides | Injected client-only | In static CSS: present during SSR |
| SSR safe | Tokens yes, component overrides flash on hydration | Fully SSR safe: no flash |
| Best for | Dev, prototyping, client-only SPAs | Production, SSR apps (Next.js, Remix) |
| Guidance | Practices |
|---|---|
| Do | Use the /built subpath + theme.css for production SSR apps. |
| Do | Use runtime themes during development for fast iteration. |
| Do | Run |
| Don't | Use runtime themes in production SSR apps; component overrides will flash on hydration. |
| Don't | Import /built without the CSS file; component overrides won't apply. |
Light/Dark Mode
Use [light, dark] tuples in token values for automatic mode switching. Use mode='system' (default) on Theme to follow OS preference.
tsx'--color-accent': ['#0064E0', '#2694FE'],// ^light ^dark
tsxconst [mode, setMode] = useState<'light' | 'dark'>('light');<Theme theme={myTheme} mode={mode}><Buttonlabel={mode === 'light' ? 'Switch to Dark' : 'Switch to Light'}onClick={() => setMode(m => (m === 'light' ? 'dark' : 'light'))}/></Theme>;
Nesting Themes
Wrap different sections in separate <Theme> providers.
tsx<Theme theme={lightTheme} mode="light"><Layoutheader={<LayoutHeader>...</LayoutHeader>}start={<Theme theme={darkTheme} mode="dark"><LayoutPanel>{/* Dark sidebar */}</LayoutPanel></Theme>}content={<LayoutContent>{/* Light content */}</LayoutContent>}/></Theme>
Token Utilities
Use tokenVar() when a non-StyleX styling library wants a CSS variable reference, and resolveThemeTokens() when JavaScript needs token values for a specific theme and mode without React context. Themes are also registered by name when created with defineTheme(); call registerTheme(theme) for prebuilt or object-literal themes that need name-based SSR lookup.
tsimport {tokenVar, tokenVars} from '@astryxdesign/core/theme/tokens';const pandaOrEmotionTheme = {colors: {text: tokenVar('--color-text-primary'),surface: tokenVars['--color-background-surface'],},spacing: {4: tokenVars['--spacing-4'],},};
tsimport {resolveThemeTokens} from '@astryxdesign/core/theme/tokens';import {neutralTheme} from '@astryxdesign/theme-neutral';const lightTokens = resolveThemeTokens(neutralTheme, {mode: 'light'});const chartTheme = {textColor: lightTokens['--color-text-primary'],seriesColor: lightTokens['--color-data-categorical-blue'],};
The @astryxdesign/core/theme/tokens subpath is server-safe and does not require React. The main @astryxdesign/core/theme barrel also re-exports these helpers for client code that already imports theme APIs.
useTheme Hook
useTheme() uses the same token resolution as resolveThemeTokens(), but reads the nearest Theme and effective color mode from React context and media query state. Use it inside client components for SVG, canvas, charts, maps, and third-party configuration objects that need token values in JavaScript instead of var(...) references.
tsximport {useMemo} from 'react';import {useTheme} from '@astryxdesign/core/theme';function ChartConfig() {const {mode, tokens} = useTheme();const options = useMemo(() => ({mode,textColor: tokens['--color-text-primary'],gridColor: tokens['--color-border'],seriesColor: tokens['--color-data-categorical-blue'],}),[mode, tokens],);return <Chart options={options} />;}
Prefer CSS variables, StyleX token imports, xstyle, or className for ordinary styling. To change the theme or mode, manage state at the app level and pass it to <Theme>.
See astryx docs styling-libraries for styling-library interop and astryx docs tokens for the full token reference.