Theme System
How to use and create themes: see Use a theme for applying themes, and Author a theme for creating and customizing them.Wrap your app in a theme
bashnpm install @astryxdesign/theme-neutralastryx theme add neutral --import
tsximport {Theme} from '@astryxdesign/core';import {themes, defaultThemeSlug} from './astryx-themes';function App() {return (<Theme theme={themes[defaultThemeSlug]}><YourApp /></Theme>);}
tsximport {useState} from 'react';import {Theme} from '@astryxdesign/core';import {themes, type ThemeSlug} from './astryx-themes';const [slug, setSlug] = useState<ThemeSlug>('neutral');const app = <Theme theme={themes[slug]}><YourApp /></Theme>;
theme add --import records an installed package theme and regenerates src/astryx-themes.ts or .js with its built module, production CSS, and optional font CSS. In a project without src, the module is at the project root. The first imported theme becomes the default. theme use <slug> changes that default, and theme remove <slug> removes a non-default theme.
Customize a package theme with defineTheme({extends: importedTheme, ...}), then build and add that local theme. Use theme eject only when you want an independent source fork that no longer receives the package owner's updates. For the full authoring guide, see astryx docs author-a-theme.
Migrating Earlier Theme Copies
A theme copied by the released theme add stays app source. The upgrade does not move, delete, or rewrite it. It only writes the missing same-stem descriptor with maintained: false, so the copy becomes a local theme.
bashastryx upgrade --from 0.6.4 --path . --apply
Before the upgrade runs, theme commands skip a descriptor-less copy in src/themes. theme list and doctor name it as unmigrated and show the upgrade command. A script that meant to copy source now runs theme eject with the same arguments. A script that meant to make the app use a theme runs theme add --import.
ASTRYX_THEME is no longer read. Run theme add <slug> --import and theme use <slug> to choose the default in a generated app theme module. When that module exists, component metadata reads its recorded default theme. Without the module, the released package.json#astryx.theme lookup keeps its meaning.
Available Themes
Install the theme package you want with npm install @astryxdesign/theme-{name}, then import its slug with theme add <slug> --import. The CLI imports the package's built outputs for you.
| Theme | Add command | Description |
|---|---|---|
| Neutral | astryx theme add neutral --import | Muted, minimal aesthetic with Figtree typography. A good starting point. |
| Butter | astryx theme add butter --import | Golden, buttery surfaces with blue accents; Sarina + Outfit type. |
| Chocolate | astryx theme add chocolate --import | Warm brown tones and cozy beige; Fraunces + Albert Sans type. |
| Gothic | astryx theme add gothic --import | Dark-only atmospheric theme; deep blue-gray surfaces, distressed display type. |
| Matcha | astryx theme add matcha --import | Earthy greens; DM Sans + Playwrite US Trad type. |
| Stone | astryx theme add stone --import | Warm stone and slate tones; Montserrat + Figtree type. |
| Y2K | astryx theme add y2k --import | Playful Y2K pop; periwinkle body, holographic accents, Poppins + Crimson Text. |
Every first-party package exports its built theme at @astryxdesign/theme-{name}/built, production CSS at /theme.css, and font loading CSS at /fonts.css. theme add --import writes those imports into the generated app module.
Theme Props
<Theme> takes theme (required), mode ('system' by default, or 'light'/'dark'), and children. For every prop, run astryx component Theme.
Using a Theme from an Integration
Install the integration as a direct dependency. Astryx discovers its themes and guides without an astryx.config entry. A theme can be added only when the installed package exports its built module and production stylesheet.
bashnpm install @astryxdesign/core @acme/brand-integrationastryx theme list --package @acme/brand-integrationastryx docs brand-themeastryx theme add ocean --import --package @acme/brand-integration
theme add --import keeps the owner package in the app record and imports its built module, stylesheet, and optional font stylesheet. Package updates continue to reach the app. Run theme eject ocean --package @acme/brand-integration only to copy the source and descriptor into src/themes/ocean as an independent local fork.
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>;
To create a theme with custom dark mode colors, see astryx docs author-a-theme.
Nested 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>
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 | Authoring input for astryx theme build | App wiring, in development and production |
| Guidance | Practices |
|---|---|
| Do | Use |
| Do | Import |
| Do | While you edit a local theme, run |
| 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. |
| Don't | Import package theme source into app runtime code. |
| Don't | Hand-edit the generated theme module; |
To build a custom theme for production, see the Build section of astryx docs author-a-theme.
Custom themes
Use an installed built theme as the base for ordinary customization. Import it into your source and pass it as extends to defineTheme({extends: importedTheme, ...}). Build the result, then add the local slug. Only eject when you need to own and maintain a full source fork.
bashastryx theme listastryx theme add stone --importastryx theme eject stoneastryx theme build src/themes/stone/stoneTheme.tsastryx theme add stone --import
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).
To apply a theme you created, see astryx docs use-a-theme. To generate colors from seed values, see the Palette section below. Use theme eject only when you want an independent source fork that no longer receives the package owner’s updates.
Generate a palette
A theme needs dozens of related colors for backgrounds, borders, text, and states, in both light and dark mode. Rather than pick each by hand, name a few seed colors and generate the rest.
json{"families": [{"id": "ocean", "seed": "#0074e2"}]}
bash# Print the palette without writing filesastryx theme palette generate palette.config.json# Write palette + receipt, and open a visual previewastryx theme palette generate palette.config.json \--out src/themes/ocean/tokens/ocean.palette.ts \--preview preview.html
The command writes a .ts file exporting 21 shades (stops 0–100) for both light and dark, plus a .receipt.json to regenerate later. Point your theme's tokens at the palette, then run defineTheme (see below).
For the full palette config fields and CLI flags, run astryx docs cli/commands/theme-palette-generate. For the integration-authoring walkthrough of connecting a palette to theme tokens, run astryx docs cli/integrations/building-blocks/themes/generate-a-palette.
defineTheme
defineTheme creates a theme from token overrides and optional scale configs. Only override tokens that differ from defaults; omitted tokens use the design system defaults. Scale configs generate tokens from parameters. Explicit token overrides always take precedence over scale-generated values, token by token. localTokens accepts any valid CSS custom-property name; prefixes do not establish ownership. 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'],},});
| 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? |
For dark mode tuples in token values, see the Dark mode section of astryx docs use-a-theme.
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). |
| adaptations | Width-breakpoint overrides merge by fixed name. Inherited ordered rules keep their relative order; child rules append and re-resolve against the child root axes. |
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) throws when defineTheme runs, rather than producing a theme that silently inherits nothing.
For the integration-specific guidance on mapping a palette to tokens when extending, see astryx docs cli/integrations/building-blocks/themes/define-the-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/styling-advanced.
tsxcomponents: {card: {base: { borderRadius: '20px', padding: '24px' },},button: {base: {borderRadius: '9999px',textTransform: 'uppercase','--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. |
| Don't | Set a public CSS var the component does not define. It compiles to CSS that never applies, and the build does not warn. Take the names from |
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: {'variant:secondary': { backgroundColor: 'rgba(0,0,0,0.06)' },'variant:primary-muted': {backgroundColor: 'light-dark(#F2F4F6, #28292C)',color: 'var(--color-text-primary)',},},banner: {'status:neutral': {backgroundColor: 'var(--color-background-muted)',color: 'var(--color-text-secondary)',},},}
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.
Theme Adaptations
Use adaptations for opt-in token, theme-local token, and component changes under viewport width, primary-pointer precision, contrast preference, or motion preference. Conditions in one when are ANDed. Rules are ordinary ordered objects, and later matching writes win.
tsxconst acmeTheme = defineTheme({name: 'acme',adaptations: {widthBreakpoints: {sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536,},rules: [{when: {width: {below: 'md'}},value: {tokens: {'--spacing-4': '12px'}},},{when: {pointer: 'coarse'},value: {tokens: {'--size-element-sm': '36px', '--size-element-md': '40px', '--size-element-lg': '44px'}},},],},});
| Condition | Values |
|---|---|
| width.from / width.below | sm | md | lg | xl | 2xl |
| pointer | coarse | fine |
| contrast | more | less | no-preference |
| motion | reduce | no-preference |
widthBreakpoints are fixed named start points. Defaults are 640 / 768 / 1024 / 1280 / 1536 CSS pixels. from includes its point; below excludes it. Breakpoint configuration alone emits no CSS.
Adaptation order and validation
Precedence follows rule order. Root theme values apply first, then every matching rule in declaration order. A later rule may deliberately restore a root value. onDark and onLight media-surface overrides apply after adaptations and win on the same leaf.
extends preserves the base rule order and appends child rules. Inherited conditions use the child's effective breakpoint map. An empty child rule is a no-op, not a removal operator.
A rule may replace a theme-local token only when the exact name is already enrolled by root localTokens or an enrolled base. Component writes in a rule are validated exactly like root components: same targets, axes, and value domains. The one addition is that a rule may not be the only place a custom value is enrolled: a value that is valid only because a theme enrolls it generates unconditional type augmentation, so declare it on the root theme first and let rules restyle it. Built-in values need no root declaration. When rules can match together, their ordered portable and local token writes are validated as one effective graph; any reachable cycle fails before CSS is emitted.
Adaptations compile to CSS media queries with no resize listener or styling rerender. Runtime and astryx theme build use the same compiler, but only a built theme is present at first paint in an SSR app.
Build a theme
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
| 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. |
| ocean.d.ts | TypeScript declarations for the theme and icon registry exports |
| ocean.variants.d.ts | (Optional) Module augmentations for custom component prop values |
The __built: true flag tells Theme to skip runtime <style> injection; the CSS file handles it. Load the generated CSS wherever you load the module.
tsximport {Theme} from '@astryxdesign/core';import {oceanTheme} from './themes/ocean';import './themes/ocean.css';<Theme theme={oceanTheme}><App /></Theme>
After upgrading Astryx, rerun astryx theme build for every custom prebuilt theme. Deploy the regenerated files together. The runtime intentionally trusts __built: true and will not repair stale CSS from an older build. The build also warns when the theme names font families it does not load. See astryx docs typography/font-setup for the full recipe.
For the runtime vs built tradeoff, see the Runtime vs Built section of astryx docs use-a-theme.
Built themes with an icon registry
theme build emits an icon import when it detects a named import used by the theme's icons: field. It does not compile that registry module. Move the registry to a separate module and use a named import.
bash# Emit the built themeastryx theme build ./src/themes/ocean.ts -o dist/theme.css --icons-specifier ./icons.mjs# Compile the icon registry alongside itesbuild src/themes/icons.tsx --bundle --format=esm --outfile=dist/icons.mjs \--external:react --external:lucide-react --jsx=automatic
In the example above, the generated theme imports ./icons.mjs from dist. If the second command is skipped, theme build can still succeed, but loading or bundling the generated module fails because dist/icons.mjs is missing. --icons-specifier changes the emitted import; it does not create or verify the target file. Match the specifier to a module that resolves from the generated JS file.
Without --icons-specifier, the detected source import specifier is emitted unchanged. In the default flow without --out, a bundler can resolve an extensionless ./icons to the neighboring icons.tsx source, but Node ESM does not perform that lookup and reports ERR_MODULE_NOT_FOUND. Moving the output with --out also changes where relative imports resolve from.
Keep react and the icon library external so the registry does not bundle its own copies of those dependencies.
Building a Theme Family
Use family mode when an app switches among one base theme and its selected descendants. The build writes one keyed CSS file containing every member, plus one keyed JavaScript module and one declaration file, beside the root source.
bashastryx theme build --family \./src/themes/ocean.mjs \./src/themes/ocean-calm.mjs \./src/themes/ocean-calm-deep.mjs \--family-key ocean-family
The family stylesheet eagerly downloads every selected member so first paint is complete. Switching members changes only the theme identity; it does not add, remove, or reorder stylesheets.
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 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'],},};
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.
For styling library interop patterns, see astryx docs styling-libraries.
useTheme Hook
useTheme() reads the nearest Theme and effective color mode from React context. Use it inside client components for SVG, canvas, charts, maps, and third-party configuration objects that need token values in JavaScript.
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 for ordinary styling. See astryx docs use-a-theme for the provider setup, and astryx docs tokens for the full token reference.