-
Notifications
You must be signed in to change notification settings - Fork 33
Component Authoring Guide
Building a new component end-to-end? See Component Lifecycle for the full lifecycle. This page is the practical reference for implementation conventions.
The practical reference for building new Astryx components. Covers file structure, StyleX patterns, token usage, and conventions.
Every component lives in its own directory under /packages/core/src/:
/packages/core/src/Button/
├── Button.tsx # Main component implementation
├── index.ts # Exports
├── Button.doc.mjs # Typed documentation (JSDoc + ComponentDoc type)
├── Button.stories.tsx # Storybook stories
└── Button.test.tsx # Tests
The naming convention is Astryx{ComponentName} — both for the directory contents and the exported component name:
| Component | Directory | File | Export |
|---|---|---|---|
| Button | Button/ |
Button.tsx |
Button |
| Text Input | TextInput/ |
TextInput.tsx |
TextInput |
| Stack | Stack/ |
Stack.tsx |
Stack |
Every component directory has a {Name}.doc.mjs file that exports typed documentation. This replaces the old README.md approach — docs are now structured data that the CLI imports directly (no markdown parsing).
/** @type {import('../docs-types').ComponentDoc} */
export const docs = {
name: 'Button',
description: 'Primary interactive element for user actions.',
features: [
"Variants: 'primary', 'secondary', 'ghost', 'destructive'",
'Sizes: sm (28px), md (32px), lg (36px)',
'Loading state: Shows spinner, disables interaction',
],
props: [
{
name: 'label',
type: 'string',
description: 'Accessible label; used as aria-label for icon-only buttons.',
required: true,
},
{
name: 'variant',
type: "'primary' | 'secondary' | 'ghost' | 'destructive'",
description: 'Visual style variant.',
default: "'secondary'",
},
// ... more props
],
examples: [
{
label: 'Basic',
code: '<Button variant="primary">Save</Button>',
},
{
label: 'With icon',
code: '<Button icon={PlusIcon} variant="secondary">Add</Button>',
},
],
theming: {
targets: [
{className: 'astryx-button', visualProps: ['variant', 'size']},
],
vars: [
{name: '--button-radius', description: 'Border radius', default: 'var(--radius-element)'},
],
},
accessibility: [
'Uses native <button> element for correct ARIA semantics.',
'Icon-only buttons use label prop as aria-label.',
],
keyboard: 'Enter/Space activates the button; Tab/Shift+Tab moves focus',
notes: [
'Hover states use backgroundImage overlay pattern for consistent layering.',
],
};The ComponentDoc type is defined in packages/core/src/docs-types.ts. Run type checking with:
pnpm --filter @xds/core typecheck:docsThis uses tsc --checkJs to validate all .doc.mjs files against the ComponentDoc type. CI runs this automatically.
-
Single component (Button, Switch, Badge): Use
propsdirectly on the doc object -
Multi-component (Table, Dialog, Layer): Use
componentsarray with separate entries for each exported component
// Multi-component example (Table)
export const docs = {
name: 'Table',
description: 'Data table with rich cell content via renderCell.',
examples: [/* top-level composition examples */],
components: [
{
name: 'Table',
description: 'Main table component with data-driven rows.',
props: [/* Table props */],
examples: [/* Table-specific examples */],
},
{
name: 'TableRow',
description: 'Individual row within BaseTable.',
props: [/* TableRow props */],
examples: [/* TableRow-specific examples */],
},
// ... more components
],
};Every component file starts with a JSDoc block describing its role:
/**
* Button — Primary interactive element for user actions.
*
* @input variant, size, disabled, loading, children
* @output Styled <button> element with theme-aware variants
* @position Inline within forms, toolbars, cards, dialogs
*
* SYNC: This component consumes ThemeContext for variant overrides.
*/| Tag | Purpose |
|---|---|
@input |
Props the component accepts |
@output |
What the component renders |
@position |
Where the component is typically used in a layout |
SYNC |
Notes about dependencies or coordination with other parts of the system |
All component files that use React hooks must include 'use client'. See RSC Compatibility for the full decision.
Astryx components consume theme via useContext, making them client components. The directive goes after any file-level JSDoc, before imports:
/**
* @file MyComponent.tsx
*/
'use client';
import {forwardRef, useContext} from 'react';Rule of thumb: If your file imports anything from
'react'other than types andforwardRef, it needs'use client'.
'use client';
import {forwardRef, type HTMLAttributes, type ReactNode} from 'react';
import * as stylex from '@stylexjs/stylex';
import {colorTokens, spacingTokens, radiusTokens} from '../theme/tokens.stylex';
const styles = stylex.create({
base: {
// Base styles using tokens
fontFamily: 'inherit',
borderWidth: 0,
cursor: 'pointer',
},
});
const variants = stylex.create({
default: {
backgroundColor: colorTokens.surface,
color: colorTokens.textPrimary,
},
primary: {
backgroundColor: colorTokens.accent,
color: 'white',
},
});
// Derive type from StyleX object
export type MyComponentVariant = keyof typeof variants;
export interface MyComponentProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'style' | 'className'> {
variant?: MyComponentVariant;
children: ReactNode;
}
export const MyComponent = forwardRef<HTMLDivElement, MyComponentProps>(
({variant = 'default', children, ...props}, ref) => {
return (
<div
ref={ref}
{...stylex.props(styles.base, variants[variant])}
{...props}>
{children}
</div>
);
},
);
MyComponent.displayName = 'MyComponent';-
forwardRef— All components forward refs for parent access -
stylex.create— Styles are static objects, compiled at build time - Token imports — Always use tokens, never hardcoded values
-
displayName— Set explicitly for React DevTools
Derive variant types directly from the StyleX object so types stay in sync with styles automatically:
const variants = stylex.create({
primary: { /* ... */ },
secondary: { /* ... */ },
ghost: { /* ... */ },
});
export type ButtonVariant = keyof typeof variants;
// Results in: 'primary' | 'secondary' | 'ghost'When you add or remove a variant from the StyleX object, the type updates automatically. No manual type maintenance needed.
The same pattern works for sizes or any other variant dimension:
const sizes = stylex.create({
sm: { padding: spacingTokens.space1, fontSize: '14px' },
md: { padding: spacingTokens.space2, fontSize: '16px' },
lg: { padding: spacingTokens.space3, fontSize: '18px' },
});
export type ButtonSize = keyof typeof sizes;
// Results in: 'sm' | 'md' | 'lg'Always use tokens from tokens.stylex instead of hardcoded values:
import {
colorTokens,
spacingTokens,
radiusTokens,
transitionTokens,
typographyTokens,
elevationTokens,
} from '../theme/tokens.stylex';
const styles = stylex.create({
base: {
backgroundColor: colorTokens.surface,
padding: spacingTokens.space3,
borderRadius: radiusTokens.element,
fontFamily: typographyTokens.fontFamilyBody,
transitionDuration: transitionTokens.fast,
boxShadow: elevationTokens.base,
},
});| Category | Tokens | Examples |
|---|---|---|
colorTokens |
Semantic colors, text, icons, status, overlays, dividers |
accent, surface, textPrimary, textSecondary, hoverOverlay, pressedOverlay, focusOutline, negative, positive
|
spacingTokens |
Consistent spacing scale |
space0 (0px), space0_5 (2px), space1 (4px), space2 (8px), space3 (12px), space4 (16px), space5 (20px), space6 (24px), space7 (28px) |
radiusTokens |
Border radius for different contexts |
rounded, container, element, content
|
elevationTokens |
Box shadows |
base, thumb, dialog, hover, menu
|
transitionTokens |
Animation durations |
fast (0.15s), normal (0.2s) |
typographyTokens |
Font families |
fontFamilyBody, fontFamilyCode, fontFamilyHeading
|
For components that need consumer-provided styles, use the xstyle prop. This ensures consumers use StyleX (maintaining compile-time optimization) instead of inline styles.
import type {StyleXStyles} from '@stylexjs/stylex';
export interface MyComponentProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'style' | 'className'> {
/** StyleX styles to apply to the component. */
xstyle?: StyleXStyles;
children?: ReactNode;
}
export const MyComponent = forwardRef<HTMLDivElement, MyComponentProps>(
function MyComponent({xstyle, children, ...props}, ref) {
return (
<div ref={ref} {...stylex.props(styles.base, xstyle)} {...props}>
{children}
</div>
);
},
);-
Omit
styleandclassName— UseOmit<HTMLAttributes<HTMLElement>, 'style' | 'className'>to prevent inline styles and ensure StyleX usage. -
Merge order matters — Pass
xstyleas the last argument tostylex.props()so consumer styles override component defaults. -
Constrained vs. freeform:
-
Higher-level components (Button, Card): Prefer constrained APIs with specific props (
variant,size) over openxstyle. Enforces design consistency. -
Primitive/layout components (Stack, Box): Allow freeform
xstylesince these are building blocks that need flexibility.
-
Higher-level components (Button, Card): Prefer constrained APIs with specific props (
import * as stylex from '@stylexjs/stylex';
import {colorTokens, radiusTokens} from '@xds/core';
const customStyles = stylex.create({
highlight: {
backgroundColor: colorTokens.wash,
borderRadius: radiusTokens.element,
},
});
// Single style
<HStack gap="space2" xstyle={customStyles.highlight}>
<Item />
</HStack>
// Multiple styles via array
<VStack xstyle={[styles.container, styles.padded]}>
<Content />
</VStack>Apply styles conditionally using stylex.props:
{...stylex.props(
styles.base,
variants[variant],
sizes[size],
isDisabled && styles.disabled,
isLoading && styles.loading,
xstyle, // Consumer override last
)}StyleX merges these left-to-right. Later styles override earlier ones for the same property. false/undefined values are safely ignored.
StyleX supports pseudo-selectors via nested objects with default for the base state:
const styles = stylex.create({
interactive: {
backgroundColor: {
default: colorTokens.surface,
':hover': colorTokens.surfaceHover,
':active': colorTokens.surfaceActive,
},
outline: {
default: 'none',
':focus-visible': `2px solid ${colorTokens.focusOutline}`,
},
outlineOffset: {
default: null,
':focus-visible': '3px',
},
},
});All :hover styles MUST use @media (hover: hover) guards to prevent "sticky hover" on mobile/touch devices:
const styles = stylex.create({
interactive: {
backgroundColor: {
default: null,
':hover': {
'@media (hover: hover)': colorTokens.hoverOverlay,
},
':active': colorTokens.pressedOverlay, // No guard needed for :active
},
},
});-
:hover— Always wrap in@media (hover: hover) -
:active— No guard needed (press feedback is good on touch) -
:focus-visible— No guard needed (keyboard focus must always work)
For interactive elements where hover/active colors should layer on top of the base background (not replace it), use backgroundImage:
const variants = stylex.create({
primary: {
backgroundColor: colorTokens.accent,
backgroundImage: {
default: null,
':hover': {
'@media (hover: hover)': `linear-gradient(${colorTokens.hoverOverlay}, ${colorTokens.hoverOverlay})`,
},
':active': `linear-gradient(${colorTokens.pressedOverlay}, ${colorTokens.pressedOverlay})`,
},
},
});Why this works: CSS background-image renders on top of background-color. By using a solid-color linear-gradient as the overlay, you get a semi-transparent tint over whatever the base color is — without needing a pseudo-element.
Why not ::after? StyleX doesn't support combined pseudo-selectors like :hover::after, so this backgroundImage trick is the standard Astryx pattern.
Some components need different focus outline colors per variant:
const variants = stylex.create({
primary: {
outline: {
default: null,
':focus-visible': `2px solid ${colorTokens.focusOutline}`,
},
outlineOffset: {
default: null,
':focus-visible': '3px',
},
},
destructive: {
outline: {
default: null,
':focus-visible': `2px solid ${colorTokens.negative}`,
},
outlineOffset: {
default: null,
':focus-visible': '3px',
},
},
});Pattern for components with loading indicators:
const loadingStyles = stylex.create({
loading: {
position: 'relative',
color: 'transparent', // Hide text while loading
},
spinnerContainer: {
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
spinner: {
animationName: stylex.keyframes({
to: {transform: 'rotate(360deg)'},
}),
animationDuration: '0.6s',
animationTimingFunction: 'linear',
animationIterationCount: 'infinite',
},
});
// In component JSX:
<button
ref={ref}
{...stylex.props(
styles.base,
variants[variant],
loading && loadingStyles.loading,
)}
disabled={disabled || loading}
{...props}>
{children}
{loading && (
<span {...stylex.props(loadingStyles.spinnerContainer)}>
<Spinner {...stylex.props(loadingStyles.spinner)} />
</span>
)}
</button>The key trick: set color: 'transparent' on the loading state to hide the text content while keeping the button's dimensions stable, then absolutely position the spinner on top.
Use stylex.keyframes for animations. Define them inline within stylex.create:
const styles = stylex.create({
spinner: {
animationName: stylex.keyframes({
to: {transform: 'rotate(360deg)'},
}),
animationDuration: '0.6s',
animationTimingFunction: 'linear',
animationIterationCount: 'infinite',
},
fadeIn: {
animationName: stylex.keyframes({
from: {opacity: 0},
to: {opacity: 1},
}),
animationDuration: transitionTokens.normal,
animationTimingFunction: 'ease-out',
},
slideDown: {
animationName: stylex.keyframes({
from: {
opacity: 0,
transform: 'translateY(-8px)',
},
to: {
opacity: 1,
transform: 'translateY(0)',
},
}),
animationDuration: transitionTokens.normal,
animationTimingFunction: 'ease-out',
},
});Use transition tokens (transitionTokens.fast, transitionTokens.normal) for durations to stay consistent with the theme.
If your component needs to escape container padding (like Table or Divider), read --container-padding-inline and/or --container-padding-block and apply negative margins. If your component creates a new container context, reset both variables for descendants. See Container Padding System for the full reference and patterns.
Every component must apply xdsThemeProps so theme authors can target it via defineTheme component overrides. This is the primary mechanism for per-component theming.
import {xdsThemeProps, mergeProps} from '../utils';
// On the element with visual styles (background, shadow, radius)
<div
{...mergeProps(
xdsThemeProps('card'),
stylex.props(styles.cardOuter),
)}>Key rules:
-
xdsThemePropsgoes on the element with the visual styles — not necessarily the root element. For layer-based components (Tooltip, HoverCard, Popover), it goes on the visual container, not the positioning wrapper. - Pass variant props for variant-specific targeting:
xdsThemeProps('button', {variant, size}) - Use
mergePropsto combinexdsThemePropswithstylex.props - Do NOT add
xdsThemePropsto composition wrappers that just wrap other themed Astryx components — it creates specificity conflicts
Visually distinct sub-elements within a component need their own xdsThemeProps so theme authors can target them independently.
Add a target when the sub-element:
- Has its own color, background, or border distinct from the parent
- Cannot be styled via a CSS descendant selector from the root
Do NOT add a target when the sub-element:
- Is structural only (wrapper divs for layout)
- Is text content that inherits from the parent
- Has appearance fully controlled by global tokens
// Switch: track gets root xdsThemeProps, thumb gets sub-element target
<div {...mergeProps(xdsThemeProps('switch'), stylex.props(styles.track))}>
<div {...mergeProps(xdsThemeProps('switch-thumb'), stylex.props(styles.thumb))} />
</div>Scale guideline: Most components need 0-1 sub-element targets. Compound components (Layout, Table) may need 3-5. If a component needs more than 5, consider decomposing it.
Prefer xdsThemeProps targeting for most theming. Only expose a CSS custom property when:
- The value participates in a
calc()expression (e.g. concentric radius) - Multiple sibling elements reference the same value
// Component var — only because items derive radius via calc()
const styles = stylex.create({
menu: {
'--dropdown-radius': radiusVars['--radius-2'],
'--dropdown-padding': spacingVars['--spacing-1'],
borderRadius: 'var(--dropdown-radius)',
},
item: {
// Concentric radius — derived from container vars
borderRadius: 'max(0px, calc(var(--dropdown-radius) - var(--dropdown-padding)))',
},
});Document component vars in the .doc.mjs file's theming.vars field:
theming: {
targets: [{className: 'astryx-dropdown-menu'}],
vars: [
{name: '--dropdown-radius', description: 'Menu popup radius', default: 'var(--radius-2)'},
{name: '--dropdown-padding', description: 'Menu popup padding', default: 'var(--spacing-1)'},
],
},Components with variant props use an interface registry pattern so theme packages can add custom variants via module augmentation:
// Define variants as an interface (not a union type)
export interface ButtonVariantMap {
primary: true;
secondary: true;
ghost: true;
destructive: true;
}
export type ButtonVariant = keyof ButtonVariantMap;Theme packages can then extend the map:
// In @xds/theme-meta/types.ts
declare module '@xds/core/Button' {
interface ButtonVariantMap {
'primary-muted': true;
'primary-outline': true;
}
}Runtime behavior: Unknown variants gracefully receive base styles only. StyleX's styles.variants[variant] returns undefined for unrecognized keys, which StyleX ignores. The theme provides the visual definition through component overrides:
defineTheme({
components: {
button: {
'variant:primary-muted': { backgroundColor: '...' },
},
},
});Render data-variant={variant} on the element with xdsThemeProps so theme CSS can target custom variants.
import {useContext} from 'react';
import {ThemeContext} from '../theme/ThemeContext';
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({variant = 'primary', children, ...props}, ref) => {
const themeContext = useContext(ThemeContext);
const themeVariantOverride =
themeContext?.theme.components?.button?.variants?.[variant];
return (
<button
ref={ref}
{...stylex.props(
styles.base,
variants[variant],
themeVariantOverride, // Theme override applied last
)}
{...props}>
{children}
</button>
);
},
);-
No runtime
stylex.create()— All styles must be compiled at build time via the Babel plugin. You cannot dynamically create styles at runtime (e.g., in Storybook'spreview.tsxor based on runtime values). -
Combined pseudo-selectors don't work —
:hover::afteris not supported. Use thebackgroundImageoverlay pattern instead of::afterpseudo-elements for hover/active effects. -
No
stylex.createin non-compiled files — The consuming app must handle the build for proper style deduping, merging, and bundling. Library code must be compiled by the consumer's build pipeline. -
Shorthand property limitations — Some CSS shorthands behave differently. Prefer longhand properties (e.g.,
paddingTop,paddingRightinstead ofpadding) when you need per-side control.
Putting it all together — here's how the patterns combine in a real component:
/**
* Button — Primary interactive element for user actions.
*
* @input variant, size, disabled, loading, children
* @output Styled <button> element with theme-aware variants
* @position Inline within forms, toolbars, cards, dialogs
*
* SYNC: Consumes ThemeContext for variant overrides.
*/
import {forwardRef, useContext, type ButtonHTMLAttributes, type ReactNode} from 'react';
import * as stylex from '@stylexjs/stylex';
import {colorTokens, spacingTokens, radiusTokens, transitionTokens} from '../theme/tokens.stylex';
import {ThemeContext} from '../theme/ThemeContext';
import type {StyleXStyles} from '../theme/types';
const styles = stylex.create({
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: spacingTokens.space2,
borderWidth: 0,
borderRadius: radiusTokens.element,
cursor: 'pointer',
fontFamily: 'inherit',
transitionDuration: transitionTokens.fast,
transitionProperty: 'background-color, transform, outline',
transform: {
default: null,
':active': 'scale(0.98)',
},
},
disabled: {
cursor: 'not-allowed',
opacity: 0.5,
},
});
const variants = stylex.create({
primary: {
backgroundColor: colorTokens.accent,
color: 'white',
backgroundImage: {
default: null,
':hover': {
'@media (hover: hover)': `linear-gradient(${colorTokens.hoverOverlay}, ${colorTokens.hoverOverlay})`,
},
':active': `linear-gradient(${colorTokens.pressedOverlay}, ${colorTokens.pressedOverlay})`,
},
outline: {
default: null,
':focus-visible': `2px solid ${colorTokens.focusOutline}`,
},
outlineOffset: {
default: null,
':focus-visible': '3px',
},
},
secondary: { /* ... */ },
ghost: { /* ... */ },
destructive: { /* ... */ },
});
const sizes = stylex.create({
sm: { padding: `${spacingTokens.space1} ${spacingTokens.space2}`, fontSize: '14px' },
md: { padding: `${spacingTokens.space2} ${spacingTokens.space4}`, fontSize: '16px' },
lg: { padding: `${spacingTokens.space3} ${spacingTokens.space5}`, fontSize: '18px' },
});
export type ButtonVariant = keyof typeof variants;
export type ButtonSize = keyof typeof sizes;
declare module '../theme/types' {
interface ComponentStyles {
button?: {
variants?: Partial<Record<ButtonVariant, StyleXStyles>>;
};
}
}
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'style' | 'className'> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
children: ReactNode;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({variant = 'primary', size = 'md', loading, disabled, children, ...props}, ref) => {
const themeContext = useContext(ThemeContext);
const themeVariantOverride =
themeContext?.theme.components?.button?.variants?.[variant];
return (
<button
ref={ref}
disabled={disabled || loading}
{...stylex.props(
styles.base,
variants[variant],
sizes[size],
(disabled || loading) && styles.disabled,
themeVariantOverride,
)}
{...props}>
{children}
</button>
);
},
);
Button.displayName = 'Button';See /packages/core/src/Button/Button.tsx for the full production implementation.