[!CAUTION] BASED ON STANDARD RADIX/SHADCN PATTERNS — VERIFY AGAINST YOUR IMPLEMENTATION These accessibility patterns are derived from Radix UI primitive documentation and WAI-ARIA best practices. Your actual component implementations may differ. Test with a screen reader before relying on this guidance.
Applies To
- Form — wrapper component (react-hook-form + Radix Form)
- Input — text input fields
- Textarea — multi-line text input
- Select — dropdown selection (Radix Select primitive)
- Checkbox — toggle check (Radix Checkbox primitive)
- Radio Group — exclusive option selection (Radix RadioGroup primitive)
- Switch — toggle on/off (Radix Switch primitive)
- Combobox — autocomplete/search select (Radix Popover + Command)
- Date Picker — calendar-based date selection
General Form Accessibility
Label Association
Every form control must have a programmatically associated label. There are no exceptions.
| Method | When to Use | Example |
|---|---|---|
<Label htmlFor="id"> | Standard — visible label above/beside field | <Label htmlFor="email">Email</Label><Input id="email" /> |
aria-label | Visually hidden label (e.g., search input with icon) | <Input aria-label="Search" /> |
aria-labelledby | Label text exists elsewhere on page | <Input aria-labelledby="section-heading" /> |
Never use placeholder as the sole label — it disappears when the user starts typing.
Error Messages and Descriptions
Link hints and error messages to their fields via aria-describedby:
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
aria-describedby="email-hint email-error"
aria-invalid={!!errors.email}
/>
<p id="email-hint" className="text-sm text-muted-foreground">
We'll never share your email.
</p>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
Key points:
aria-describedbycan reference multiple IDs (space-separated) — the hint and error are both announcedaria-invalid="true"tells screen readers the field has an error- Use
role="alert"on dynamically appearing error messages so they're announced immediately - The error message element should only be in the DOM when there's an error (or use
aria-live="assertive"on a container)
Required Fields
The convention is a visible asterisk on required labels plus a form-level note explaining it —
RequiredFieldsNote. It is not one option among several; see
Decision: Marking Required Fields for why mixing it with
"(optional)" labels leaves an unmarked field meaning nothing. Use required on FormItem or
Field and the mechanics below are applied for you. What follows is what that prop produces,
and what to check when you are hand-rolling a field outside those wrappers.
<RequiredFieldsNote variant="marked" />
<Label htmlFor="name">
Name <span aria-hidden="true">*</span>
</Label>
<Input id="name" required aria-required="true" />
- Use the native
requiredattribute AND/ORaria-required="true"— this is what actually communicates requiredness to assistive technology. The asterisk is the visible half only. - The asterisk is hidden from screen readers with
aria-hidden="true". Without it the character is announced ("Name star, required") or, on some screen readers, read as "asterisk" — noise on top of the statearia-requiredalready conveys. - If you want the requiredness in the label's own text rather than in the control's ARIA state,
sr-only"(required)" inside the label is the way to do it — but it is an implementation detail of the asterisk, not a replacement for it. On its own it leaves sighted users with no indicator at all, and paired witharia-requiredit stutters: "Name, required, required, edit text". Prefer the asterisk +aria-requiredpairing above. - When every field is required, mark none of them and use
<RequiredFieldsNote variant="all" />instead. An asterisk on every label distinguishes nothing.aria-requiredstill goes on every control. - Never ship a label reading "Name (optional)". That is the rejected convention, and it logs a dev warning.
Component-Specific Patterns
Input
Based on standard HTML <input> — no Radix primitive.
ARIA attributes:
| Attribute | When | Purpose |
|---|---|---|
aria-invalid="true" | Field has a validation error | Screen reader announces "invalid" |
aria-describedby="hint-id error-id" | Field has hints or errors | Associates descriptions with the field |
aria-required="true" | Field is required | Screen reader announces "required" |
aria-disabled="true" | Field is disabled (if not using native disabled) | Screen reader announces "dimmed" or "disabled" |
Keyboard:
| Key | Action |
|---|---|
| Tab | Focus the input |
| Shift+Tab | Move focus to previous element |
| Any character | Types into the field |
Screen reader announcement on focus:
"Email, required, edit text, We'll never share your email" (label + required + role + description)
If invalid:
"Email, required, invalid, edit text, Please enter a valid email" (adds invalid state + error message)
Textarea
Identical to Input for accessibility. Same ARIA attributes and keyboard patterns apply.
Additionally:
- If there's a character count, announce it:
aria-describedbypointing to the counter - Auto-expanding textareas should not interfere with screen reader operation
Select (Radix)
Built on Radix Select primitive — a custom dropdown that replaces native <select>.
ARIA attributes (handled by Radix):
| Attribute | Applied To | Value |
|---|---|---|
role="combobox" | Trigger | Identifies it as a combo box |
aria-expanded | Trigger | "true" when open, "false" when closed |
aria-haspopup="listbox" | Trigger | Tells AT a listbox will appear |
role="listbox" | Content dropdown | Container for options |
role="option" | Each item | Individual selectable option |
aria-selected="true" | Selected option | Currently selected value |
Keyboard interactions:
| Key | Action |
|---|---|
| Space / Enter | Open the select dropdown |
| Arrow Down | Open dropdown; move to next option |
| Arrow Up | Move to previous option |
| Home | Move to first option |
| End | Move to last option |
| Typeahead (type characters) | Jump to matching option (e.g., typing "Ca" jumps to "California") |
| Enter / Space | Select the focused option and close |
| Escape | Close without selecting |
Grouped options:
- Radix
SelectGrouprendersrole="group"witharia-labelledbypointing to theSelectLabel - Screen readers announce group label before options in that group
Screen reader announcement on focus:
"Country, combo box, collapsed, United States" (label + role + state + current value)
On open:
"listbox, 5 items" + focused option announced
Checkbox (Radix)
Built on Radix Checkbox primitive.
ARIA attributes (handled by Radix):
| Attribute | Value | When |
|---|---|---|
role="checkbox" | Always | Implicit from Radix |
aria-checked="true" | Checked | Box is checked |
aria-checked="false" | Unchecked | Box is not checked |
aria-checked="mixed" | Indeterminate | When checked="indeterminate" is passed |
Keyboard interactions:
| Key | Action |
|---|---|
| Space | Toggle checked/unchecked (or cycle through indeterminate) |
| Tab | Move focus to next element |
Do NOT use Enter to toggle — this is the standard behavior per WAI-ARIA. Radix enforces this.
Implementation pattern:
<div className="flex items-center gap-2">
<Checkbox
id="terms"
checked={checked}
onCheckedChange={setChecked}
aria-describedby="terms-desc"
/>
<Label htmlFor="terms">Accept terms and conditions</Label>
<p id="terms-desc" className="sr-only">
You must accept the terms to continue.
</p>
</div>
Indeterminate state (e.g., "select all" when some items are selected):
- Pass
checked="indeterminate"to Radix Checkbox - Screen reader announces: "Accept all, checkbox, mixed"
Screen reader announcement:
"Accept terms and conditions, checkbox, not checked" → (press Space) → "checked"
Radio Group (Radix)
Built on Radix RadioGroup primitive.
ARIA attributes (handled by Radix):
| Attribute | Applied To | Value |
|---|---|---|
role="radiogroup" | Container | Groups the radio buttons |
role="radio" | Each item | Individual option |
aria-checked="true" | Selected item | Currently selected |
aria-checked="false" | Other items | Not selected |
Keyboard interactions:
| Key | Action |
|---|---|
| Tab | Move focus into the radio group (lands on the selected item, or first item if none selected) |
| Arrow Down / Arrow Right | Move to next radio and select it |
| Arrow Up / Arrow Left | Move to previous radio and select it |
| Tab (again) | Move focus out of the radio group |
Important: Arrow keys both move focus AND select the option — this is the standard radio group behavior per WAI-ARIA. Individual radios are NOT separately tab-focusable.
Implementation pattern:
<RadioGroup value={value} onValueChange={setValue} aria-label="Notification preference">
<div className="flex items-center gap-2">
<RadioGroupItem value="all" id="all" />
<Label htmlFor="all">All notifications</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="mentions" id="mentions" />
<Label htmlFor="mentions">Mentions only</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="none" id="none" />
<Label htmlFor="none">None</Label>
</div>
</RadioGroup>
Screen reader announcement:
"Notification preference, radio group" → "All notifications, radio, 1 of 3, selected"
Switch (Radix)
Built on Radix Switch primitive. Visually a toggle, semantically a switch.
ARIA attributes (handled by Radix):
| Attribute | Value |
|---|---|
role="switch" | Distinguishes from checkbox — "on/off" rather than "checked/unchecked" |
aria-checked="true" | Switch is on |
aria-checked="false" | Switch is off |
Keyboard interactions:
| Key | Action |
|---|---|
| Space | Toggle on/off |
| Tab | Move focus to next element |
Implementation pattern:
<div className="flex items-center gap-2">
<Switch
id="airplane"
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor="airplane">Airplane mode</Label>
</div>
Screen reader announcement:
"Airplane mode, switch, off" → (press Space) → "on"
Note: role="switch" is distinct from role="checkbox". Use Switch when the control represents an immediate on/off state (like a light switch). Use Checkbox when the value is submitted as part of a form.
Combobox (Autocomplete)
Typically built with Radix Popover + Command (cmdk) — not a single Radix primitive.
ARIA attributes:
| Attribute | Applied To | Value |
|---|---|---|
role="combobox" | Input field | Identifies as combobox |
aria-expanded | Input field | "true" when listbox is open |
aria-controls | Input field | ID of the listbox popup |
aria-activedescendant | Input field | ID of the currently highlighted option |
role="listbox" | Options container | List of options |
role="option" | Each option | Individual selectable option |
aria-selected="true" | Highlighted option | Currently active option |
Keyboard interactions:
| Key | Action |
|---|---|
| Type characters | Filter options, open listbox if closed |
| Arrow Down | Open listbox; move highlight to next option |
| Arrow Up | Move highlight to previous option |
| Enter | Select the highlighted option |
| Escape | Close listbox without selecting |
| Home / End | Move to first / last option |
Implementation pattern:
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-label="Select framework"
>
{selectedValue || "Select framework..."}
<ChevronsUpDown className="size-4" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput placeholder="Search framework..." aria-label="Search frameworks" />
<CommandEmpty>No framework found.</CommandEmpty>
<CommandGroup>
<CommandItem onSelect={handleSelect}>React</CommandItem>
<CommandItem onSelect={handleSelect}>Vue</CommandItem>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
Key points:
- The "No results found" state (
CommandEmpty) must be announced —aria-live="polite"on the container or use the cmdk built-in announcements - Consider announcing the result count: "5 options available" when the listbox opens or filters change
aria-activedescendanton the input tracks which option is highlighted — this allows the input to keep DOM focus while the listbox item is virtually focused
Screen reader announcement:
"Select framework, combo box, expanded" → "React, option, 1 of 5"
Date Picker
Typically built with a text input + Radix Popover containing a calendar grid. This is one of the most complex form widgets for accessibility.
ARIA for the calendar grid:
| Attribute | Applied To | Value |
|---|---|---|
role="grid" | Calendar month grid | Identifies as navigable grid |
role="row" | Each week row | |
role="gridcell" | Each day cell | |
aria-selected="true" | Selected date | Currently chosen date |
aria-disabled="true" | Disabled dates | Dates that cannot be selected |
aria-label | Each day cell | Full date: "January 15, 2026" (not just "15") |
Keyboard interactions:
| Key | Action |
|---|---|
| Arrow Left/Right | Move to previous/next day |
| Arrow Up/Down | Move to same day in previous/next week |
| Home | Move to first day of the week |
| End | Move to last day of the week |
| Page Up | Move to same day in previous month |
| Page Down | Move to same day in next month |
| Shift+Page Up | Move to same day in previous year |
| Shift+Page Down | Move to same day in next year |
| Enter / Space | Select the focused date |
| Escape | Close the calendar popup, return focus to input |
Key points:
- Each date cell must have a full accessible label ("January 15, 2026"), not just the day number
- The month/year navigation buttons need
aria-label(e.g., "Go to previous month", "Go to next month") - Disabled dates should have
aria-disabled="true"— explain why if possible (e.g., "January 10, 2026, not available") - When the calendar opens, focus should move to the currently selected date (or today if no selection)
- When a date is selected, announce it: "January 15, 2026, selected" and close the popup
Form Validation Patterns
Inline Validation (On Blur)
<FormField
control={form.control}
name="email"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
aria-invalid={!!fieldState.error}
aria-describedby={fieldState.error ? "email-error" : "email-hint"}
/>
</FormControl>
<FormDescription id="email-hint">Enter your work email.</FormDescription>
{fieldState.error && (
<FormMessage id="email-error" role="alert">
{fieldState.error.message}
</FormMessage>
)}
</FormItem>
)}
/>
Key points:
- Set
aria-invalid="true"when the field has an error - Error message appears with
role="alert"so it's immediately announced - When the error is corrected, remove
aria-invalidand the error message — the screen reader won't announce the removal (which is the correct behavior)
Submit Validation (Error Summary)
When a form is submitted with errors:
- Move focus to the first field with an error (or to an error summary at the top of the form)
- If using an error summary:
<div role="alert" aria-label="Form errors"> <h2>Please correct the following errors:</h2> <ul> <li><a href="#email">Email is required</a></li> <li><a href="#password">Password must be at least 8 characters</a></li> </ul> </div> - Each error in the summary should link to the corresponding field (via anchor to the field's
id) - Screen reader will announce: "Form errors, alert, Please correct the following errors: ..."
Live Region for Validation State
For real-time validation feedback (e.g., password strength):
<div aria-live="polite" aria-atomic="true" className="sr-only">
{passwordStrength} {/* e.g., "Password strength: strong" */}
</div>
Debounce updates (300–500ms) to avoid excessive announcements while typing.
Common Mistakes
| Mistake | Why It's a Problem | Correct Approach |
|---|---|---|
| Placeholder as only label | Disappears on input; many screen readers don't announce it | Always use <Label> or aria-label |
| Error message not linked to field | Screen reader user doesn't know which field has the error | Use aria-describedby pointing to the error message ID |
Missing aria-invalid on error fields | Screen reader doesn't announce the error state | Set aria-invalid="true" when field has validation error |
| Custom Select without keyboard support | Keyboard users can't operate it | Use Radix Select which handles all keyboard patterns |
| Radio buttons individually tab-focusable | Violates WAI-ARIA pattern; confusing keyboard behavior | Use Radix RadioGroup — Tab enters group, arrows navigate |
Switch using role="checkbox" | Incorrect semantics — switch is on/off, not checked/unchecked | Use Radix Switch which applies role="switch" |
Asterisk (*) with no aria-required | Screen reader users may not hear the visual asterisk | Use the required prop on FormItem/Field, or set aria-required="true" yourself |
| Asterisks with no form-level note | The marker is decoration — nothing on screen says what * means | Render <RequiredFieldsNote /> above the fields |
| "(optional)" in a label | Mixes the two marking conventions, so an unmarked field means nothing — see the decision record | Mark the required fields with required and leave optional labels plain |
| Error appears but not announced | Screen reader user doesn't know an error occurred | Use role="alert" or aria-live="assertive" on error messages |
| Combobox options not labelled | Screen reader says "option" with no text | Ensure each option has text content or aria-label |
| Date picker calendar without full date labels | Screen reader says "15" instead of "January 15, 2026" | Use aria-label with full date on each day cell |
Testing Checklist
Screen Reader Testing
- All labels announced correctly on focus
- Required state announced ("required") — once, not twice, and the asterisk itself not announced
- Error messages announced when they appear (
role="alert") -
aria-invalidstate announced on errored fields - Hint/description text announced via
aria-describedby - Select: options and groups announced, selected value announced
- Checkbox: checked/unchecked/mixed state announced
- Radio: group label, selected option, "X of Y" position announced
- Switch: "on"/"off" state announced
- Combobox: filtered options and selection announced
- Date picker: full date labels and navigation announced
Keyboard Testing
- All form fields reachable via Tab
- Select: Space/Enter opens, arrows navigate, Escape closes, typeahead works
- Checkbox: Space toggles
- Radio Group: Tab enters, arrows navigate AND select, Tab exits
- Switch: Space toggles
- Combobox: type to filter, arrows to navigate, Enter to select, Escape to close
- Date Picker: arrows navigate days, Page Up/Down for months, Enter selects
- Form: Enter submits from text inputs (when appropriate)
- Focus visible on all fields and controls
Visual Testing
- Error states visible beyond color alone (icon, border, text)
- Focus ring visible on all form controls
- Required indicator visible — asterisk on required labels, or "All fields are required" when every field is
- The form carries a
RequiredFieldsNotewhenever any label shows an asterisk - Disabled state visually distinct
References
- WAI-ARIA Forms Pattern
- WAI-ARIA Combobox Pattern
- WAI-ARIA Checkbox Pattern
- WAI-ARIA Radio Group Pattern
- WAI-ARIA Switch Pattern
- Radix Select Primitive
- Radix Checkbox Primitive
- Radix RadioGroup Primitive
- Radix Switch Primitive
Last updated: September 9, 2026