Skip to content

API Conventions

Cindy Zhang edited this page Jun 23, 2026 · 1 revision

API Conventions

Consolidated reference for Astryx component API conventions. This is a decision doc — it states what the conventions are.

For related decisions, see:

⚠️ 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.


Principles

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.

The Component Spectrum

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.


Component Naming

Anatomy

<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

File Naming

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-component CLI command exists to scaffold this structure, though it may need manual adjustments.

File Structure

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

File Header

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
 */

Hook Naming

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.


Context & Type Naming

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.


Prop Naming

Booleans

Always prefix with is or has.

Prefix Meaning Examples
is State or condition isDisabled, isRequired, isOptional, isLabelHidden, isLoading
has Feature toggle hasAutoFocus, hasClear, hasSeconds

Callbacks

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.

Visibility Callbacks

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 use onShow/onHide internally. These are implementation details, not public API.

Primary Change Callback

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): CollapsibleGroup was renamed from onValueChange to onChange to follow this convention.

Enums / String Unions

Use camelCase values:

variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
size?: 'sm' | 'md' | 'lg';

Common Enum Values

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: variant is a special prop with theming considerations. Variant values map to the component theme system — themes can override styles per-variant via ThemeContext. See Theming Infrastructure for how variant overrides work in the theming system.

Directional Props

Use start/end instead of left/right for RTL support:

✅ Do ❌ Don't
startIcon leftIcon
paddingEnd paddingRight
endContent rightContent

HTML Attribute Collisions

Prefix with html when wrapping native attributes to avoid collisions:

htmlName?: string;   // maps to <input name="...">
htmlFor?: string;    // maps to <label htmlFor="...">

Required vs Optional Props

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.


Composition vs Config

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, icon as 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>

Slot Props

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.

Escape Hatches

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: HoverCard handles hover triggers — don't add trigger="hover" to Popover.
  • If an escape hatch overlaps with an existing slot (e.g., a customContent prop that does what endContent already does), remove it.
  • Prefer enforced good practices over toggles. Example: focus trapping in a popover should be the default behavior, not a hasFocusTrap toggle — unless there's a real use case for disabling it.

Behaviors: Hooks Over Wrappers

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.


Use the System

When building a component, use existing Astryx primitives for everything they cover. Don't reimplement behavior that already exists in the system.

Rules

  1. No raw HTML elements when an Astryx equivalent exists. Use Button instead of <button>, Divider instead of <hr>, Dialog instead of building your own modal. Raw elements bypass theming and swizzle — consumers can't customize them.

  2. Icons come from IconRegistry. Never embed inline SVGs. Registry icons are themable; hardcoded SVGs are not.

  3. Use existing hooks. If useListFocus handles keyboard navigation, use it. If useFocusTrap handles focus trapping, use it. Don't roll custom versions.

  4. 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.

Why This Matters

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

Component Size and Swizzle

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.


Component Families

Components in the same family must share visual treatment. When building a new component, check its siblings first.

Shared Properties

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

Divider Mode

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"

Selection Indicators

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 Selector and should be reused, not reinvented

Code Smells

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.

Styling

Three Escape Hatches

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.

Internal Merging

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.

xstyle Pattern

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,
)} />;

Composed Component Prop Forwarding

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.

Theme Overrides

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)}

Prop Surface: BaseProps

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.


Input Component Props

All input components that represent a form field implement a standard set of props for consistency.

Core Field Props

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

Optional Common Props

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 Variants

Size Input Height Use Case
sm 18px Compact UIs, tables, dense forms
md 26px Default, most forms
lg 34px Prominent inputs, landing pages

Mobile Font Size Floor

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.

State Management

Controlled Components

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

Uncontrolled Defaults

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.

Boolean-or-Config Props

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:

  1. true enables the feature with sensible defaults.
  2. Object enables the feature with configuration. The presence of the object implies enabled — no need for { enabled: true, ... }.
  3. false or omitted disables the feature.
  4. 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

onChange Signature

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.


Async Actions (React Transitions)

Two Patterns

Pattern Prop Hook Used By
Button actions onClickAction useTransition Button
Input actions onChangeAction useOptimistic All inputs

Input Pattern: onChangeAction + useOptimistic

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, not value
  • isBusy drives visual feedback (opacity, aria-busy) — never disables the input (prevents focus loss)
  • onChangeAction has the same signature as onChange

Button Pattern: onClickAction + useTransition

const [isPending, startTransition] = useTransition();
const isBusy = isLoading || isPending;

const handleClick = e => {
  if (onClickAction) {
    startTransition(() => {
      onClickAction(e);
    });
  } else if (onClick) {
    onClick(e);
  }
};
  • isBusy disables the button and shows a spinner
  • When combined with href, navigation defers until action completes

Naming

  • onClick / onChange — synchronous, immediate
  • onClickAction / onChangeAction — async, wrapped in transition
  • isLoading — external loading state

Accessibility

Required Patterns

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

Disabled vs Busy

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.

Focus Preservation

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>.

ARIA Pattern Enforcement

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 listbox to list in a combobox breaks the pattern).
  • Use React's event system for interaction handling. Don't imperatively attach addEventListener to child DOM elements — it bypasses React and breaks keyboard interaction (Enter/Space on buttons).

Refs

ref as a Prop (React 19)

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 the ref prop pattern. The remaining forwardRef usages will be cleaned up before v1.

Ref Types

Component Type Ref Target
Button HTMLButtonElement
Text input HTMLInputElement
Container HTMLDivElement
Link button HTMLAnchorElement

Variant and Size Types

Deriving from StyleX

Derive variant/size types from the StyleX object keys:

const variants = stylex.create({
  primary: { ... },
  secondary: { ... },
  ghost: { ... },
});

export type ButtonVariant = keyof typeof variants;

Standard Sizes

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

Status / Validation

Status Object

interface InputStatus {
  type: 'error' | 'warning' | 'success';
  message?: string;
}
  • type controls border color and status icon
  • message is optional — renders below the input when provided
  • Components re-export with component-specific aliases: TextInputStatus, SelectorStatus, etc.

Test ID Convention

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).


Module Augmentation for Themes

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 Conventions

From Component index.ts

Export the component, its props type, and any variant/status types:

export {Button} from './Button';
export type {
  ButtonProps,
  ButtonVariant,
  ButtonSize,
} from './Button';

From Package src/index.ts

Re-export all component directories:

export * from './Button';
export * from './TextInput';

tsup.config.ts

Add each component directory as a separate entry point for tree-shaking:

entry: [
  'src/index.ts',
  'src/Button/index.ts',
  'src/TextInput/index.ts',
],

JSDoc Convention

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;

Clone this wiki locally