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
onKeyDownfor custom key handling onEntercallback for submit-on-return patterns- Focusable clear button with dynamic
aria-labelreferencing the field label hasAutoFocusfor programmatic focus on mountuseInputContainerhook 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:
Fieldcomponent provides consistent grouping, label, and description structure- Required fields announce via
aria-required - Error state sets
aria-invalidand displays visible message - Loading state sets
aria-busyon 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, andaria-busyare set automatically based on props - Disabled states remain accessible through
aria-disabledanddisabledMessagetooltips instead of nativedisabled - Hidden labels still announce to screen readers via the
VisuallyHiddencomponent whenisLabelHiddenis used - Validation feedback reaches assistive technology through
statusprops with configurablestatusVariantdisplay modes - Keyboard and focus management are built in, including
onEntercallbacks 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →