# How Astryx ThemeProvider Handles Dark Mode Switching and CSS Custom Property Overrides

> Discover how Astryx ThemeProvider manages dark mode switching and CSS custom property overrides with data-theme attributes and layered @scope rules for seamless theming.

- Repository: [Meta/astryx](https://github.com/facebook/astryx)
- Tags: deep-dive
- Published: 2026-08-04

---

**Astryx's `Theme` provider synchronizes browser color-schemes via `data-theme` attributes on the `<html>` element while injecting scoped CSS custom properties through layered `@scope` rules that respect both nested themes and portal-rendered content.**

The `Theme` component in `facebook/astryx` serves as the central hub for design-system theming, combining StyleX-based styling with runtime CSS injection to enable seamless dark mode transitions and token customization without rebuilds.

## Dark Mode Handling in Theme.tsx

### Mode Prop and Color-Scheme Styles

The `Theme` component accepts a `mode` prop typed as `'light' | 'dark' | 'system'`, defaulting to `'system'` to respect OS-level preferences. In [`packages/core/src/theme/Theme.tsx`](https://github.com/facebook/astryx/blob/main/packages/core/src/theme/Theme.tsx) at lines 56-58, this prop determines which color-scheme wrapper style gets applied.

```tsx
// Theme.tsx lines 71-79
const wrapperStyles = stylex.create({
  light: { colorScheme: 'light' },
  dark:  { colorScheme: 'dark' },
  system: { colorScheme: 'light dark' },
});

```

### Root-Level Synchronization with useRootThemeSync

The first (non-nested) `Theme` instance executes `useRootThemeSync`, which performs two critical DOM operations according to [`Theme.tsx`](https://github.com/facebook/astryx/blob/main/Theme.tsx) lines 80-95:

- Writes `data-theme="light"` or `data-theme="dark"` on the `<html>` element — consumed by [`reset.css`](https://github.com/facebook/astryx/blob/main/reset.css) to set native browser UI colors
- Removes the attribute entirely in `'system'` mode, falling back to `color-scheme: light dark`
- Adds `data-astryx-theme="<theme-name>"` for **@scope**-based CSS targeting of portals and external elements

### Nested Theme Detection

Secondary `Theme` components create a `ThemeNestingContext` (`React.createContext(false)`). When `isNested` is detected at lines 86-94, the root-sync hook is skipped entirely. This prevents duplicate attribute writes while preserving per-theme styling through scoped CSS injection.

## CSS Custom Property Overrides

### Token Definition with defineTheme.ts

Themes are constructed via `defineTheme` in [`packages/core/src/theme/defineTheme.ts`](https://github.com/facebook/astryx/blob/main/packages/core/src/theme/defineTheme.ts). The `tokens` field accepts two value shapes per [`defineTheme.ts`](https://github.com/facebook/astryx/blob/main/defineTheme.ts) lines 55-59:

- **Static strings**: Applied universally across modes
- **`[light, dark]` tuples**: Compile-time conversion to CSS `light-dark()` functions

```tsx
// defineTheme.ts usage pattern
const theme = defineTheme({
  name: 'ocean',
  tokens: {
    '--color-background': ['#f0f8ff', '#001726'], // tuple → light-dark()
    '--color-text-primary': '#111',               // static
  },
});

```

### Runtime Style Injection via useThemeStyleInjection

For unbuilt themes, `useThemeStyleInjection` ([`Theme.tsx`](https://github.com/facebook/astryx/blob/main/Theme.tsx) lines 101-158) generates dual `<style>` tags:

- **Prose defaults**: Injected into `@layer reset` for baseline typography
- **Component overrides**: Injected into `@layer astryx-theme` above StyleX layers

Both tags carry a unique `data-astryx-id` marker for deterministic cleanup on unmount.

### Scoped CSS Generation with @scope Rules

The [`generateThemeRules.ts`](https://github.com/facebook/astryx/blob/main/generateThemeRules.ts) module (lines 72-82) wraps generated CSS in `@scope` rules targeting `[data-astryx-theme="<name>"]`. This architecture delivers three benefits:

1. **Subtree isolation**: Overrides apply only within the theme's DOM boundary
2. **Portal reach**: Root `<html>` attributes extend styling to portal-rendered content
3. **Layer enforcement**: `@layer` stacking ensures predictable cascade behavior

## Implementation Examples

### Basic Dark Mode Toggle

```tsx
import { Theme, defineTheme } from '@astryxdesign/core/Theme';
import { useState } from 'react';

const ocean = defineTheme({
  name: 'ocean',
  tokens: {
    '--color-background': ['#f0f8ff', '#001726'],
    '--color-text-primary': '#111',
  },
});

export default function App() {
  const [mode, setMode] = useState<'light' | 'dark' | 'system'>('system');

  return (
    <Theme theme={ocean} mode={mode}>
      <button onClick={() => setMode(m => (m === 'light' ? 'dark' : 'light'))}>
        Toggle light/dark
      </button>
    </Theme>
  );
}

```

**Execution flow**: Button click → `mode` prop change → wrapper style update → `useRootThemeSync` rewrites `data-theme` attribute → native UI elements (scrollbars, form controls) synchronize automatically.

### Runtime Token Override Without Rebuild

```tsx
import { Theme, defineTheme } from '@astryxdesign/core/Theme';
import { useEffect } from 'react';

const defaultTheme = defineTheme({ name: 'default' });

export default function Demo() {
  useEffect(() => {
    document.documentElement.style.setProperty('--color-accent', '#ff4081');
  }, []);

  return (
    <Theme theme={defaultTheme}>
      <MyComponent />
    </Theme>
  );
}

```

The `data-astryx-theme="default"` attribute on `<html>` ensures the `@scope` rule captures this override for all themed components, including portal content.

### Nested Themes with Isolated Palettes

```tsx
const darkTheme = defineTheme({
  name: 'dark',
  tokens: {
    '--color-background': '#111',
    '--color-text-primary': '#eee',
  },
});

export default function App() {
  return (
    <Theme theme={defaultTheme}>
      <Header />
      <Theme theme={darkTheme} mode="dark">
        <Sidebar />  {/* Nested: no <html> attribute changes */}
      </Theme>
      <Footer />
    </Theme>
  );
}

```

The inner `Theme` detects nesting and suppresses `useRootThemeSync`, yet still injects its scoped CSS (`data-astryx-theme="dark"`) for localized palette application.

## Summary

- **`mode` prop** in [`Theme.tsx`](https://github.com/facebook/astryx/blob/main/Theme.tsx) drives `colorScheme` styles and `data-theme` attribute synchronization
- **`useRootThemeSync`** writes to `<html>` for native browser UI theming, skipped when `ThemeNestingContext` detects nesting
- **[`defineTheme.ts`](https://github.com/facebook/astryx/blob/main/defineTheme.ts)** converts `[light, dark]` tuples to `light-dark()` CSS functions at build time
- **`useThemeStyleInjection`** injects layered `<style>` tags with `data-astryx-id` markers for runtime theme updates
- **[`generateThemeRules.ts`](https://github.com/facebook/astryx/blob/main/generateThemeRules.ts)** produces `@scope` rules enabling subtree isolation with portal reach via root attributes

## Frequently Asked Questions

### How does Astryx detect whether to write to the `<html>` element or not?

The `Theme` component uses `ThemeNestingContext` (`React.createContext(false)`) to track provider depth. The first `Theme` in the tree receives `isNested === false` and executes `useRootThemeSync`; subsequent nested instances receive `isNested === true` and skip DOM attribute manipulation while still injecting their scoped CSS.

### Can I override theme tokens after the application has mounted?

Yes. Runtime overrides work because `data-astryx-theme` attributes persist on the `<html>` element, and the `@scope` CSS generated by [`generateThemeRules.ts`](https://github.com/facebook/astryx/blob/main/generateThemeRules.ts) targets these attributes. Direct manipulation of CSS custom properties via `document.documentElement.style.setProperty()` propagates immediately to all themed components, including those in portals.

### What happens when `mode` is set to `'system'`?

The `useRootThemeSync` hook removes the `data-theme` attribute entirely from `<html>`, allowing the browser to apply its preferred color-scheme based on OS settings. The [`reset.css`](https://github.com/facebook/astryx/blob/main/reset.css) stylesheet then activates its `color-scheme: light dark` fallback, while the wrapper style `wrapperStyles.system` applies the same declaration to the provider's DOM subtree.

### Why does Astryx use two separate style injection layers?

The `@layer reset` layer establishes typography and baseline defaults without !important conflicts, while `@layer astryx-theme` sits above StyleX's own layers to ensure token overrides win in the cascade. This separation, implemented in `useThemeStyleInjection` at [`Theme.tsx`](https://github.com/facebook/astryx/blob/main/Theme.tsx) lines 101-158, maintains predictable specificity for both prose content and component styling.