# How to Build Accessible Forms with Astryx Components: A Complete Guide for WCAG Compliance

> Build accessible forms with Astryx components. Achieve WCAG 2.1 compliance effortlessly with automatic ARIA attributes and keyboard support. Get started today.

- Repository: [Meta/astryx](https://github.com/facebook/astryx)
- Tags: how-to-guide
- Published: 2026-08-05

---

**Astryx form-input components are fully accessible out of the box, implementing WCAG 2.1 success criteria through automatic ARIA attributes, keyboard navigation, and screen reader support without requiring custom accessibility code.**

Building accessible forms with Astryx components eliminates the complexity of manual ARIA implementation. The `facebook/astryx` library provides form primitives—`TextInput`, `NumberInput`, `CheckboxInput`, and others—that embed accessibility directly into their source code. Each component handles labels, validation states, focus management, and assistive technology notifications automatically.

---

## Core Accessibility Features in Astryx Form Components

Astryx components follow a consistent accessibility pattern across all form inputs. These features are implemented in the source files located at `packages/core/src/`.

### Always-Visible or Screen-Reader-Only Labels

Every Astryx input requires a `label` prop. When `isLabelHidden` is true, the label wraps in `<VisuallyHidden>` so screen readers announce it while sighted users see only the placeholder. This pattern appears in [`packages/core/src/TextInput/TextInput.tsx`](https://github.com/facebook/astryx/blob/main/packages/core/src/TextInput/TextInput.tsx):

```tsx
<TextInput
  label="Search"
  isLabelHidden  // Visually hidden, still announced by screen readers
  value={query}
  onChange={setQuery}
/>

```

### Description and Helper Text with aria-describedby

The `description` prop renders between the label and input, automatically linked via `aria-describedby` as shown in [`packages/core/src/NumberInput/NumberInput.tsx`](https://github.com/facebook/astryx/blob/main/packages/core/src/NumberInput/NumberInput.tsx):

```tsx
<NumberInput
  label="Age"
  description="Enter your age in years for demographic analysis"
  value={age}
  onChange={setAge}
/>

```

### Required and Optional Indicators

Astryx enforces mutual exclusivity between `isRequired` and `isOptional`. The `isRequired` prop adds `aria-required="true"` and a visual "Required" badge, while `isOptional` shows an "Optional" badge. This implementation is visible in [`packages/core/src/CheckboxInput/CheckboxInput.tsx`](https://github.com/facebook/astryx/blob/main/packages/core/src/CheckboxInput/CheckboxInput.tsx):

```tsx
<CheckboxInput
  label="I agree to the terms"
  isRequired  // Adds aria-required="true"
  value={agreed}
  onChange={setAgreed}
/>

```

---

## Handling Disabled States Accessibly

Unlike native `disabled` attributes that remove elements from the focus order, Astryx uses `aria-disabled="true"` combined with a `disabledMessage` tooltip. This approach keeps the control focusable for assistive technology while explaining why it's unavailable.

```tsx
<TextInput
  label="Email"
  value={email}
  onChange={setEmail}
  aria-disabled={!isLoggedIn}
  disabledMessage="You must be logged in to edit your email."
/>

```

The `disabledMessage` renders in an accessible Tooltip component, ensuring screen reader users understand the restriction without losing context of where they are in the form.

---

## Loading States and Optimistic UI

When `isLoading` is true or a `changeAction` is pending, Astryx sets `aria-busy="true"` and displays a spinner. This signals to screen readers that the field is processing, preventing users from attempting edits during asynchronous operations.

```tsx
<TextInput
  label="Username"
  value={username}
  onChange={setUsername}
  isLoading={isCheckingAvailability}
/>

```

The spinner component at [`packages/core/src/Spinner/Spinner.tsx`](https://github.com/facebook/astryx/blob/main/packages/core/src/Spinner/Spinner.tsx) coordinates with the input's `aria-busy` attribute for synchronized announcements.

---

## Validation Status and Error Handling

Astryx provides three ways to communicate validation status through the `status` and `statusVariant` props:

| Variant | Behavior | Use Case |
|---------|----------|----------|
| `attached` | Message appears below the input | Default, always visible |
| `detached` | Message rendered separately by parent | Custom layouts |
| `tooltip` | Message appears on hover of status icon | Compact UIs |

For `error` status types, Astryx automatically adds `aria-invalid="true"`. This implementation resides in [`packages/core/src/NumberInput/NumberInput.tsx`](https://github.com/facebook/astryx/blob/main/packages/core/src/NumberInput/NumberInput.tsx):

```tsx
<NumberInput
  label="Quantity"
  value={qty}
  onChange={setQty}
  status={qty < 1 ? { type: 'error', message: 'Minimum order is 1' } : undefined}
  statusVariant="tooltip"
/>

```

---

## Keyboard Navigation and Focus Management

Astryx components handle keyboard interaction through several mechanisms in [`packages/core/src/TextInput/TextInput.tsx`](https://github.com/facebook/astryx/blob/main/packages/core/src/TextInput/TextInput.tsx):

- **Forwarded `onKeyDown`** for custom key handling
- **`onEnter` callback** for submit-on-return patterns
- **Focusable clear button** with dynamic `aria-label` referencing the field label
- **`hasAutoFocus`** for programmatic focus on mount
- **`useInputContainer`** hook making the entire wrapper clickable

```tsx
<TextInput
  label="Search query"
  value={query}
  onChange={setQuery}
  onEnter={() => performSearch(query)}
  hasClear  // Focusable clear button with aria-label
  hasAutoFocus  // Auto-focus on mount
/>

```

The clear button's `aria-label` includes the field's label through the `@astryx.textInput.clearLabel` i18n key, ensuring unique and descriptive labeling in repeated form fields.

---

## Complete Accessible Contact Form Example

This example combines multiple Astryx components into a WCAG-compliant form:

```tsx
import { TextInput, NumberInput, CheckboxInput, Field } from '@astryxdesign/core';

export function ContactForm() {
  const [email, setEmail] = React.useState('');
  const [age, setAge] = React.useState<number | null>(null);
  const [agreed, setAgreed] = React.useState(false);
  const [submitting, setSubmitting] = React.useState(false);
  const [emailError, setEmailError] = React.useState<string | undefined>();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setSubmitting(true);
    await new Promise(r => setTimeout(r, 1500));
    
    if (!email.includes('@')) {
      setEmailError('Please enter a valid email address.');
    } else {
      setEmailError(undefined);
    }
    setSubmitting(false);
  };

  return (
    <form onSubmit={handleSubmit}>
      <Field 
        label="Contact Information" 
        description="All fields are required unless marked optional."
      >
        <TextInput
          label="Email"
          value={email}
          onChange={setEmail}
          placeholder="you@example.com"
          isRequired
          status={emailError ? { type: 'error', message: emailError } : undefined}
          isLoading={submitting}
        />
        <NumberInput
          label="Age"
          value={age}
          onChange={setAge}
          isOptional
          min={0}
          max={120}
          units="years"
        />
        <CheckboxInput
          label="I agree to the terms and conditions"
          value={agreed}
          onChange={setAgreed}
          isRequired
        />
        <button type="submit" disabled={submitting}>
          Submit
        </button>
      </Field>
    </form>
  );
}

```

**Accessibility features in this form:**

- `Field` component provides consistent grouping, label, and description structure
- Required fields announce via `aria-required`
- Error state sets `aria-invalid` and displays visible message
- Loading state sets `aria-busy` on the email input
- Checkbox follows native checkbox accessibility patterns with enhanced labeling

---

## Key Source Files for Accessibility Implementation

| Component | Source Path | Key Accessibility Exports |
|-----------|-------------|---------------------------|
| TextInput | [`packages/core/src/TextInput/TextInput.tsx`](https://github.com/facebook/astryx/blob/main/packages/core/src/TextInput/TextInput.tsx) | `TextInputProps`, `useInputContainer` |
| NumberInput | [`packages/core/src/NumberInput/NumberInput.tsx`](https://github.com/facebook/astryx/blob/main/packages/core/src/NumberInput/NumberInput.tsx) | `NumberInputProps` |
| CheckboxInput | [`packages/core/src/CheckboxInput/CheckboxInput.tsx`](https://github.com/facebook/astryx/blob/main/packages/core/src/CheckboxInput/CheckboxInput.tsx) | `CheckboxInputProps` |
| Field | `packages/core/src/Field/*` | `FieldProps` |
| VisuallyHidden | [`packages/core/src/VisuallyHidden/VisuallyHidden.tsx`](https://github.com/facebook/astryx/blob/main/packages/core/src/VisuallyHidden/VisuallyHidden.tsx) | Hidden-but-readable text utility |
| Tooltip | `packages/core/src/Tooltip/*` | Accessible tooltip for messages |
| Spinner | [`packages/core/src/Spinner/Spinner.tsx`](https://github.com/facebook/astryx/blob/main/packages/core/src/Spinner/Spinner.tsx) | `aria-busy` indicator |

---

## Summary

- **Astryx components require no manual ARIA**—attributes like `aria-required`, `aria-invalid`, `aria-describedby`, and `aria-busy` are set automatically based on props
- **Disabled states remain accessible** through `aria-disabled` and `disabledMessage` tooltips instead of native `disabled`
- **Hidden labels still announce** to screen readers via the `VisuallyHidden` component when `isLabelHidden` is used
- **Validation feedback reaches assistive technology** through `status` props with configurable `statusVariant` display modes
- **Keyboard and focus management** are built in, including `onEnter` callbacks and clickable input containers

---

## Frequently Asked Questions

### Does Astryx require additional accessibility testing?

Astryx components satisfy WCAG 2.1 form success criteria by default, but you should still test with actual screen readers and automated tools like axe-core. The components handle the technical requirements; your responsibility is proper prop usage and logical form structure.

### How do I hide a label without breaking accessibility?

Use the `isLabelHidden` prop. This wraps the label in `VisuallyHidden` instead of removing it, maintaining the `label` element's association with the input for screen reader announcement.

### Can I customize validation message placement?

Yes. The `statusVariant` prop accepts `"attached"`, `"detached"`, or `"tooltip"`. All variants maintain screen reader accessibility through `aria-describedby` or tooltip announcement patterns.

### What should I use instead of the native disabled attribute?

Use `aria-disabled="true"` combined with `disabledMessage`. This keeps the control focusable so screen reader users understand the field exists and why it's unavailable, rather than having it disappear from the tab order entirely.