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

Install and add a theme
bash
npm install @astryxdesign/theme-neutral
astryx theme add neutral --import
Wire the generated module once
tsx
import {Theme} from '@astryxdesign/core';
import {themes, defaultThemeSlug} from './astryx-themes';
​
function App() {
return (
<Theme theme={themes[defaultThemeSlug]}>
<YourApp />
</Theme>
);
}
Switch among added themes
tsx
import {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.

Add descriptors to earlier copies
bash
astryx 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.

ThemeAdd commandDescription
Neutralastryx theme add neutral --importMuted, minimal aesthetic with Figtree typography. A good starting point.
Butterastryx theme add butter --importGolden, buttery surfaces with blue accents; Sarina + Outfit type.
Chocolateastryx theme add chocolate --importWarm brown tones and cozy beige; Fraunces + Albert Sans type.
Gothicastryx theme add gothic --importDark-only atmospheric theme; deep blue-gray surfaces, distressed display type.
Matchaastryx theme add matcha --importEarthy greens; DM Sans + Playwrite US Trad type.
Stoneastryx theme add stone --importWarm stone and slate tones; Montserrat + Figtree type.
Y2Kastryx theme add y2k --importPlayful 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.

Install, inspect, and add
bash
npm install @astryxdesign/core @acme/brand-integration
astryx theme list --package @acme/brand-integration
astryx docs brand-theme
astryx 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.

Light/dark tuple
tsx
'--color-accent': ['#0064E0', '#2694FE'],
// ^light ^dark
Toggle with a button
tsx
const [mode, setMode] = useState<'light' | 'dark'>('light');
​
<Theme theme={myTheme} mode={mode}>
<Button
label={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.

Dark sidebar with light content
tsx
<Theme theme={lightTheme} mode="light">
<Layout
header={<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() directlyBuilt .js + .css from astryx theme build
How it worksuseInsertionEffect injects <style> at hydrationPre-compiled .css file loaded with the page
Component overridesInjected client-onlyIn static CSS: present during SSR
SSR safeTokens yes, component overrides flash on hydrationFully SSR safe: no flash
Best forAuthoring input for astryx theme buildApp wiring, in development and production
GuidancePractices
Do

Use theme add --import for production apps; it wires the built module and CSS.

Do

Import themes and defaultThemeSlug from the generated module once, and keep that wiring in development too.

Do

While you edit a local theme, run astryx theme build --watch so its built files stay current.

Do

Run astryx theme build for custom themes to get the built artifacts.

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; theme add, theme remove, and theme use regenerate it.

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.

Add to use, eject to fork
bash
astryx theme list
astryx theme add stone --import
astryx theme eject stone
astryx theme build src/themes/stone/stoneTheme.ts
astryx 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.

Minimal palette config (palette.config.json)
json
{"families": [{"id": "ocean", "seed": "#0074e2"}]}
Generate and preview
bash
# Print the palette without writing files
astryx theme palette generate palette.config.json
​
# Write palette + receipt, and open a visual preview
astryx 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.

defineTheme with scale configs
tsx
import {defineTheme} from '@astryxdesign/core/theme';
​
const myTheme = defineTheme({
name: 'my-theme',
// accent: single hex, or [light, dark] tuple to seed each scheme separately
color: { 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'],
},
});
ConfigGeneratesParameters
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/leadingbase (px), ratio
typography.body/heading/code--font-family-body, --font-family-heading, --font-family-codefamily, fallbacks?, url?, weight?
radius--radius-inner, --radius-element, --radius-container, --radius-page, --radius-chatbase (px), multiplier (0–2)
motion--duration-fast-min/fast/fast-max, --duration-medium-min/medium/medium-maxfast (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.

Extending the neutral theme
tsx
import {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'],
},
});
FieldMerge behavior
tokensBase tokens are copied first, then child tokens override on top.
componentsDeep-merged: child component rules override matching keys from the base.
iconsShallow-merged: child icons override matching names from the base.
indicatorsShallow-merged: child indicators override matching names from the base.
onDark, onLightDeep-merged per surface: the base's resolved surface first, then the child's overrides.
typography, motion, radius, colorChild config replaces base entirely (these are scale inputs, not additive).
adaptationsWidth-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.

Component overrides with standard CSS
tsx
components: {
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.

GuidancePractices
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. astryx theme build will error.

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 astryx component <Name>.

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.

Adding custom variants
tsx
components: {
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)',
},
},
}
Using custom variants
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.

Width and pointer adaptations
tsx
const 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'}},
},
],
},
});
ConditionValues
width.from / width.belowsm | md | lg | xl | 2xl
pointercoarse | fine
contrastmore | less | no-preference
motionreduce | 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.

Build a theme
bash
astryx theme build ./src/themes/ocean.ts
FileDescription
ocean.cssPre-compiled CSS with token overrides, component overrides, and prose element styles in @scope rules
ocean.jsES module exporting the theme object with __built: true and pre-resolved token values.
ocean.d.tsTypeScript 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.

Using a custom built theme
tsx
import {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.

Compiling the icon registry sidecar
bash
# Emit the built theme
astryx theme build ./src/themes/ocean.ts -o dist/theme.css --icons-specifier ./icons.mjs
​
# Compile the icon registry alongside it
esbuild 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.

Build one family
bash
astryx 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.

CSS var references for styling-library configs
ts
import {tokenVar, tokenVars} from '@astryxdesign/core/theme/tokens';
​
const pandaOrEmotionTheme = {
colors: {
text: tokenVar('--color-text-primary'),
surface: tokenVars['--color-background-surface'],
},
};
Resolve token values without a hook
ts
import {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.

Access resolved token values in React
tsx
import {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.