How Astryx ThemeProvider Handles Dark Mode Switching and CSS Custom Property Overrides
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 at lines 56-58, this prop determines which color-scheme wrapper style gets applied.
// 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 lines 80-95:
- Writes
data-theme="light"ordata-theme="dark"on the<html>element — consumed byreset.cssto set native browser UI colors - Removes the attribute entirely in
'system'mode, falling back tocolor-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. The tokens field accepts two value shapes per defineTheme.ts lines 55-59:
- Static strings: Applied universally across modes
[light, dark]tuples: Compile-time conversion to CSSlight-dark()functions
// 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 lines 101-158) generates dual <style> tags:
- Prose defaults: Injected into
@layer resetfor baseline typography - Component overrides: Injected into
@layer astryx-themeabove 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 module (lines 72-82) wraps generated CSS in @scope rules targeting [data-astryx-theme="<name>"]. This architecture delivers three benefits:
- Subtree isolation: Overrides apply only within the theme's DOM boundary
- Portal reach: Root
<html>attributes extend styling to portal-rendered content - Layer enforcement:
@layerstacking ensures predictable cascade behavior
Implementation Examples
Basic Dark Mode Toggle
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
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
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
modeprop inTheme.tsxdrivescolorSchemestyles anddata-themeattribute synchronizationuseRootThemeSyncwrites to<html>for native browser UI theming, skipped whenThemeNestingContextdetects nestingdefineTheme.tsconverts[light, dark]tuples tolight-dark()CSS functions at build timeuseThemeStyleInjectioninjects layered<style>tags withdata-astryx-idmarkers for runtime theme updatesgenerateThemeRules.tsproduces@scoperules 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 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 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 lines 101-158, maintains predictable specificity for both prose content and component styling.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →