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

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:

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

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

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

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

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

The spinner component at 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:

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

  • 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
<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:

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 TextInputProps, useInputContainer
NumberInput packages/core/src/NumberInput/NumberInput.tsx NumberInputProps
CheckboxInput packages/core/src/CheckboxInput/CheckboxInput.tsx CheckboxInputProps
Field packages/core/src/Field/* FieldProps
VisuallyHidden 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 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →