How to Use `stylex.when.ancestor()` with Hover State Markers in Astryx

stylex.when.ancestor() is Astryx's low-level API for expressing parent-to-child state relationships, allowing child elements to respond when an ancestor is hovered by combining a pseudo-selector with a scoped marker defined via stylex.defineMarker().

Astryx, Meta's experimental styling library, provides a unique pattern for ancestor-dependent styling that differs from traditional CSS selectors. Instead of descendant combinators, you use markers and conditional expressions to create type-safe, scoped relationships between components. This article explains the complete pattern for hover states based on the actual facebook/astryx source code.

The Core Pattern: Markers + Conditional Expressions

Ancestor-based hover styling in Astryx requires three coordinated pieces:

  1. Define a marker scoped to the component that owns the ancestor element
  2. Attach the marker to the ancestor via stylex.props()
  3. Style the child using stylex.when.ancestor(':hover', marker) with an optional media query guard

This pattern appears throughout the Astryx codebase, particularly in packages/core/src/TabList/TabMenu.tsx and packages/core/src/hooks/containerReveal.pool.stylex.ts.

Step 1: Defining a Scoped Marker

Markers are created with stylex.defineMarker() and should be exported from a dedicated markers file. Per the design rules in CLAUDE.md, markers must be scoped to the component that owns the ancestor element.

// packages/core/src/TabList/tab.markers.stylex.ts
import {stylex} from '@astryxdesign/stylex';

export const tabScope = stylex.defineMarker();

Multiple markers can be defined for complex components. The containerReveal.pool.stylex.ts file demonstrates this with six separate markers (m0 through m5) for a pooled animation system:

// packages/core/src/hooks/containerReveal.pool.stylex.ts
export const m0 = stylex.defineMarker();
export const m1 = stylex.defineMarker();
// ... m2 through m5

Step 2: Attaching the Marker to the Ancestor

The ancestor component must include the marker in its stylex.props() call. This creates the linkage that child components can detect.

// Example: Tab component with marker attached
import {tabScope} from './tab.markers.stylex';

function Tab({children}: Props) {
  return (
    <button {...stylex.props(tabScope)}>
      {children}
    </button>
  );
}

Without this step, stylex.when.ancestor() has no marker to match against—the child styles will never activate.

Step 3: Styling the Child with Hover Detection

Child components use the computed property syntax with stylex.when.ancestor() to apply styles when the marked ancestor enters a hover state.

// packages/core/src/TabList/TabMenu.tsx
import {tabScope} from './tab.markers.stylex';

export const tabMenuStyle = stylex.create({
  root: {
    opacity: 0, // default hidden state
    
    [stylex.when.ancestor(':hover', tabScope)]: {
      opacity: 1,
    },
  },
});

The first parameter accepts pseudo-selectors only.class, [attr], or other complex selectors are not supported per internal/stylex-capabilities/CAPABILITIES.md.

Adding the Hover Media Query Guard

For production-ready hover effects, Astryx supports wrapping styles in @media (hover: hover). This prevents hover states from activating on touch devices where :hover behavior can be erratic.

[stylex.when.ancestor(':hover', tabScope)]: {
  '@media (hover: hover)': {
    opacity: 1,
  },
}

The containerReveal.pool.stylex.ts implementation demonstrates this pattern extensively:

// packages/core/src/hooks/containerReveal.pool.stylex.ts
export const containerRevealStyle = stylex.create({
  reveal0: {
    [stylex.when.ancestor(':hover', m0)]: {
      '@media (hover: hover)': '0s, 0s',
    },
    [stylex.when.ancestor(':focus-within', m0)]: '0s, 0s',
  },
});

Note that the media query guard is automatically emitted when the style value contains an @media (hover: hover) key—no additional configuration is required.

Supported Pseudo-Selectors for Ancestor Conditions

According to internal/stylex-capabilities/CAPABILITIES.md, the following pseudo-selectors work with stylex.when.ancestor():

Selector Use Case Example Source
:hover Mouse hover states containerReveal.pool.stylex.ts
:focus-within Focus contained within ancestor containerReveal.pool.stylex.ts
:active Active/pressed state CAPABILITIES.md
:disabled Disabled state CAPABILITIES.md

Arbitrary attribute selectors like [data-state="open"] are not supported. Only selectors beginning with : are valid.

Complete Working Example

Here's the full implementation pattern from the Astryx source files:

// === tab.markers.stylex.ts ===
import {stylex} from '@astryxdesign/stylex';
export const tabScope = stylex.defineMarker();

// === Tab.tsx (ancestor component) ===
import {tabScope} from './tab.markers.stylex';

function Tab({children}: Props) {
  return <button {...stylex.props(tabScope)}>{children}</button>;
}

// === TabMenu.stylex.ts (child styles) ===
import {tabScope} from './tab.markers.stylex';

export const styles = stylex.create({
  menu: {
    opacity: 0,
    transform: 'translateY(-8px)',
    transition: 'opacity 150ms, transform 150ms',
    
    [stylex.when.ancestor(':hover', tabScope)]: {
      '@media (hover: hover)': {
        opacity: 1,
        transform: 'translateY(0)',
      },
    },
  },
});

Multiple Markers and Complex UIs

For components managing multiple ancestor possibilities—like tooltip systems or shared animation pools—define multiple markers and reference them individually:

export const poolStyles = stylex.create({
  item0: {
    [stylex.when.ancestor(':hover', m0)]: {
      '@media (hover: hover)': { /* styles */ },
    },
    [stylex.when.ancestor(':focus-within', m0)]: { /* styles */ },
  },
  item1: {
    [stylex.when.ancestor(':hover', m1)]: {
      '@media (hover: hover)': { /* styles */ },
    },
  },
  // ... item2 through item5
});

Each marker operates independently, allowing precise control over which ancestor triggers which child styles.

Key Limitations to Know

  • Selector restriction: Only :pseudo selectors allowed—no classes, IDs, or attribute selectors
  • Marker scoping: Markers must be defined in the ancestor's component scope, not the child's
  • Runtime dependency: Both ancestor and child must be rendered for the relationship to establish; there's no CSS-only fallback

These constraints enable Astryx's compile-time optimizations and type safety guarantees.

Summary

  • stylex.when.ancestor(':hover', marker) creates parent-to-child hover relationships without CSS combinators
  • Markers are defined with stylex.defineMarker() and attached via stylex.props()
  • Media query guards (@media (hover: hover)) prevent hover effects on non-hover-capable devices
  • Pseudo-selector only: :hover, :focus-within, :active, and :disabled are supported; attribute selectors are not
  • Source references: containerReveal.pool.stylex.ts and TabMenu.tsx demonstrate production patterns in the facebook/astryx repository

Frequently Asked Questions

Can I use stylex.when.ancestor() with class selectors like .active?

No. According to internal/stylex-capabilities/CAPABILITIES.md, only pseudo-selectors beginning with : are supported. You cannot use .class, #id, or [data-attribute] selectors. For component state, use :active or define a separate marker for each state variation.

Touch devices expose inconsistent :hover behavior—elements may remain "hovered" after tap until another element receives focus. The media query guard ensures hover styles only apply on devices with precise pointing devices (mouse, trackpad, stylus). Astryx automatically emits this guard when you include the @media (hover: hover) key in your style object.

How do markers differ from CSS custom properties or data attributes?

Markers are compile-time identifiers, not runtime values. They allow Astryx to generate optimized CSS without selector string escaping issues or global namespace pollution. Unlike data-* attributes, markers are type-checked and scoped to specific component boundaries enforced by the framework.

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 →