-
Notifications
You must be signed in to change notification settings - Fork 107
API Conventions
Consolidated reference for Astryx component API conventions. This is a decision doc — it states what the conventions are.
For related decisions, see:
- Why StyleX — Styling architecture rationale
- Distribution — Packages, versioning, and how Astryx ships
- Theming Infrastructure — CSS tokens, xdsThemeProps, variant maps, sub-element targeting
- Night Watch Component Auditor — Automated nightly enforcement of these conventions
⚠️ Keep in sync. The Night Watch Component Auditor enforces these conventions automatically. When updating rules here, update the corresponding auditor check. When the auditor finds a gap not documented here, add it.
Grounded in Astryx Philosophy. These steer every convention below.
- Guidance over enforcement. Components provide capability, not design guardrails. Design opinions live in docs and examples, not in runtime prop gating. If a consumer passes a prop value, the component renders it.
-
Prop independence. One prop never suppresses another prop's output. Variants affect styling — never whether sibling props appear. Exception: physical constraints (
isTruncated,maxLines) where clipping is self-evident. - Orthogonal axes. Each prop controls one dimension of variation. If you can't name the axis without describing a use case, it's a design recipe, not a primitive.
- Measure deliberately. Runtime measurement (ResizeObserver, getClientRects) has real cost — extra render passes, style recalc, layout thrash. Some components exist to measure (overflow detection, virtualization). But measurement should be a conscious trade-off, not a casual reach. If a prop or CSS can express the same intent, prefer that.
- No design recipes in the API. Props describe what the component does, not how a specific comp arranged it. Optical nudges and one-off adjustments belong in theme infrastructure or internal styling.
- Test before shipping. New API surface needs vibe testing. If the names weren't validated against real usage, the API isn't ready.
- Fix at the right layer. Before patching a component, check if the bug lives in reset CSS, theme config, or build tooling. Don't patch symptoms.
Components exist on a spectrum from pure utility to guided compositions. Each band calls for a different API strategy:
| Band | Examples | API Strategy |
|---|---|---|
| Utility (low-level primitives) |
VStack, Text, Divider
|
Fine-tuning knobs and behavior switches are natural here. Granular props make sense because the component's job is flexibility. |
| Mid-range (contained UI elements) |
TextInput, Selector, Badge
|
Mix of both strategies, calibrated by the vision for where the component sits on the spectrum. |
| Compositions (high-level patterns) |
AppShell, Table, CommandPalette
|
Opinionated by design. Customization comes through composition (slots, children, render functions), not props. Prop explosion here signals the wrong abstraction layer. |
Use this model when evaluating whether a prop belongs on a component. A behavior switch that's natural on a utility component becomes too-specific tweaking on a larger composition — the threshold shifts with complexity.
This also informs the Composition vs Config section below: config-heavy APIs are appropriate for the utility end; composition-heavy APIs are appropriate for the complex end.
<Namespace><Variant><Type><Postfixes>
| Segment | Required | Example | Notes |
|---|---|---|---|
| Namespace | Layout |
Groups related components (e.g. LayoutHeader, LayoutPanel) |
|
| Variant | Icon |
Distinguishes visual/behavioral variants (use sparingly — prefer concise APIs) | |
| Type | ✓ |
Input, Button
|
The component's role |
| Postfixes |
Item, Group
|
Compositional parts |
Component names are unprefixed — Button, not XDSButton.
Examples: Button, TextInput, Selector, CheckboxInput, VStack
Files match export names. This is the LLM-friendly default — no re-export indirection.
packages/core/src/Button/
├── Button.tsx → exports Button
├── Button.test.tsx
├── Button.doc.mjs → typed docs (ComponentDoc)
└── index.ts → re-exports from Button.tsx
apps/storybook/stories/
└── Button.stories.tsx → Storybook stories (separate from core)
Tip: A
create-componentCLI command exists to scaffold this structure, though it may need manual adjustments.
Component source lives in packages/core/src/, stories live in apps/storybook/stories/:
| File | Purpose |
|---|---|
packages/core/src/<Name>/<Name>.tsx |
Component implementation |
packages/core/src/<Name>/<Name>.test.tsx |
Colocated unit tests |
packages/core/src/<Name>/index.ts |
Public exports |
packages/core/src/<Name>/<Name>.doc.mjs |
Typed component documentation |
packages/core/src/<Name>/types.ts |
Shared types (if needed) |
packages/core/src/<Name>/utils.ts |
Internal helpers (if needed) |
packages/core/src/<Name>/hooks.ts |
Internal hooks (if needed) |
apps/storybook/stories/<Name>.stories.tsx |
Storybook stories |
Every component file includes a structured header for traceability:
/**
* @file Button.tsx
* @input Uses React forwardRef, ButtonHTMLAttributes, ReactNode
* @output Exports Button component, ButtonProps, ButtonVariant types
* @position Core implementation; consumed by index.ts, tested by Button.test.tsx
*
* SYNC: When modified, update these files to stay in sync:
* - /packages/core/src/Button/Button.doc.mjs
* - /packages/core/src/Button/Button.test.tsx
* - /packages/core/src/Button/index.ts
* - /apps/storybook/stories/Button.stories.tsx
*/Public hooks use the use prefix and an unprefixed name:
| Pattern | Scope | Examples |
|---|---|---|
use<Name> |
Public API |
useCollapsible, usePopover, useTheme
|
use<Name> |
Internal only |
useFocusTrap, useControllableState
|
Hook names are unprefixed — usePopover, not useXDSPopover. Utility hooks that can be generally applied (e.g., useGridFocus, useListFocus) follow the same convention.
React contexts and exported types that belong to a component use unprefixed names:
| Kind | Pattern | Examples |
|---|---|---|
| Context | <Component>Context |
SideNavCollapseContext, ThemeContext
|
| Context hook | use<Component><Thing> |
useSideNavCollapse, useTheme
|
| Props type | <Component>Props |
ButtonProps, TextInputProps
|
| Variant type | <Component>Variant |
ButtonVariant, BadgeVariant
|
| Status type | <Component>Status |
TextInputStatus, SelectorStatus
|
Contexts and types take the component name without a prefix.
Always prefix with is or has.
| Prefix | Meaning | Examples |
|---|---|---|
is |
State or condition |
isDisabled, isRequired, isOptional, isLabelHidden, isLoading
|
has |
Feature toggle |
hasAutoFocus, hasClear, hasSeconds
|
Pattern: on{Verb}{Scope?}
Scope is optional — use it when a component has multiple parts that could apply to the same verb, to disambiguate. When there's only one thing the verb could apply to, omit the scope.
| Type | Pattern | Examples |
|---|---|---|
| Sync handler | on{Verb} |
onClick, onChange, onBlur
|
| Scoped handler | on{Verb}{Scope} |
onSidebarCollapsedChange, onVisibilityChange
|
| Async action | on{Verb}Action |
onClickAction, onChangeAction
|
When to add scope: If a component has multiple things that could toggle, change, or collapse, add scope to clarify which one. For example, AppShell has onSidebarCollapsedChange because the shell could have multiple collapsible areas.
All components with open/close behavior use a single unified callback:
onOpenChange?: (isOpen: boolean) => void;The boolean parameter tells you whether the component is opening (true) or closing (false). This applies only to components with controllable layers — components that manage an overlay, popover, or dialog: Dialog, Popover, DropdownMenu, MobileNav, HoverCard, Tooltip.
Components without layers (e.g. Button, Card, Badge) should never have visibility props (isOpen, isShown, isVisible) or onOpenChange. Conditional rendering is the consumer's responsibility — the component should not accept a prop that makes it render null.
Note: Internal hooks (
usePopover,useHoverCard,useTooltip) still useonShow/onHideinternally. These are implementation details, not public API.
Use onChange for the primary value-change callback on a component. Don't scope it unless the component has multiple independent values that can change.
// ✅ Primary callback — no scope needed
onChange?: (value: string) => void;
// ❌ Don't use onValueChange, onSelectionChange for the primary callback
onValueChange?: (value: string) => void;Decision (March 2026 API audit):
CollapsibleGroupwas renamed fromonValueChangetoonChangeto follow this convention.
Use camelCase values:
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
size?: 'sm' | 'md' | 'lg';Enum values should align with CSS token names where applicable:
| Prop | Values | Matches Token |
|---|---|---|
size |
'sm' | 'md' | 'lg' |
--size-sm, --size-md, --size-lg
|
padding |
'sm' | 'md' | 'lg' |
--spacing-* tokens |
margin |
'sm' | 'md' | 'lg' |
--spacing-* tokens |
variant |
component-specific | — |
Note:
variantis a special prop with theming considerations. Variant values map to the component theme system — themes can override styles per-variant viaThemeContext. See Theming Infrastructure for how variant overrides work in the theming system.
Use start/end instead of left/right for RTL support:
| ✅ Do | ❌ Don't |
|---|---|
startIcon |
leftIcon |
paddingEnd |
paddingRight |
endContent |
rightContent |
Prefix with html when wrapping native attributes to avoid collisions:
htmlName?: string; // maps to <input name="...">
htmlFor?: string; // maps to <label htmlFor="...">| Prop Category | Required? | Rationale |
|---|---|---|
label (interactive elements) |
✓ | Accessibility — buttons and inputs need labels. Not required for display components (e.g. Text, Link where content is the label). |
value / onChange
|
✓ | Controlled components need both |
children |
✓ | When the component has no meaningful output without content (e.g. Button needs a label or children) |
| Visual variants | Optional | Safe defaults exist (variant='secondary', size='md') |
| Boolean flags | Optional | Default to false
|
| Event handlers | Optional | Not all uses need them |
Principle: Make behavioral/structural props required. Make presentation props optional with sensible defaults.
| Use Composition When | Use Config (Props) When |
|---|---|
| Content is arbitrary/user-defined | Options are finite and well-known |
| Children need access to parent context | Prop controls a single visual attribute |
| Flexibility is needed for unknown use cases | Consistency matters more than flexibility |
Examples:
-
Config:
variant,size,isDisabled— finite, well-known values -
Composition:
children,iconas ReactNode, render functions for custom items
// Config: finite variants
<Button variant="primary" size="md" label="Save" />
// Composition: custom content via render function
<Selector items={items} value={v} onChange={setV} label="Pick">
{(item) => <CustomItem icon={item.icon} label={item.label} />}
</Selector>When a parent component accepts composed children via named props (slots), follow these rules:
1. Slots are passthrough. The parent renders the slot content directly — it never wraps it in another component.
// ✅ Consumer composes the full element, parent just renders it
<AppShell
topNav={<TopNav items={navItems} />}
sideNav={<SideNav sections={sections} />}
mobileNav={<MobileNav isOpen={isOpen} onOpenChange={setIsOpen} />}
/>
// ❌ Parent wraps slot content in its own component
<AppShell mobileNav={content} isMobileNavOpen={isOpen} onMobileNavOpenChange={setOpen} />2. Don't hoist child props onto parents. If isOpen and onOpenChange belong to MobileNav, they stay on MobileNav. Adding parallel props to the parent duplicates the API surface and breaks the composition pattern.
3. Every slot follows the same pattern. The contract is slotName={<Component props />} — no exceptions. If you find yourself adding slot-specific props to the parent, you've broken the pattern.
Don't add flexibility without a demonstrated use case.
- If a separate component already handles the behavior, don't add it as a mode on another component. Example:
HoverCardhandles hover triggers — don't addtrigger="hover"toPopover. - If an escape hatch overlaps with an existing slot (e.g., a
customContentprop that does whatendContentalready does), remove it. - Prefer enforced good practices over toggles. Example: focus trapping in a popover should be the default behavior, not a
hasFocusTraptoggle — unless there's a real use case for disabling it.
Composition is great for content — slotting UI into other UI. But when the thing being composed is a behavior (resize, drag, collapse, scroll-lock), wrapper components create problems:
-
DOM bloat. Each behavioral wrapper adds a DOM node. Stack a few and the consumer's tree is three
<div>s deep before they've rendered anything meaningful. - Prop tunneling. The wrapper needs to forward refs, styles, event handlers, and ARIA attributes to the inner element. This gets fragile fast.
- Composition ceiling. Two behavioral wrappers on the same element don't compose — they nest, and now ordering matters.
Prefer hooks that compose behavior into the target element directly:
// ✅ Hook composes behavior onto the element — no extra DOM
const resizeProps = useResizable({ onWidthChange, defaultWidth: 260 });
<SideNav {...resizeProps} />
// ✅ Or as a boolean-or-config prop on the component itself
<SideNav resizable={{ defaultWidth: 260, onWidthChange }} />
// ❌ Wrapper adds a DOM node just to attach behavior
<Resizable defaultWidth={260} onWidthChange={onWidthChange}>
<SideNav />
</Resizable>When to offer both hook and component: Sometimes the component version is genuinely easier to reach for (quick prototyping, simple cases). In those cases, the component should be a thin shell over the hook — never the other way around. The hook is the primitive; the component is sugar.
Stress-test against the native prop: Before shipping a behavioral wrapper, enumerate the real use cases and test whether a boolean | config prop on the target component covers them. For example, SideNav resizable={...} was chosen over <Resizable> after evaluating all resize scenarios — inline config was simpler for every case that actually came up.
When building a component, use existing Astryx primitives for everything they cover. Don't reimplement behavior that already exists in the system.
-
No raw HTML elements when an Astryx equivalent exists. Use
Buttoninstead of<button>,Dividerinstead of<hr>,Dialoginstead of building your own modal. Raw elements bypass theming and swizzle — consumers can't customize them. -
Icons come from
IconRegistry. Never embed inline SVGs. Registry icons are themable; hardcoded SVGs are not. -
Use existing hooks. If
useListFocushandles keyboard navigation, use it. IfuseFocusTraphandles focus trapping, use it. Don't roll custom versions. -
Check sibling components for prior art. Before adding a feature (anchorRef, animation, alignment), check whether a related component (Tooltip, HoverCard, Popover) already implements it. Match the API.
Every raw element or reimplemented behavior is a point where the system breaks down:
- Theming — raw elements don't respond to theme changes
- Swizzle — consumers can't replace what isn't a system component
- Consistency — reimplementations drift from the canonical version over time
Large monolithic components are hard to swizzle — consumers can't replace one part without replacing everything. Break components down so each piece is independently replaceable.
If a component has repeated blocks that look similar but you can't tell from reading whether they're semantically equivalent, extract them into named sub-components with clear contracts.
Components in the same family must share visual treatment. When building a new component, check its siblings first.
| Property | Rule |
|---|---|
| Padding | Match the family default (e.g., Popover and HoverCard share the same content padding) |
| Animation | Same entry/exit animation within a family |
| Elevation | Use elevationTokens — never raw boxShadow strings. Match the family level (e.g., all menus use elevationTokens.menu) |
| Radius | Interactive states (hover, selected) use radiusTokens.content. Divider mode uses radius 0 |
| Status borders | Error/warning/success states need a visible border on the input wrapper, not just a field message below |
When a list-like component supports dividers between items:
- Remove inter-item padding (items should feel like a continuous strip)
- Set item radius to 0
- This is a distinct visual treatment, not just "add a line between items"
Maintain consistent selection appearance across all combobox-like components (Selector, Typeahead, CommandPalette):
- Selected items show a checkmark at the end and slightly bolder text
- This pattern comes from
Selectorand should be reused, not reinvented
Patterns to watch for during review. These aren't always wrong, but they're usually a sign something needs rethinking.
| Smell | Why It's a Problem | Fix |
|---|---|---|
useEffect for state sync |
Effects run after render — causes flicker and extra renders. State sync belongs in the hook or initializer. | Push initial/controlled state handling into the hook itself |
| Wrapper component with no branch | If a conditional was removed but the wrapper component remains, it's dead abstraction. | Inline the remaining path |
| Repeated optional blocks | If you can't tell from reading whether repeated blocks are semantically equivalent, the API is unclear. | Extract into a named sub-component with a clear contract |
addEventListener in React |
Bypasses React's event system. Breaks keyboard interaction, synthetic events, and event delegation. | Use onClick, onKeyDown on React elements |
| Conditional rendering that removes focusable elements | Users lose keyboard focus when elements disappear from the DOM. | Hide visually (display: none or opacity: 0) but keep in DOM, or manage focus explicitly |
Raw boxShadow strings |
Hardcoded shadows bypass theming. | Use elevationTokens.base, .dialog, .menu, etc. |
All components accept three styling props, in priority order:
| Prop | For | Merging |
|---|---|---|
xstyle |
StyleX styles from stylex.create()
|
Merged inside a single stylex.props() call with the component's base styles — optimal deduplication |
className |
Tailwind, CSS modules, plain CSS | Appended after StyleX classes |
style |
Inline overrides | Spread after StyleX inline styles — highest priority |
// StyleX consumer — best deduplication
<Card xstyle={overrides.card}>...</Card>
// Tailwind consumer
<Card className="max-w-md shadow-lg">...</Card>
// Inline override
<Card style={{maxWidth: 400}}>...</Card>
// All three compose
<Card xstyle={overrides.card} className="mt-4" style={{opacity: 0.9}}>...</Card>If you're using StyleX, prefer xstyle over className — it goes through the same stylex.props() call as the component's internal styles, so StyleX can deduplicate atomic classes across the boundary.
Components use mergeProps() internally to merge all three. Never spread {...stylex.props(xstyle)} and then set className/style separately — that overwrites instead of merging.
// ✅ All styling goes through mergeProps
<div {...mergeProps(
xdsThemeProps('card'),
stylex.props(styles.base, xstyle),
className,
style,
)} />
// ❌ Separate spread overwrites className/style
<div {...stylex.props(styles.base, xstyle)} className={className} style={style} />Components that delegate to Field (Slider, RadioList, Tokenizer, Typeahead) pass xstyle, className, and style as props — Field handles the merge internally.
import type {StyleXStyles} from '@stylexjs/stylex';
interface Props {
xstyle?: StyleXStyles;
className?: string;
style?: React.CSSProperties;
}
// xstyle merges inside stylex.props; className/style merge after
<div {...mergeProps(
xdsThemeProps('component'),
stylex.props(styles.base, xstyle),
className,
style,
)} />;When a component wraps another Astryx component (e.g. ClickableCard delegates to Card), follow these rules for props that the wrapper sets explicitly on the inner component:
1. Never let {...rest} overwrite explicitly-set props. Destructure every prop you set on the inner component — xstyle, className, style, onClick, onMouseUp, etc. — so they don't leak through the rest spread.
2. Alias with Prop suffix. When destructuring a prop that the component also uses internally under the same name, alias it with a Prop suffix to distinguish the caller's value from the internal one:
// ✅ Consistent Prop suffix aliases
export function ClickableCard({
onClick: onClickProp, // caller's onClick
onMouseUp: onMouseUpProp, // caller's onMouseUp
xstyle: xstyleProp, // caller's xstyle
className: classNameProp, // caller's className
style, // no alias needed — forwarded directly
...props
}: ClickableCardProps) {
// Internal onClick/onMouseUp from hook
const {onClick, onMouseUp} = useClickableContainer({...});
// ...
}The Prop suffix convention (onClickProp, xstyleProp, classNameProp) is the standard — don't use caller* or underscore-prefixed _className aliases.
3. Merge, don't drop. Destructured caller props must be forwarded, not silently discarded:
| Prop | How to merge |
|---|---|
className |
Concatenate: classNameProp ? `${internalClassName} ${classNameProp}` : internalClassName
|
xstyle |
Append to array: [...internalStyles, xstyleProp]
|
style |
Forward to inner component (it handles merging via mergeProps) |
| Event handlers | Compose: call internal handler first, then caller's |
// ✅ Properly forwarded with Prop suffix aliases
export function ClickableCard({
onClick: onClickProp,
onMouseUp: onMouseUpProp,
xstyle: xstyleProp,
className: classNameProp,
style,
...props
}: ClickableCardProps) {
const {onClick, onMouseUp} = useClickableContainer({onClick: onClickProp, ...});
const handleMouseUp = onMouseUpProp
? (e) => { onMouseUp(e); onMouseUpProp(e); }
: onMouseUp;
return (
<Card
className={classNameProp ? `${xdsThemeProps(...)} ${classNameProp}` : xdsThemeProps(...)}
xstyle={[styles.internal, xstyleProp]}
style={style}
onMouseUp={handleMouseUp}
{...props}
/>
);
}
// ❌ Rest spread overwrites explicit props
export function ClickableCard({...props}: ClickableCardProps) {
return (
<Card
className={xdsThemeProps(...)}
xstyle={[styles.internal]}
onClick={onClick}
onMouseUp={onMouseUp}
{...props} // caller's className, xstyle, onClick overwrite!
/>
);
}4. Place {...rest} before explicit props on the inner component as an alternative pattern, but only if the inner component's prop merging handles it. The destructure-and-merge approach is preferred because it's explicit about what happens.
Components read from ThemeContext for theme-level variant overrides. Theme styles are applied after base styles but before consumer xstyle:
const themeOverride = themeContext?.theme.components?.button?.variants?.[variant];
// Order: base → theme override → consumer xstyle
{...stylex.props(styles.base, variants[variant], themeOverride)}All components extend BaseProps — a shared interface that starts from HTMLAttributes and omits only the handful of props that make no sense on design system components (contentEditable, dangerouslySetInnerHTML, etc.).
This gives consumers full DOM event access (drag, pointer, keyboard, clipboard, etc.) without the genuinely dangerous props. Components re-declare common props with better JSDoc where useful (e.g. Button re-declares onClick with its specific event type).
export interface BaseProps
extends Omit<
React.HTMLAttributes<HTMLElement>,
| 'contentEditable'
| 'dangerouslySetInnerHTML'
| 'suppressContentEditableWarning'
| 'suppressHydrationWarning'
> {
xstyle?: StyleXStyles;
}When a component uses a prop name that collides with HTMLAttributes (e.g. title as ReactNode instead of string), it Omits the conflicting prop:
export interface TopNavProps extends Omit<BaseProps, 'title'> {
title?: ReactNode;
}Decision (March 2026): Started with a curated allowlist approach (React Aria style), but the maintenance burden of anticipating every use case (drag-and-drop, pointer events, gestures) wasn't worth it. The Omit approach gives consumers what they need with zero friction for new DOM features.
All input components that represent a form field implement a standard set of props for consistency.
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
label |
string |
- | ✓ | Accessible label text (always rendered for screen readers) |
isLabelHidden |
boolean |
false |
Visually hide the label while keeping it accessible | |
description |
string |
- | Helper text displayed between label and input | |
isOptional |
boolean |
false |
Shows "(Optional)" indicator. Mutually exclusive with isRequired
|
|
isRequired |
boolean |
false |
Marks field as required. Mutually exclusive with isOptional
|
|
isDisabled |
boolean |
false |
Disables the input |
| Prop | Type | Default | Applicable To | Description |
|---|---|---|---|---|
placeholder |
string |
varies | Text-based inputs | Placeholder text when empty |
size |
'sm' | 'md' | 'lg' |
'md' |
Most inputs | Size variant |
status |
{type, message?} |
- | Inputs with validation | Error/warning/success state |
labelTooltip |
string |
- | Inputs needing explanation | Tooltip on info icon next to label |
startIcon |
IconType |
- | Inputs with icon support | Icon at start of input |
| Size | Input Height | Use Case |
|---|---|---|
sm |
18px | Compact UIs, tables, dense forms |
md |
26px | Default, most forms |
lg |
34px | Prominent inputs, landing pages |
All focusable input elements (TextInput, TextArea, NumberInput, Selector trigger, Typeahead input) must have a computed font-size ≥ 16px on touch devices. iOS Safari auto-zooms when focusing inputs below this threshold.
Implementation: Use a conditional StyleX media query on the input element's fontSize:
fontSize: {
default: typeScaleVars['--text-body-size'],
'@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--text-body-size']})`,
},pointer: coarse targets touch devices specifically — where the zoom problem exists — without affecting desktop where smaller text is fine. max() ensures themes with font sizes already ≥ 16px aren't clamped down.
Rules:
- Apply only to elements that receive focus (the
<input>,<textarea>,<select>, or<button>that triggers the keyboard). Not to labels, descriptions, or dropdown items. - Use
max(1rem, <token>)— the floor respects user font-size preferences and doesn't override themes that set a larger size. - This is a per-component concern, not a global token. Each input component owns its own floor.
All input components are controlled — they require value and onChange/onChangeAction.
| Prop | Type | Description |
|---|---|---|
value |
varies | Current value (source of truth) |
onChange |
(value, event?) => void |
Sync change handler |
onChangeAction |
(value, event?) => Promise |
Async action (replaces onChange) |
isLoading |
boolean |
External loading state |
When supporting uncontrolled mode, prefix default values with default. For booleans, maintain the is/has prefix after default:
defaultValue?: string;
defaultIsOpen?: boolean; // not initialIsOpen, not defaultOpen
defaultIsExpanded?: boolean;
defaultHasSelection?: boolean;This follows React's convention (defaultValue, defaultChecked) while preserving Astryx's boolean prefix rules. The ESLint rule @xds/boolean-prop-naming enforces defaultIs/defaultHas prefixes.
When a component feature needs both a simple on/off toggle and an advanced configuration, use a single prop that accepts boolean | object:
collapsible?: boolean | {
defaultIsCollapsed?: boolean;
isCollapsed?: boolean;
onCollapsedChange?: (isCollapsed: boolean) => void;
};
resizable?: boolean | {
defaultWidth?: number;
onWidthChange?: (width: number) => void;
};Usage:
// Simple — enable with defaults
<SideNav collapsible>
// Configured — customize behavior
<SideNav collapsible={{ defaultIsCollapsed: true }}>
// Controlled — external state
<SideNav collapsible={{ isCollapsed, onCollapsedChange }}>
// Disabled (default)
<SideNav collapsible={false}>
// or just omit the prop
<SideNav>Rules:
-
trueenables the feature with sensible defaults. -
Object enables the feature with configuration. The presence of the object implies enabled — no need for
{ enabled: true, ... }. -
falseor omitted disables the feature. - Extract the config in the component body:
const config = typeof collapsible === 'object' ? collapsible : {};
const isEnabled = !!collapsible;
const defaultValue = config.defaultIsCollapsed ?? false;
const onChange = config.onCollapsedChange;When to use this pattern:
- The feature has a meaningful default that works without configuration (e.g., collapsible starts expanded, resizable starts at 260px)
- Advanced users need controlled state or custom defaults
- Adding 3+ separate props (
isCollapsible,defaultIsCollapsed,onCollapsedChange) would clutter the props interface
When NOT to use this pattern:
- The feature is always on or always off (use a plain boolean)
- The config has only one option (just use two props:
isX+onXChange) - The feature is the primary purpose of the component (use individual props at the top level)
Precedent: SideNav.collapsible, SideNav.resizable, SideNavItem.collapsible, AppShell.mobileNav
The onChange signature varies by input type:
| Input Type | Signature |
|---|---|
| Text-based | (value: string, e: ChangeEvent) => void |
| Boolean | (checked: boolean, e: ChangeEvent) => void |
| Parsed value |
(value: T | undefined) => void (no event) |
| Selection | (value: string) => void |
Convention: Include the event when the raw DOM element is useful. Omit it when the value is parsed/transformed.
| Pattern | Prop | Hook | Used By |
|---|---|---|---|
| Button actions | onClickAction |
useTransition |
Button |
| Input actions | onChangeAction |
useOptimistic |
All inputs |
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
const isBusy = isLoading || optimisticValue !== value;
const handleChange = e => {
const newValue = e.target.value;
if (onChangeAction) {
startTransition(() => {
setOptimisticValue(newValue); // Immediate UI feedback
onChangeAction(newValue, e); // Async — auto-rollback on failure
});
} else if (onChange) {
onChange(newValue, e);
}
};- Render
optimisticValue, notvalue -
isBusydrives visual feedback (opacity,aria-busy) — never disables the input (prevents focus loss) -
onChangeActionhas the same signature asonChange
const [isPending, startTransition] = useTransition();
const isBusy = isLoading || isPending;
const handleClick = e => {
if (onClickAction) {
startTransition(() => {
onClickAction(e);
});
} else if (onClick) {
onClick(e);
}
};-
isBusydisables the button and shows a spinner - When combined with
href, navigation defers until action completes
-
onClick/onChange— synchronous, immediate -
onClickAction/onChangeAction— async, wrapped in transition -
isLoading— external loading state
| Requirement | Implementation |
|---|---|
| Label |
label prop → <label htmlFor={id}> or aria-label
|
| Hidden label |
isLabelHidden — visually hidden, still in DOM |
| Description |
aria-describedby linking to description element |
| Required state |
aria-required="true" when isRequired
|
| Invalid state |
aria-invalid="true" when status.type === 'error'
|
| Disabled state | Native disabled attribute (correct semantics) |
| Busy state |
aria-busy="true" during async operations |
| Icon-only buttons |
label used as aria-label when no visible text |
| State | Interaction | Focus |
disabled attr |
Visual |
|---|---|---|---|---|
isDisabled |
Blocked | Lost | ✓ | 50% opacity, not-allowed cursor |
isBusy |
Guarded in handler | Kept | ✗ | Reduced opacity, spinner (buttons), aria-busy
|
Key rule: Neither inputs nor buttons use native disabled for busy state. Busy is visual-only (aria-busy, aria-disabled, opacity). Buttons guard against re-triggering in the click handler. This prevents focus loss during async operations.
Never remove interactive elements from the DOM when state changes — hide them visually but keep them available for keyboard users.
- When a max limit is reached (e.g., max tokens in a tokenizer), keep the input in the DOM so backspace still works.
- After a state transition (popover close, dialog dismiss), restore focus to the trigger element.
- Focus must always go somewhere intentional — never let it fall to
<body>.
Components that implement established ARIA patterns (button + dialog, button + menu, combobox + listbox) must enforce the pattern:
- Event handlers (click, keydown) should target the correct semantic element — e.g., the
<button>or[role="button"]inside children, not an arbitrary wrapper<div>. - Don't switch ARIA roles mid-component (e.g., changing
listboxtolistin a combobox breaks the pattern). - Use React's event system for interaction handling. Don't imperatively attach
addEventListenerto child DOM elements — it bypasses React and breaks keyboard interaction (Enter/Space on buttons).
Astryx uses React 19, where ref is a regular prop — no forwardRef wrapper needed. New components accept ref on their props interface and pass it to the root DOM element:
interface ButtonProps {
ref?: React.Ref<HTMLButtonElement>;
// ... other props
}
export function Button({ ref, label, ...props }: ButtonProps) {
return <button ref={ref} {...props}>{label}</button>;
}
Button.displayName = 'Button';Always set displayName — required for React DevTools and error messages.
Note: Some existing components still use
forwardRef. New components should use therefprop pattern. The remainingforwardRefusages will be cleaned up before v1.
| Component Type | Ref Target |
|---|---|
| Button | HTMLButtonElement |
| Text input | HTMLInputElement |
| Container | HTMLDivElement |
| Link button | HTMLAnchorElement |
Derive variant/size types from the StyleX object keys:
const variants = stylex.create({
primary: { ... },
secondary: { ... },
ghost: { ... },
});
export type ButtonVariant = keyof typeof variants;| Size | Height Token | Use Case |
|---|---|---|
sm |
--size-sm (18px) |
Compact UIs, tables |
md |
--size-md (26px) |
Default for most contexts |
lg |
--size-lg |
Prominent actions |
interface InputStatus {
type: 'error' | 'warning' | 'success';
message?: string;
}-
typecontrols border color and status icon -
messageis optional — renders below the input when provided - Components re-export with component-specific aliases:
TextInputStatus,SelectorStatus, etc.
Use the data-testid prop for test framework integration:
interface Props {
'data-testid'?: string;
}
// In JSX
<button data-testid={testId} />;Pass through to the primary interactive element (the button, input, or trigger — not a wrapper div).
Components register their variant types with the theme system via module augmentation. This avoids circular dependencies between components and the theme type system:
declare module '../theme/types' {
interface ComponentStyles {
button?: {
variants?: Partial<Record<ButtonVariant, StyleXStyles>>;
};
}
}Export the component, its props type, and any variant/status types:
export {Button} from './Button';
export type {
ButtonProps,
ButtonVariant,
ButtonSize,
} from './Button';Re-export all component directories:
export * from './Button';
export * from './TextInput';Add each component directory as a separate entry point for tree-shaking:
entry: [
'src/index.ts',
'src/Button/index.ts',
'src/TextInput/index.ts',
],All exported props and components should have JSDoc comments. These serve both human developers and LLM code generation:
/**
* A text input component for collecting user input.
*
* @example
* ```tsx
* <TextInput label="Name" value={name} onChange={setName} />
* ```
*/
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(For props, describe what it does, not how:
/**
* Whether the input is in a loading state.
* Shows a spinner and disables interaction.
* @default false
*/
isLoading?: boolean;