-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Component Authoring Guide
Building a new component end-to-end? See Component Lifecycle for the full lifecycle. Start implementation and architecture review with the Architecture Cheat Sheet, then use this page for the detailed 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.test.tsx # Tests
Stories are not colocated. They live in the Storybook app, one file per component:
/apps/storybook/stories/Button.stories.tsx
The directory, the file, and the exported component all share the component's name — no prefix:
| 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('@astryxdesign/cli/authoring').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 comes from @astryxdesign/cli/authoring — import it by package name, not by a relative path. Run type checking with:
pnpm --filter @astryxdesign/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: When modified, update this header and Button.doc.mjs.
*/| 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 {useContext} from 'react';Rule of thumb: If your file imports anything from
'react'other than types, it needs'use client'. A component that only acceptsrefas a prop imports nothing at runtime.
'use client';
import type {HTMLAttributes, ReactNode, Ref} from 'react';
import * as stylex from '@stylexjs/stylex';
import {colorVars} 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: colorVars['--color-background-surface'],
color: colorVars['--color-text-primary'],
},
primary: {
backgroundColor: colorVars['--color-accent'],
color: colorVars['--color-on-accent'],
},
});
// Derive type from StyleX object
export type MyComponentVariant = keyof typeof variants;
export interface MyComponentProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'style' | 'className'> {
/** Ref forwarded to the root element. */
ref?: Ref<HTMLDivElement>;
variant?: MyComponentVariant;
children: ReactNode;
}
export function MyComponent({variant = 'default', ref, children, ...props}: MyComponentProps) {
return (
<div ref={ref} {...stylex.props(styles.base, variants[variant])} {...props}>
{children}
</div>
);
}
MyComponent.displayName = 'MyComponent';-
refas a prop — React 19 passesreflike any other prop; declare it on the props interface and hand it to the root element.forwardRefis banned (@eslint-react/no-forward-ref) -
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: {height: sizeVars['--size-element-sm']},
md: {height: sizeVars['--size-element-md']},
lg: {height: sizeVars['--size-element-lg']},
});
export type ButtonSize = keyof typeof sizes;
// Results in: 'sm' | 'md' | 'lg'Always use tokens from tokens.stylex instead of hardcoded values:
Token objects are StyleX defineVars maps, so they are indexed by the CSS custom property name — colorVars['--color-accent'], not colorVars['--color-accent']:
import {
colorVars,
spacingVars,
radiusVars,
durationVars,
easeVars,
typographyVars,
shadowVars,
} from '../theme/tokens.stylex';
const styles = stylex.create({
base: {
backgroundColor: colorVars['--color-background-surface'],
padding: spacingVars['--spacing-3'],
borderRadius: radiusVars['--radius-element'],
fontFamily: typographyVars['--font-family-body'],
transitionDuration: durationVars['--duration-fast'],
transitionTimingFunction: easeVars['--ease-standard'],
boxShadow: shadowVars['--shadow-low'],
},
});| Category | Tokens | Examples |
|---|---|---|
colorVars |
Semantic colors, text, icons, status, overlays, borders |
--color-accent, --color-background-surface, --color-text-primary, --color-text-secondary, --color-neutral, --color-overlay-hover, --color-overlay-pressed, --color-border, --color-error, --color-success
|
spacingVars |
Consistent spacing scale |
--spacing-0 (0px), --spacing-0-5 (2px), --spacing-1 (4px), --spacing-2 (8px), --spacing-3 (12px) … --spacing-12 (48px) |
sizeVars |
Fixed control heights |
--size-element-sm (28px), --size-element-md (32px), --size-element-lg (36px) |
radiusVars |
Border radius by role |
--radius-none, --radius-inner (4px), --radius-element (8px), --radius-container (12px), --radius-page (28px), --radius-full
|
shadowVars |
Box shadows |
--shadow-low, --shadow-med, --shadow-high, --shadow-inset-hover, --shadow-inset-selected
|
durationVars |
Motion durations |
--duration-fast (175ms), --duration-medium (410ms), --duration-slow (975ms) — each with a -min and -max variant |
easeVars |
Easing curves | --ease-standard |
typographyVars |
Font families |
--font-family-body, --font-family-code, --font-family-heading
|
typeScaleVars |
Semantic type roles — prefer these over raw sizes |
--text-body-size, --text-label-size, --text-supporting-size, --text-large-size (each with -weight and -leading) |
textSizeVars |
Raw geometric size scale |
--font-size-2xs … --font-size-base (14px) … --font-size-5xl
|
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 function MyComponent({xstyle, ref, children, ...props}: MyComponentProps) {
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 {colorVars, radiusVars} from '@astryxdesign/core';
const customStyles = stylex.create({
highlight: {
backgroundColor: colorVars['--color-background-muted'],
borderRadius: radiusVars['--radius-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: colorVars['--color-background-surface'],
':hover': colorVars['--color-overlay-hover'],
':active': colorVars['--color-overlay-pressed'],
},
outline: {
default: 'none',
':focus-visible': `2px solid ${colorVars['--color-accent']}`,
},
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)': colorVars['--color-overlay-hover'],
},
':active': colorVars['--color-overlay-pressed'], // 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: colorVars['--color-accent'],
backgroundImage: {
default: null,
':hover': {
'@media (hover: hover)': `linear-gradient(${colorVars['--color-overlay-hover']}, ${colorVars['--color-overlay-hover']})`,
},
':active': `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`,
},
},
});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 ${colorVars['--color-accent']}`,
},
outlineOffset: {
default: null,
':focus-visible': '3px',
},
},
destructive: {
outline: {
default: null,
':focus-visible': `2px solid ${colorVars['--color-error']}`,
},
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: durationVars['--duration-slow-min'],
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: durationVars['--duration-slow-min'],
animationTimingFunction: 'linear',
animationIterationCount: 'infinite',
},
fadeIn: {
animationName: stylex.keyframes({
from: {opacity: 0},
to: {opacity: 1},
}),
animationDuration: durationVars['--duration-medium'],
animationTimingFunction: easeVars['--ease-standard'],
},
slideDown: {
animationName: stylex.keyframes({
from: {
opacity: 0,
transform: 'translateY(-8px)',
},
to: {
opacity: 1,
transform: 'translateY(0)',
},
}),
animationDuration: durationVars['--duration-medium'],
animationTimingFunction: easeVars['--ease-standard'],
},
});Use transition tokens (durationVars['--duration-fast'], durationVars['--duration-medium']) for durations to stay consistent with the theme.
If your component needs to escape container padding (like Table or Divider), read the matching edge from --container-padding-inline-start, --container-padding-inline-end, --container-padding-block-start, and --container-padding-block-end, then apply the corresponding negative margin. There is no isotropic --container-padding, --container-padding-inline, or --container-padding-block. If your component creates a new container context, reset all four for descendants. See Container Padding System for the full reference and patterns.
Every component must apply themeProps so theme authors can target it via defineTheme component overrides. This is the primary mechanism for per-component theming.
import {themeProps, mergeProps} from '../utils';
// On the element with visual styles (background, shadow, radius)
<div
{...mergeProps(
themeProps('card'),
stylex.props(styles.cardOuter),
)}>Key rules:
-
themePropsgoes 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:
themeProps('button', {variant, size}) - Use
mergePropsto combinethemePropswithstylex.props - Do NOT add
themePropsto composition wrappers that just wrap other themed Astryx components — it creates specificity conflicts
Visually distinct sub-elements within a component need their own themeProps 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 themeProps, thumb gets sub-element target
<div {...mergeProps(themeProps('switch'), stylex.props(styles.track))}>
<div {...mergeProps(themeProps('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 themeProps 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 @astryxdesign/theme-meta/types.ts
declare module '@astryxdesign/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 themeProps so theme CSS can target custom variants.
Components do not read overrides in JavaScript. Theming happens in CSS: themeProps() puts a stable class and a data-* reflection on the styled element, and the theme's components entry targets that class. Merge it ahead of stylex.props() with mergeProps so both class names survive:
import {themeProps, mergeProps} from '../utils';
export function Button({variant = 'primary', size = 'md', ref, children, ...props}: ButtonProps) {
return (
<button
ref={ref}
{...mergeProps(
themeProps('button', {variant, size}),
stylex.props(styles.base, variants[variant], sizes[size]),
)}
{...props}>
{children}
</button>
);
}themeProps('button', {variant: 'primary', size: 'sm'}) emits
{className: 'astryx-button primary sm', 'data-variant': 'primary', 'data-size': 'sm'}.
Every prop that selects a style object must be passed to themeProps — otherwise a theme cannot target that variation. See Theming Infrastructure.
-
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: When modified, update this header and Button.doc.mjs.
*/
import type {ButtonHTMLAttributes, ReactNode, Ref} from 'react';
import * as stylex from '@stylexjs/stylex';
import {colorVars, spacingVars, radiusVars, durationVars, easeVars, sizeVars} from '../theme/tokens.stylex';
import {themeProps, mergeProps} from '../utils';
import type {ButtonVariantMap} from './index';
const styles = stylex.create({
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: spacingVars['--spacing-2'],
borderWidth: 0,
borderRadius: radiusVars['--radius-element'],
cursor: 'pointer',
fontFamily: 'inherit',
transitionDuration: durationVars['--duration-fast'],
transitionTimingFunction: easeVars['--ease-standard'],
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: colorVars['--color-accent'],
color: 'white',
backgroundImage: {
default: null,
':hover': {
'@media (hover: hover)': `linear-gradient(${colorVars['--color-overlay-hover']}, ${colorVars['--color-overlay-hover']})`,
},
':active': `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`,
},
outline: {
default: null,
':focus-visible': `2px solid ${colorVars['--color-accent']}`,
},
outlineOffset: {
default: null,
':focus-visible': '3px',
},
},
secondary: { /* ... */ },
ghost: { /* ... */ },
destructive: { /* ... */ },
});
const sizes = stylex.create({
sm: {height: sizeVars['--size-element-sm']},
md: {height: sizeVars['--size-element-md']},
lg: {height: sizeVars['--size-element-lg']},
});
export type ButtonVariant = keyof ButtonVariantMap;
export type ButtonSize = keyof typeof sizes;
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'style' | 'className'> {
/** Ref forwarded to the root element. */
ref?: Ref<HTMLButtonElement>;
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
children: ReactNode;
}
export function Button({variant = 'primary', size = 'md', loading, disabled, ref, children, ...props}: ButtonProps) {
return (
<button
ref={ref}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...mergeProps(
themeProps('button', {variant, size}),
stylex.props(
styles.base,
variants[variant],
sizes[size],
(disabled || loading) && styles.disabled,
),
)}
{...props}>
{children}
</button>
);
}
Button.displayName = 'Button';See /packages/core/src/Button/Button.tsx for the full production implementation.
Start here Astryx Philosophy Contributing with AI Assistants Contributing
Architecture System Architecture Architecture Cheat Sheet Theming Infrastructure Distribution
Building a component Component Lifecycle Component Authoring Guide API Conventions Design Conventions
Quality Component Audit Rubric Accessibility Checklist
Operations Release Process Night Watch Overview