Compound Component Patterns in Astryx: Building SelectableCard and MultiSelector
Astryx provides five core patterns for building compound components: Context Provider + Hook for state sharing, StyleX marker definitions for scoped styling, the stylex.when API for declarative state selectors, a dual Data-Driven/Fallback API, and defineTheme overrides for theme customization.
Astryx, Meta's React component library, delivers a systematic approach to compound component patterns that powers cohesive, state-aware UI primitives. This guide examines how SelectableCard and MultiSelector implement these patterns using context-based state management, StyleX markers, and flexible composition APIs.
Context Provider + Hook Pattern
Every compound component in Astryx ships with a dedicated context file that eliminates prop drilling. The DropdownMenuContext.tsx file demonstrates the reusable pattern:
// From packages/core/src/DropdownMenu/DropdownMenuContext.tsx
const DropdownMenuContext = React.createContext<{
isOpen: boolean;
selectedItem: string | null;
// ... additional state
} | null>(null);
export function useDropdownMenu() {
const context = useContext(DropdownMenuContext);
if (!context) {
throw new Error('useDropdownMenu must be used within DropdownMenu');
}
return context;
}
Child components consume this hook to read and write shared state. For SelectableCard, the internal context propagates selection state to inner parts like the checkmark icon without exposing implementation details through the public API.
StyleX Marker Definitions for Scoped Styling
Astryx uses stylex.defineMarker() in component *.stylex.ts files to generate scoped CSS markers that map directly to component states.
// Pattern used in SelectableCard.stylex.ts
import * as stylex from '@stylexjs/stylex';
export const markers = {
selected: stylex.defineMarker('selected'),
disabled: stylex.defineMarker('disabled'),
hovered: stylex.defineMarker('hovered'),
};
These markers create a clear styling contract between the component and consuming themes. The marker:selected identifier becomes a targetable token that theme authors can override without touching component internals.
The stylex.when API for Declarative State Selectors
The stylex.when API provides compound-state selectors without CSS nesting. SelectableCard leverages this for focus-visible handling:
import * as stylex from '@stylexjs/stylex';
import {markers} from './SelectableCard.stylex';
const styles = stylex.create({
root: {
borderWidth: 1,
borderStyle: 'solid',
borderColor: 'var(--color-border)',
},
selected: {
// Applied when parent has marker:selected
borderColor: 'var(--color-primary)',
backgroundColor: 'var(--color-primary-subtle)',
},
focusVisible: stylex.when.ancestor(':focus-visible', markers.selected, {
outline: '2px solid var(--color-focus-ring)',
outlineOffset: 2,
}),
});
stylex.when supports three relationship types:
stylex.when.ancestor()— parent element statesstylex.when.descendant()— child element statesstylex.when.sibling()— adjacent element states
This declarative approach eliminates brittle CSS selectors and ensures style encapsulation.
Data-Driven API with Compound Fallback
MultiSelector demonstrates Astryx's dual-mode API that supports both rapid data-driven usage and custom JSX composition:
Data-Driven Mode
import {MultiSelector} from '@astryx/core';
export function TagPicker() {
const [selected, setSelected] = useState<string[]>([]);
const options = [
{value: 'react', label: 'React'},
{value: 'vue', label: 'Vue'},
{value: 'svelte', label: 'Svelte'},
];
return (
<MultiSelector
options={options}
value={selected}
onChange={setSelected}
/>
);
}
Compound Mode (Custom Rendering)
export function CustomTagPicker() {
const [selected, setSelected] = useState<string[]>([]);
return (
<MultiSelector value={selected} onChange={setSelected}>
<MultiSelector.Item value="react">
<Icon name="react" /> React
</MultiSelector.Item>
<MultiSelector.Item value="vue">
<Badge>Popular</Badge> Vue
</MultiSelector.Item>
<MultiSelector.Item value="svelte">
Svelte <Icon name="external" />
</MultiSelector.Item>
</MultiSelector>
);
}
Both modes share the same internal context. The options prop is simply a convenience that auto-generates MultiSelector.Item children with standard rendering.
defineTheme Overrides for Custom Theming
Theme authors target compound component states through the defineTheme API without writing additional CSS:
import {defineTheme} from '@astryx/core';
const customTheme = defineTheme({
components: {
'selectable-card': {
'marker:selected': {
backgroundColor: 'var(--brand-surface)',
borderColor: 'var(--brand-border)',
},
'marker:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
},
},
'multi-selector': {
'marker:open': {
boxShadow: 'var(--shadow-elevated)',
},
},
},
});
This pattern creates a single source of truth where component states are addressable tokens in the theme system.
Complete SelectableCard Example
import {SelectableCard} from '@astryx/core';
import {useState} from 'react';
export function PlanSelector() {
const [selected, setSelected] = useState(false);
return (
<SelectableCard
label="Pro Plan"
isSelected={selected}
onChange={() => setSelected(!selected)}
>
<h3>Unlimited Projects</h3>
<ul>
<li>Priority support</li>
<li>Custom integrations</li>
<li>SSO authentication</li>
</ul>
<p>$29/month</p>
</SelectableCard>
);
}
Key implementation details from the source:
SelectableCard.tsxhandles the context provider setupisSelectedandonChangeform the controlled component API- Internal checkmark visibility toggles via the shared context
- Focus and hover states use
stylex.when.ancestor()fromSelectableCard.stylex.ts
Key Source Files
| Component | File Path | Purpose |
|---|---|---|
| SelectableCard | packages/core/src/SelectableCard/SelectableCard.tsx |
Core implementation with context provider |
| SelectableCard | packages/core/src/SelectableCard/SelectableCard.stylex.ts |
Marker definitions and stylex.when usage |
| MultiSelector | packages/core/src/MultiSelector/MultiSelector.tsx |
Dual-mode API implementation |
| Context Pattern | packages/core/src/DropdownMenu/DropdownMenuContext.tsx |
Reusable context + hook template |
| Theming | packages/core/src/theme/defineTheme.test.tsx |
Theme override examples |
Summary
- Context Provider + Hook: Shares state across compound children without prop drilling, implemented in files like
DropdownMenuContext.tsx - StyleX Markers:
defineMarker()creates scoped, themeable state tokens in*.stylex.tsfiles stylex.whenAPI: Declarative selectors for ancestor, descendant, and sibling relationships without CSS nesting- Dual API Design:
MultiSelectorsupports bothoptionsprop and explicit<MultiSelector.Item>children defineThemeIntegration: Component states become overrideable theme tokens viamarker:selected,marker:disabled, etc.
These patterns combine to give Astryx components predictable state management, encapsulated styling, and flexible composition boundaries.
Frequently Asked Questions
How does Astryx avoid prop drilling in compound components?
Astryx uses a Context Provider + Hook pattern where each compound component exports a context file (e.g., DropdownMenuContext.tsx) that provides a useDropdownMenu() hook. Child components call this hook to access shared state directly, eliminating the need to thread props through intermediate layers.
What is a StyleX marker and why does Astryx use them?
A StyleX marker is a scoped CSS identifier created via stylex.defineMarker() that represents a component state. Astryx uses markers to create a styling contract between components and themes—states like marker:selected become addressable tokens that theme authors can override through defineTheme() without modifying component source code.
Can I use MultiSelector without the options prop?
Yes. MultiSelector supports compound mode where you render explicit <MultiSelector.Item> children instead of passing an options array. This enables custom markup, additional UI elements per item, or conditional rendering while maintaining the same internal state management and selection behavior.
How do I theme the selected state of SelectableCard?
Use defineTheme with the component name and marker state as keys: defineTheme({components: {'selectable-card': {'marker:selected': {backgroundColor: '...'}}}}). This targets the internal marker without requiring CSS selectors or component prop overrides.
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 →