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 states
  • stylex.when.descendant() — child element states
  • stylex.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.tsx handles the context provider setup
  • isSelected and onChange form the controlled component API
  • Internal checkmark visibility toggles via the shared context
  • Focus and hover states use stylex.when.ancestor() from SelectableCard.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.ts files
  • stylex.when API: Declarative selectors for ancestor, descendant, and sibling relationships without CSS nesting
  • Dual API Design: MultiSelector supports both options prop and explicit <MultiSelector.Item> children
  • defineTheme Integration: Component states become overrideable theme tokens via marker: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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →