React Aria provides WAI-ARIA compliant behavior for React components — it handles keyboard navigation, focus management, screen reader announcements, and touch events so you only write the visual styles. useButton handles clicks, keyboard press, focus rings, and disabled states. useSelect + useListBox + useOption builds fully accessible dropdowns. useDialog + useModal manages focus trapping and aria-modal for overlays. useDatePicker + useCalendar + useCalendarCell composes accessible date pickers. useComboBox + useFilter builds autocomplete inputs. useTable with useTableRowSelection and useTableColumnHeader handles sortable, selectable data tables. React Aria Components (RAC) is the higher-level styled API on top. useLocale and I18nProvider handle internationalization. Claude Code generates React Aria hook compositions, accessible custom components, focus management patterns, and the complete keyboard interaction models for complex interactive widgets.
CLAUDE.md for React Aria
## React Aria Stack
- Version: react-aria >= 3.35, react-stately >= 3.33
- Hooks: @react-aria/button, @react-aria/select, @react-aria/dialog, @react-aria/focus
- State: useSelectState, useListState, useOverlayTriggerState from react-stately
- Focus: useFocusRing({ isTextInput? }) — returns isFocusVisible for ring styles
- I18n: <I18nProvider locale="en-US"> wraps app — useLocale() reads locale
- RAC: react-aria-components — higher level, already composed: Button, Select, Dialog
- Overlay: useOverlayTrigger + usePopover — positions and manages overlay lifecycle
Accessible Button
// components/ui/AccessibleButton.tsx
import { useButton, useFocusRing, mergeProps } from "react-aria"
import { useRef } from "react"
import type { AriaButtonProps } from "react-aria"
interface ButtonProps extends AriaButtonProps {
variant?: "primary" | "secondary" | "ghost" | "danger"
size?: "sm" | "md" | "lg"
isLoading?: boolean
children: React.ReactNode
}
export function AccessibleButton({
variant = "primary",
size = "md",
isLoading,
children,
isDisabled,
...props
}: ButtonProps) {
const ref = useRef<HTMLButtonElement>(null)
const { buttonProps, isPressed } = useButton(
{ ...props, isDisabled: isDisabled || isLoading },
ref
)
const { focusProps, isFocusVisible } = useFocusRing()
const variantStyles = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
danger: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
}
const sizeStyles = {
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
}
return (
<button
{...mergeProps(buttonProps, focusProps)}
ref={ref}
className={[
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
variantStyles[variant],
sizeStyles[size],
isPressed && "scale-95",
isFocusVisible && "ring-2 ring-ring ring-offset-2",
]
.filter(Boolean)
.join(" ")}
>
{isLoading ? (
<>
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="sr-only">Loading</span>
{children}
</>
) : children}
</button>
)
}
Accessible Select
// components/ui/AccessibleSelect.tsx — fully accessible dropdown
import {
useSelect,
useListBox,
useOption,
usePopover,
useOverlayPosition,
DismissButton,
mergeProps,
HiddenSelect,
} from "react-aria"
import { useSelectState, type SelectStateOptions } from "react-stately"
import { useRef } from "react"
import { ChevronDown } from "lucide-react"
import type { Node } from "@react-types/shared"
interface SelectProps<T extends object> extends SelectStateOptions<T> {
label: string
className?: string
}
function Option<T extends object>({ item, state }: { item: Node<T>; state: ReturnType<typeof useSelectState<T>> }) {
const ref = useRef<HTMLLIElement>(null)
const { optionProps, isSelected, isFocused } = useOption({ key: item.key }, state, ref)
const { focusProps, isFocusVisible } = useFocusRing()
return (
<li
{...mergeProps(optionProps, focusProps)}
ref={ref}
className={[
"px-3 py-2 text-sm cursor-default rounded-sm outline-none",
isFocused && "bg-accent text-accent-foreground",
isSelected && "font-medium",
isFocusVisible && "ring-1 ring-ring",
]
.filter(Boolean)
.join(" ")}
>
<span className="flex items-center gap-2">
{isSelected && (
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" />
</svg>
)}
<span className={isSelected ? "" : "ml-6"}>{item.rendered}</span>
</span>
</li>
)
}
import { useFocusRing } from "react-aria"
export function AccessibleSelect<T extends object>({
label,
className,
...props
}: SelectProps<T>) {
const state = useSelectState(props)
const triggerRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const listBoxRef = useRef<HTMLUListElement>(null)
const { labelProps, triggerProps, valueProps, menuProps } = useSelect(props, state, triggerRef)
const { overlayProps } = useOverlayPosition({
targetRef: triggerRef,
overlayRef: popoverRef,
placement: "bottom start",
offset: 4,
isOpen: state.isOpen,
})
const { focusProps, isFocusVisible } = useFocusRing()
return (
<div className={className}>
<label {...labelProps} className="text-sm font-medium text-foreground mb-1 block">
{label}
</label>
{/* Hidden native select for form submission */}
<HiddenSelect state={state} triggerRef={triggerRef} label={label} name={props.name} />
<button
{...mergeProps(triggerProps, focusProps)}
ref={triggerRef}
className={[
"flex h-10 w-full items-center justify-between rounded-md border border-input",
"bg-background px-3 py-2 text-sm ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
isFocusVisible && "ring-2 ring-ring ring-offset-2",
]
.filter(Boolean)
.join(" ")}
>
<span {...valueProps}>
{state.selectedItem ? state.selectedItem.rendered : "Select an option"}
</span>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform ${state.isOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
{state.isOpen && (
<div
{...overlayProps}
ref={popoverRef}
className="z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
>
<DismissButton onDismiss={() => state.close()} />
<ul
{...menuProps}
ref={listBoxRef}
className="p-1 max-h-60 overflow-auto outline-none"
>
{[...state.collection].map(item => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
<DismissButton onDismiss={() => state.close()} />
</div>
)}
</div>
)
}
Accessible Dialog / Modal
// components/ui/AccessibleDialog.tsx — focus trap + aria-modal
import {
useDialog,
useModal,
useOverlay,
usePreventScroll,
FocusScope,
mergeProps,
} from "react-aria"
import { useRef } from "react"
interface DialogProps {
title: string
isOpen: boolean
onClose: () => void
children: React.ReactNode
isDismissable?: boolean
}
function DialogInner({ title, onClose, isDismissable = true, children }: Omit<DialogProps, "isOpen">) {
const ref = useRef<HTMLDivElement>(null)
const { overlayProps, underlayProps } = useOverlay(
{ isOpen: true, onClose, isDismissable, shouldCloseOnInteractOutside: () => isDismissable },
ref
)
const { modalProps } = useModal()
const { dialogProps, titleProps } = useDialog({}, ref)
usePreventScroll()
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
{...underlayProps}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50" aria-hidden="true" />
<FocusScope contain restoreFocus autoFocus>
<div
{...mergeProps(overlayProps, dialogProps, modalProps)}
ref={ref}
className="relative z-10 w-full max-w-lg rounded-xl bg-background p-6 shadow-2xl"
>
<h2 {...titleProps} className="text-lg font-semibold mb-4">
{title}
</h2>
{children}
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label="Close dialog"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</FocusScope>
</div>
)
}
export function AccessibleDialog({ isOpen, ...props }: DialogProps) {
if (!isOpen) return null
return <DialogInner {...props} />
}
ComboBox / Autocomplete
// components/ui/AccessibleComboBox.tsx
import {
useComboBox,
useButton,
useFilter,
mergeProps,
} from "react-aria"
import { useComboBoxState } from "react-stately"
import { useRef } from "react"
import type { ComboBoxProps } from "@react-types/combobox"
export function AccessibleComboBox<T extends object>(props: ComboBoxProps<T> & { label: string }) {
const { contains } = useFilter({ sensitivity: "base" })
const state = useComboBoxState({ ...props, defaultFilter: contains })
const inputRef = useRef<HTMLInputElement>(null)
const listBoxRef = useRef<HTMLUListElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const { buttonProps, inputProps, listBoxProps, labelProps } = useComboBox(
{ ...props, inputRef, listBoxRef, popoverRef, buttonRef },
state
)
const { buttonProps: toggleProps } = useButton(buttonProps, buttonRef)
return (
<div className="relative">
<label {...labelProps} className="text-sm font-medium block mb-1">
{props.label}
</label>
<div className="flex">
<input
{...inputProps}
ref={inputRef}
className="flex h-10 w-full rounded-l-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
{...toggleProps}
ref={buttonRef}
className="rounded-r-md border border-l-0 border-input px-2 focus:ring-2 focus:ring-ring"
>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.427 9.573l3.396-3.396a.25.25 0 01.354 0l3.396 3.396A.25.25 0 0111.396 10H4.604a.25.25 0 01-.177-.427z"/>
</svg>
</button>
</div>
{state.isOpen && (
<div
ref={popoverRef}
className="absolute z-50 w-full mt-1 rounded-md border bg-popover shadow-md"
>
<ul
{...listBoxProps}
ref={listBoxRef}
className="max-h-60 overflow-auto p-1 outline-none"
>
{[...state.collection].map(item => (
<li
key={item.key}
className={`px-3 py-2 text-sm rounded-sm cursor-default ${
state.selectionManager.focusedKey === item.key ? "bg-accent" : ""
}`}
>
{item.rendered}
</li>
))}
</ul>
</div>
)}
</div>
)
}
For the Radix UI Primitives alternative that provides unstyled accessible components with a different API style — Radix uses compound components with Root/Trigger/Content pattern rather than hooks, so it integrates more naturally with CSS-in-JS styling without manual ref wiring, see the headless component guide. For the Headless UI alternative from Tailwind Labs that provides a smaller set of components (Listbox, Combobox, Dialog, Disclosure, Transition) with Tailwind-friendly styling patterns and simpler API than full React Aria hooks — useful for projects already using Tailwind, see the Headless UI patterns guide. The Claude Skills 360 bundle includes React Aria skill sets covering accessible dropdowns, dialogs, and date pickers. Start with the free tier to try accessibility-first component generation.