Skip to content

Theming Infrastructure

cixzhang edited this page Jun 26, 2026 · 2 revisions

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:

⚠️ 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.

Architecture Overview

                          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.

Token Layers

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)'

Neutral Gray Tokens

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:

  1. Is it a hover, active, or keyboard-highlighted state? → --color-overlay-hover
  2. Is it a slim track/rail (slider, progress, switch)? → --color-track
  3. Is it a container with other content inside (text, icons, controls)? → --color-muted
  4. Is it a self-contained element sitting on a surface? → --color-secondary

--color-wash is reserved for page-level backgrounds only (AppShell body, nav areas). It should never appear on individual components — use --color-muted or --color-secondary instead.

Note: --color-overlay-hover and --color-muted may 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.

Higher-Order Transforms

Instead of manually setting every token, themes can use scale configs that generate related tokens from a few parameters:

color

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

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

radius: { base: 4, multiplier: 1 }

Generates --radius-1 through --radius-4. Setting multiplier: 0 gives a sharp/brutalist look.

motion

motion: { fast: 175, medium: 410, ratio: 0.75 }

Generates duration min/max variants.

Precedence: Explicit tokens always override scale-generated values.

Cascade Model

The expected cascade for any visual property on a component, from lowest to highest priority:

  1. Component base — the default look from stylex.create() in the component source
  2. Theme base overridesdefineTheme component overrides targeting base
  3. Component variant/prop styles — size, color, orientation from component props
  4. Theme variant overridesdefineTheme overrides targeting specific variants or states
  5. Consumer overridesxstyle, 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.

Component Theming

Astryx provides three mechanisms for per-component theming, each for a different use case.

1. Class targeting via xdsThemeProps (primary)

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:value or prop: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.

2. Component CSS variables

Components expose CSS custom properties for values that participate in calc() expressions or need to cascade to children. There are two kinds:

Private vars — set via standard CSS properties

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.

Public vars — set directly

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 padding expansion

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.

CSS variable naming convention

Component CSS variables follow this pattern:

--[_]<component>[-<part>]-<property>
  • <component> — lowercase component name without Astryx prefix, matching xdsThemeProps output (e.g. button, dropdown-menu, segmented-control)
  • <part> (optional) — sub-element within the component (e.g. composer in chat-composer, icon-only in button-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

Adding vars to new components

  1. Name the var following the convention above
  2. Add it to StyleX styles with private: true in the doc if it's derivable from a standard CSS property
  3. Document it in the component's .doc.mjs theming.vars[]
  4. If derivable, add a derived entry in the doc and derivedVarRegistry.ts
  5. The consistency test catches drift between source, docs, and registry

3. Custom variants (extensibility)

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.

On-Media Theming (Inverted Surfaces)

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.

How it works

  1. color-scheme is the primary mechanism — setting color-scheme: dark on the media element makes all light-dark() tokens resolve to their dark-side values automatically.
  2. 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).
  3. Parent theme's component overrides flow through — structural styling (border-radius, font-weight, text-transform) is preserved. Only color tokens change.
  4. Themes can further customize components on media surfaces via onDark.components / onLight.components.

defineTheme config

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)',
        },
      },
    },
  },
});

Generated CSS

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);
  }
}

MediaTheme component

// 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.

Token rules for --color-on-dark / --color-on-light

These tokens must be absolute values, not mode-relative light-dark() tuples:

  • --color-on-dark should always be light (e.g. white) — it's "the color to use ON a dark surface"
  • --color-on-light should 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.

useImageMode hook

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.

Theming Targets Reference

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.

CLI theme config

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.

Font Declarations

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):

  1. Best: Consumer loads fonts in <head> — no FOUC
  2. Good: xds theme build prints a warning with the <link> snippet
  3. 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.

Domain Tokens

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

Syntax Highlighting Themes

Code components (CodeBlock, CodeEditor) use 14 syntax tokens for coloring. These are independent from the core theme — they can be configured at multiple levels.

The 14 Syntax Tokens

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.

Cascading Priority

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)

Configuration Approaches

1. Automatic (no config needed)

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
  },
});

2. Explicit tokens in defineTheme

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.

3. SyntaxTheme provider (runtime override)

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.

4. Custom syntax themes

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',
  },
});

Community Theme Presets

@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.

Architecture

┌─────────────────────────────────────────────────────┐
│ @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.

Consumption Paths

Source build (StyleX interop)

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:

  1. @layer stylex — component base styles
  2. @layer xds.theme — theme overrides
  3. 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 })

Dist build (any styling library)

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.

CDN (zero build)

Link to CSS files directly. No bundler required. Layers are pre-configured.

Theme Switching

<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.

Clone this wiki locally