
React Aria Patterns
- 15 installs
- 213 repo stars
- Updated August 4, 2026
- yonatangross/orchestkit
Helps with frontend development tasks.
About
react-aria-patterns is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted coding.
- react-aria-patterns
- Frontend Development
- AI-coding skill
React Aria Patterns by the numbers
- 15 all-time installs (skills.sh)
- Ranked #1,610 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yonatangross/orchestkit --skill react-aria-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 15 |
|---|---|
| repo stars | ★ 213 |
| Last updated | August 4, 2026 |
| Repository | yonatangross/orchestkit ↗ |
What it does
Helps with frontend development tasks.
Files
React Aria Patterns
Build accessible UI components using Adobe's React Aria hooks library with React 19 patterns.
Overview
- Building accessible buttons, links, and toggles with keyboard/screen reader support
- Implementing modal dialogs with proper focus management and trapping
- Creating autocomplete/combobox components with filtering and selection
- Building menu systems with roving tabindex and proper ARIA roles
- Implementing accessible tables, listboxes, and selection patterns
Quick Reference
useButton - Accessible Button Component
import { useRef } from 'react';
import { useButton, useFocusRing, mergeProps } from 'react-aria';
import type { AriaButtonProps } from 'react-aria';
function Button(props: AriaButtonProps & { className?: string }) {
const ref = useRef<HTMLButtonElement>(null);
const { focusProps, isFocusVisible } = useFocusRing();
const { buttonProps } = useButton(props, ref);
return (
<button
{...mergeProps(buttonProps, focusProps)}
ref={ref}
className={`${props.className ?? ''} ${isFocusVisible ? 'ring-2 ring-blue-500' : ''}`}
>
{props.children}
</button>
);
}useDialog - Modal Dialog with Focus Management
import { useRef } from 'react';
import { useDialog, useModalOverlay, FocusScope, mergeProps } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';
function Modal({ state, title, children }) {
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay({}, state, ref);
const { dialogProps, titleProps } = useDialog({ 'aria-label': title }, ref);
return (
<div {...underlayProps} className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center">
<FocusScope contain restoreFocus autoFocus>
<div {...mergeProps(modalProps, dialogProps)} ref={ref} className="bg-white rounded-lg p-6">
<h2 {...titleProps} className="text-xl font-semibold mb-4">{title}</h2>
{children}
</div>
</FocusScope>
</div>
);
}useComboBox - Accessible Autocomplete
import { useRef } from 'react';
import { useComboBox, useFilter } from 'react-aria';
import { useComboBoxState } from 'react-stately';
function ComboBox(props) {
const { contains } = useFilter({ sensitivity: 'base' });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const inputRef = useRef(null), buttonRef = useRef(null), listBoxRef = useRef(null);
const { buttonProps, inputProps, listBoxProps, labelProps } = useComboBox(
{ ...props, inputRef, buttonRef, listBoxRef }, state
);
return (
<div className="relative inline-flex flex-col">
<label {...labelProps}>{props.label}</label>
<div className="flex">
<input {...inputProps} ref={inputRef} className="border rounded-l px-3 py-2" />
<button {...buttonProps} ref={buttonRef} className="border rounded-r px-2">▼</button>
</div>
{state.isOpen && (
<ul {...listBoxProps} ref={listBoxRef} className="absolute top-full w-full border bg-white">
{[...state.collection].map((item) => (
<li key={item.key} className="px-3 py-2 hover:bg-gray-100">{item.rendered}</li>
))}
</ul>
)}
</div>
);
}Key Decisions
| Decision | Option A | Option B | Recommendation |
|---|---|---|---|
| Hook vs Component | useButton hooks | Button from react-aria-components | Hooks for control, Components for speed |
| Focus Management | Manual tabIndex | FocusScope component | FocusScope - trapping, restore, auto-focus |
| Virtual Lists | Native scroll | useVirtualizer + useListBox | Virtualizer for lists > 100 items |
| State Management | Local useState | react-stately hooks | react-stately - designed for a11y |
Anti-Patterns (FORBIDDEN)
// NEVER use div with onClick for interactive elements
<div onClick={handleClick}>Click me</div> // Missing keyboard support!
// ALWAYS use useButton or native button
const { buttonProps } = useButton({ onPress: handleClick }, ref);
<div {...buttonProps} ref={ref}>Click me</div>
// NEVER handle focus manually for modals
useEffect(() => { modalRef.current?.focus(); }, []); // Incomplete!
// ALWAYS use FocusScope for modals/overlays
<FocusScope contain restoreFocus autoFocus>
<div role="dialog">...</div>
</FocusScope>
// NEVER forget aria-live for dynamic announcements
<div>{errorMessage}</div> // Screen readers won't announce!
// ALWAYS use aria-live for status updates
<div aria-live="polite" className="sr-only">{errorMessage}</div>
// NEVER omit label associations
<input type="text" placeholder="Email" /> // No accessible name!
// ALWAYS associate labels properly
<label {...labelProps}>Email</label>
<input {...inputProps} />Related Skills
a11y-testing- Automated accessibility testing with jest-axe and Playwrightfocus-management- Advanced focus patterns and keyboard navigationdesign-system-starter- Building accessible component librariesi18n-date-patterns- Internationalization for accessible content
Capability Details
useButton-hook
Keywords: button, useButton, press, tap, keyboard, click, onPress, focus ring Solves:
- How to create accessible custom buttons
- Handling keyboard and pointer interactions consistently
- Focus ring visibility management with useFocusRing
useDialog-modal
Keywords: dialog, modal, useDialog, useModalOverlay, FocusScope, overlay, trap Solves:
- Building accessible modal dialogs with proper ARIA roles
- Focus trapping within overlays using FocusScope
- Restoring focus to trigger element on close
useComboBox-autocomplete
Keywords: combobox, autocomplete, useComboBox, typeahead, filter, select, dropdown Solves:
- Accessible autocomplete/typeahead inputs with filtering
- Keyboard navigation through options (arrow keys, enter, escape)
- Screen reader announcements for selection changes
focus-scope-management
Keywords: focus, FocusScope, contain, restore, autoFocus, trap, keyboard navigation Solves:
- Trapping focus within modals and popovers (contain prop)
- Restoring focus to trigger elements on unmount (restoreFocus prop)
- Auto-focusing first focusable element (autoFocus prop)
React Aria Component Checklist
Comprehensive checklist for building accessible components with React Aria.
Pre-Implementation
Before building a new component:
- [ ] Identify the correct ARIA pattern from ARIA Authoring Practices Guide
- [ ] Determine if a React Aria hook exists for this pattern
- [ ] Install dependencies:
npm install react-aria react-stately - [ ] Review existing examples in React Aria documentation
---
ARIA Roles and Attributes
Ensure proper semantic structure:
- [ ] Component uses appropriate ARIA role (button, dialog, listbox, menu, etc.)
- [ ] All interactive elements have accessible names (
aria-labelor associated<label>) - [ ] Related elements are linked with
aria-labelledby,aria-describedby,aria-controls - [ ] Dynamic content uses
aria-liveregions (polite, assertive, off) - [ ] Hidden elements use
aria-hidden="true"(NOT display: none for SR-only content) - [ ] States are announced:
aria-expanded,aria-selected,aria-checked,aria-pressed - [ ] Invalid inputs have
aria-invalid="true"andaria-errormessage
---
Keyboard Navigation
All interactions must be keyboard accessible:
Focus Management
- [ ] All interactive elements are focusable (no
tabindex="-1"on buttons/links) - [ ] Focus order follows visual order (logical tab sequence)
- [ ] Custom components have appropriate
tabIndex(0 for focusable, -1 for managed) - [ ] Focus indicators are visible (use
useFocusRingfor keyboard-only indicators) - [ ] No keyboard traps (user can always escape with Tab or Escape)
Modal/Overlay Focus
- [ ] Focus is trapped within modal using
<FocusScope contain> - [ ] Focus auto-moves to first focusable element on open (
autoFocus) - [ ] Focus restores to trigger element on close (
restoreFocus) - [ ] Escape key closes modal/overlay
- [ ] Clicking outside dismisses (if
isDismissableis true)
Keyboard Shortcuts
- [ ] Enter/Space - Activates buttons and toggles
- [ ] Arrow keys - Navigate lists, menus, tabs, radio groups
- [ ] Home/End - Jump to first/last item in lists/menus
- [ ] Escape - Closes overlays, cancels actions
- [ ] Tab/Shift+Tab - Moves focus between interactive elements
- [ ] Type-ahead - Single-character search in listboxes/menus (if applicable)
---
Screen Reader Testing
Test with actual screen readers:
NVDA (Windows) + Chrome/Firefox
- [ ] Navigate component with Tab/Shift+Tab
- [ ] Verify all elements are announced correctly
- [ ] Test forms mode (Enter on input fields)
- [ ] Verify
aria-liveannouncements work
VoiceOver (macOS) + Safari
- [ ] Navigate with VO+Right Arrow (browse mode)
- [ ] Test form controls with VO+Space
- [ ] Verify rotor navigation (VO+U)
- [ ] Check landmarks and headings structure
JAWS (Windows) + Chrome/Edge
- [ ] Test virtual cursor navigation
- [ ] Verify forms mode activation
- [ ] Test table navigation (if applicable)
Mobile Screen Readers
- [ ] TalkBack (Android) - Swipe navigation
- [ ] VoiceOver (iOS) - Swipe navigation
- [ ] Test touch gestures (double-tap to activate)
---
Common Patterns Checklist
Button Component
- [ ] Uses
useButtonhook, not div+onClick - [ ] Supports
onPressfor click/tap/Enter/Space - [ ] Has
isPressedstate for visual feedback - [ ] Uses
useFocusRingfor keyboard focus indicator - [ ] Works with
isDisabledprop (no pointer events, aria-disabled)
Dialog/Modal Component
- [ ] Uses
useDialog+useModalOverlayhooks - [ ] Wrapped in
<FocusScope contain restoreFocus autoFocus> - [ ] Has accessible name (
aria-labeloraria-labelledby) - [ ] Escape key closes modal
- [ ] Clicking overlay dismisses (if
isDismissable) - [ ] Uses
useOverlayTriggerStatefor open/close state
Combobox/Autocomplete
- [ ] Uses
useComboBox+useComboBoxState - [ ] Label associated with input
- [ ] Dropdown opens on input focus or button click
- [ ] Arrow keys navigate options
- [ ] Enter selects option
- [ ] Escape closes dropdown
- [ ] Type-ahead filtering works
- [ ] Selected value shown in input
- [ ]
aria-expandedindicates dropdown state
Menu Component
- [ ] Uses
useMenu+useMenuItemhooks - [ ] Trigger button has
aria-haspopup="menu" - [ ] Arrow keys navigate items (roving tabindex)
- [ ] Enter/Space activates menu item
- [ ] Escape closes menu
- [ ] Focus returns to trigger button on close
- [ ] Submenus open with Arrow Right, close with Arrow Left
ListBox Component
- [ ] Uses
useListBox+useOptionhooks - [ ] Supports single/multiple selection modes
- [ ] Arrow keys navigate options
- [ ] Enter/Space toggles selection
- [ ] Home/End jump to first/last item
- [ ] Selected items have
aria-selected="true" - [ ] Type-ahead search works
Form Field Component
- [ ] Uses
useTextField,useCheckbox,useRadioGroup, etc. - [ ] Label visually and programmatically associated
- [ ] Required fields have
aria-required="true" - [ ] Error messages linked with
aria-describedby - [ ] Invalid inputs have
aria-invalid="true" - [ ] Helper text announced to screen readers
---
Testing Strategy
Automated Testing
- [ ] Add
jest-axetests for automatic WCAG violations - [ ] Use
@testing-library/reactfor interaction testing - [ ] Test keyboard navigation programmatically
- [ ] Verify ARIA attributes with queries
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('MyComponent has no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Manual Testing
- [ ] Tab through entire component (forward and backward)
- [ ] Test with keyboard only (no mouse)
- [ ] Use screen reader to verify announcements
- [ ] Test with browser zoom at 200%
- [ ] Verify color contrast with devtools
- [ ] Test in high contrast mode (Windows)
---
Performance Considerations
- [ ] Large lists use virtualization (
@tanstack/react-virtual) - [ ] Focus management doesn't cause layout thrashing
- [ ]
mergePropsused instead of manual object spreading - [ ] State updates debounced/throttled where appropriate (search inputs)
---
Documentation
- [ ] Component usage examples in Storybook/docs
- [ ] Keyboard shortcuts documented
- [ ] ARIA attributes explained
- [ ] Common gotchas and troubleshooting
---
Code Review Checklist
Before merging:
- [ ] No div+onClick buttons (use
useButtoninstead) - [ ] No manual focus management for modals (use
FocusScope) - [ ] All interactive elements keyboard accessible
- [ ] Proper ARIA roles and attributes
- [ ] Focus indicators visible
- [ ] Screen reader tested
- [ ] jest-axe tests pass
- [ ] TypeScript types correct (from
react-ariatypes)
---
Resources
React Aria Examples
Complete working examples of accessible components built with React Aria.
Installation
npm install react-aria react-stately
npm install --save-dev @types/react-aria @types/react-stately---
Example 1: Accessible Dropdown Menu
Full-featured menu with keyboard navigation and ARIA semantics.
// MenuButton.tsx
import { useRef } from 'react';
import { useButton, useMenuTrigger, useMenu, useMenuItem, mergeProps } from 'react-aria';
import { useMenuTriggerState, useTreeState } from 'react-stately';
import { Item } from 'react-stately';
// Menu Trigger Component
export function MenuButton(props: { label: string; onAction: (key: string) => void }) {
const state = useMenuTriggerState({});
const ref = useRef<HTMLButtonElement>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
const { buttonProps } = useButton(menuTriggerProps, ref);
return (
<div className="relative inline-block">
<button
{...buttonProps}
ref={ref}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 flex items-center gap-2"
>
{props.label}
<span aria-hidden="true">▼</span>
</button>
{state.isOpen && (
<MenuPopup
{...menuProps}
autoFocus={state.focusStrategy}
onClose={state.close}
onAction={(key) => {
props.onAction(key as string);
state.close();
}}
/>
)}
</div>
);
}
// Menu Popup Component
function MenuPopup(props: any) {
const ref = useRef<HTMLUListElement>(null);
const state = useTreeState({ ...props, selectionMode: 'none' });
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="absolute top-full left-0 mt-1 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg py-1 z-50"
>
{[...state.collection].map((item) => (
<MenuItem
key={item.key}
item={item}
state={state}
onAction={props.onAction}
onClose={props.onClose}
/>
))}
</ul>
);
}
// Menu Item Component
function MenuItem({ item, state, onAction, onClose }: any) {
const ref = useRef<HTMLLIElement>(null);
const { menuItemProps, isFocused, isPressed } = useMenuItem(
{ key: item.key, onAction, onClose },
state,
ref
);
return (
<li
{...menuItemProps}
ref={ref}
className={`
px-4 py-2 cursor-pointer
${isFocused ? 'bg-blue-50' : ''}
${isPressed ? 'bg-blue-100' : ''}
`}
>
{item.rendered}
</li>
);
}
// Usage
function App() {
return (
<MenuButton
label="Actions"
onAction={(key) => {
if (key === 'edit') console.log('Edit clicked');
if (key === 'delete') console.log('Delete clicked');
}}
>
<Item key="edit">Edit</Item>
<Item key="delete">Delete</Item>
<Item key="duplicate">Duplicate</Item>
</MenuButton>
);
}Features:
- Keyboard navigation with arrow keys
- Enter/Space activates menu items
- Escape closes menu
- Focus returns to trigger button
- Proper ARIA roles and attributes
---
Example 2: Modal Dialog with Focus Trap
Accessible modal with focus management and backdrop dismissal.
// Modal.tsx
import { useRef } from 'react';
import { useDialog, useModalOverlay, useButton, FocusScope, mergeProps } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';
import { AnimatePresence, motion } from 'motion/react';
import { modalBackdrop, modalContent } from '@/lib/animations';
// Modal Component
function Modal({
state,
title,
children,
}: {
state: ReturnType<typeof useOverlayTriggerState>;
title: string;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(
{ isDismissable: true },
state,
ref
);
const { dialogProps, titleProps } = useDialog({ 'aria-label': title }, ref);
return (
<AnimatePresence>
{state.isOpen && (
<>
{/* Backdrop */}
<motion.div
{...underlayProps}
{...modalBackdrop}
className="fixed inset-0 z-50 bg-black/50"
/>
{/* Modal Content */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<FocusScope contain restoreFocus autoFocus>
<motion.div
{...mergeProps(modalProps, dialogProps)}
{...modalContent}
ref={ref}
className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 pointer-events-auto"
>
<h2 {...titleProps} className="text-xl font-semibold mb-4">
{title}
</h2>
{children}
</motion.div>
</FocusScope>
</div>
</>
)}
</AnimatePresence>
);
}
// Usage
function App() {
const state = useOverlayTriggerState({});
return (
<>
<button
onClick={state.open}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Open Modal
</button>
<Modal state={state} title="Confirm Action">
<p className="mb-4 text-gray-700">
Are you sure you want to proceed with this action?
</p>
<div className="flex gap-2 justify-end">
<button
onClick={state.close}
className="px-4 py-2 border rounded hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={() => {
console.log('Confirmed');
state.close();
}}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Confirm
</button>
</div>
</Modal>
</>
);
}Features:
- Focus trapped within modal
- Escape key closes modal
- Click outside dismisses
- Focus returns to trigger button
- Motion animations for smooth entrance/exit
---
Example 3: Combobox with Filtering
Autocomplete input with keyboard navigation and filtering.
// Combobox.tsx
import { useRef } from 'react';
import { useComboBox, useFilter, useButton } from 'react-aria';
import { useComboBoxState } from 'react-stately';
import { Item } from 'react-stately';
interface ComboBoxProps {
label: string;
items: Array<{ id: string; name: string }>;
onSelectionChange?: (key: string | null) => void;
}
export function ComboBox(props: ComboBoxProps) {
const { contains } = useFilter({ sensitivity: 'base' });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const inputRef = useRef<HTMLInputElement>(null);
const listBoxRef = useRef<HTMLUListElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const { inputProps, listBoxProps, labelProps } = useComboBox(
{
...props,
inputRef,
listBoxRef,
buttonRef,
},
state
);
const { buttonProps } = useButton(
{
onPress: () => state.open(),
isDisabled: state.isDisabled,
},
buttonRef
);
return (
<div className="relative inline-flex flex-col gap-1">
<label {...labelProps} className="font-medium text-sm">
{props.label}
</label>
<div className="flex">
<input
{...inputProps}
ref={inputRef}
className="flex-1 border rounded-l px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
{...buttonProps}
ref={buttonRef}
className="border border-l-0 rounded-r px-3 bg-gray-50 hover:bg-gray-100"
>
<span aria-hidden="true">▼</span>
</button>
</div>
{state.isOpen && (
<ul
{...listBoxProps}
ref={listBoxRef}
className="absolute top-full mt-1 w-full border bg-white rounded shadow-lg max-h-60 overflow-auto z-10"
>
{[...state.collection].map((item) => (
<ComboBoxItem key={item.key} item={item} state={state} />
))}
</ul>
)}
</div>
);
}
function ComboBoxItem({ item, state }: any) {
const ref = useRef<HTMLLIElement>(null);
const { optionProps, isSelected, isFocused } = useOption(
{ key: item.key },
state,
ref
);
return (
<li
{...optionProps}
ref={ref}
className={`
px-3 py-2 cursor-pointer
${isFocused ? 'bg-blue-50' : ''}
${isSelected ? 'bg-blue-100 font-semibold' : ''}
`}
>
{item.rendered}
</li>
);
}
// Usage
function App() {
const items = [
{ id: '1', name: 'Apple' },
{ id: '2', name: 'Banana' },
{ id: '3', name: 'Cherry' },
{ id: '4', name: 'Date' },
];
return (
<ComboBox
label="Select Fruit"
items={items}
onSelectionChange={(key) => console.log('Selected:', key)}
>
{(item) => <Item key={item.id}>{item.name}</Item>}
</ComboBox>
);
}Features:
- Type-ahead filtering with
useFilter - Keyboard navigation (arrow keys, Enter, Escape)
- Accessible name via label
- Button to open dropdown
- Selected value shown in input
---
Example 4: Tooltip Component
Accessible tooltip with hover/focus triggers.
// Tooltip.tsx
import { useRef } from 'react';
import { useTooltip, useTooltipTrigger } from 'react-aria';
import { useTooltipTriggerState } from 'react-stately';
import { AnimatePresence, motion } from 'motion/react';
import { fadeIn } from '@/lib/animations';
interface TooltipProps {
children: React.ReactElement;
content: string;
delay?: number;
}
export function Tooltip({ children, content, delay = 0 }: TooltipProps) {
const state = useTooltipTriggerState({ delay });
const ref = useRef<HTMLButtonElement>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
{ isDisabled: false },
state,
ref
);
return (
<>
{/* Trigger element */}
<span {...triggerProps} ref={ref}>
{children}
</span>
{/* Tooltip popup */}
<AnimatePresence>
{state.isOpen && (
<TooltipPopup {...tooltipProps}>{content}</TooltipPopup>
)}
</AnimatePresence>
</>
);
}
function TooltipPopup(props: any) {
const ref = useRef<HTMLDivElement>(null);
const { tooltipProps } = useTooltip(props, ref);
return (
<motion.div
{...tooltipProps}
{...fadeIn}
ref={ref}
className="absolute z-50 px-3 py-1.5 bg-gray-900 text-white text-sm rounded shadow-lg"
style={{
top: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)',
}}
>
{props.children}
</motion.div>
);
}
// Usage
function App() {
return (
<div className="p-8">
<Tooltip content="This is a helpful tooltip">
<button className="px-4 py-2 bg-blue-500 text-white rounded">
Hover Me
</button>
</Tooltip>
</div>
);
}Features:
- Shows on hover and focus
- Accessible via
aria-describedby - Delay before showing (configurable)
- Motion animation for smooth entrance
---
Testing Example
Using @testing-library/react and jest-axe:
// MenuButton.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe, toHaveNoViolations } from 'jest-axe';
import { MenuButton } from './MenuButton';
import { Item } from 'react-stately';
expect.extend(toHaveNoViolations);
describe('MenuButton', () => {
test('has no accessibility violations', async () => {
const { container } = render(
<MenuButton label="Actions" onAction={() => {}}>
<Item key="edit">Edit</Item>
<Item key="delete">Delete</Item>
</MenuButton>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('opens menu on click', async () => {
const user = userEvent.setup();
render(
<MenuButton label="Actions" onAction={() => {}}>
<Item key="edit">Edit</Item>
</MenuButton>
);
const button = screen.getByRole('button', { name: /actions/i });
await user.click(button);
expect(screen.getByRole('menu')).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /edit/i })).toBeInTheDocument();
});
test('navigates with arrow keys', async () => {
const user = userEvent.setup();
render(
<MenuButton label="Actions" onAction={() => {}}>
<Item key="edit">Edit</Item>
<Item key="delete">Delete</Item>
</MenuButton>
);
const button = screen.getByRole('button', { name: /actions/i });
await user.click(button);
const editItem = screen.getByRole('menuitem', { name: /edit/i });
expect(editItem).toHaveFocus();
await user.keyboard('{ArrowDown}');
const deleteItem = screen.getByRole('menuitem', { name: /delete/i });
expect(deleteItem).toHaveFocus();
});
test('closes menu on escape', async () => {
const user = userEvent.setup();
render(
<MenuButton label="Actions" onAction={() => {}}>
<Item key="edit">Edit</Item>
</MenuButton>
);
const button = screen.getByRole('button', { name: /actions/i });
await user.click(button);
expect(screen.getByRole('menu')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});
});---
Resources
React Aria Hooks Reference
Comprehensive API reference for Adobe's React Aria hooks with React 19 patterns.
Installation
npm install react-aria react-statelyPeer Dependencies:
- React 19.x
- react-dom 19.x
---
Button Hooks
useButton
Creates accessible buttons with keyboard, pointer, and focus support.
import { useRef } from 'react';
import { useButton, useFocusRing, mergeProps } from 'react-aria';
import type { AriaButtonProps } from 'react-aria';
function Button(props: AriaButtonProps) {
const ref = useRef<HTMLButtonElement>(null);
const { buttonProps, isPressed } = useButton(props, ref);
const { focusProps, isFocusVisible } = useFocusRing();
return (
<button
{...mergeProps(buttonProps, focusProps)}
ref={ref}
className={`
px-4 py-2 rounded
${isPressed ? 'scale-95' : ''}
${isFocusVisible ? 'ring-2 ring-blue-500' : ''}
`}
>
{props.children}
</button>
);
}Key Props:
onPress- Triggered on click or Enter/SpaceisDisabled- Disables interactiontype- Button type (button, submit, reset)elementType- Custom element type (default: button)
Returns:
buttonProps- Spread on button elementisPressed- Current press state
---
useToggleButton
Toggle buttons with on/off states (like icon toggles).
import { useRef } from 'react';
import { useToggleButton } from 'react-aria';
import { useToggleState } from 'react-stately';
function ToggleButton(props) {
const state = useToggleState(props);
const ref = useRef(null);
const { buttonProps, isPressed } = useToggleButton(props, state, ref);
return (
<button
{...buttonProps}
ref={ref}
className={state.isSelected ? 'bg-blue-500 text-white' : 'bg-gray-200'}
>
{props.children}
</button>
);
}
// Usage
<ToggleButton onChange={(isSelected) => console.log(isSelected)}>
Toggle Me
</ToggleButton>---
Selection Hooks
useListBox
Accessible list with single/multiple selection, keyboard navigation, and typeahead.
import { useRef } from 'react';
import { useListBox, useOption } from 'react-aria';
import { useListState } from 'react-stately';
import { Item } from 'react-stately';
function ListBox(props) {
const state = useListState(props);
const ref = useRef(null);
const { listBoxProps } = useListBox(props, state, ref);
return (
<ul {...listBoxProps} ref={ref} className="border rounded">
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
);
}
function Option({ item, state }) {
const ref = useRef(null);
const { optionProps, isSelected, isFocused } = useOption(
{ key: item.key },
state,
ref
);
return (
<li
{...optionProps}
ref={ref}
className={`
px-3 py-2 cursor-pointer
${isSelected ? 'bg-blue-500 text-white' : ''}
${isFocused ? 'bg-gray-100' : ''}
`}
>
{item.rendered}
</li>
);
}
// Usage
<ListBox selectionMode="multiple">
<Item key="red">Red</Item>
<Item key="green">Green</Item>
<Item key="blue">Blue</Item>
</ListBox>Key Props:
selectionMode- "single", "multiple", "none"disallowEmptySelection- Prevent deselecting last itemonSelectionChange- Callback with Set of keys
---
useSelect
Dropdown select with keyboard navigation and proper ARIA semantics.
import { useRef } from 'react';
import { HiddenSelect, useSelect } from 'react-aria';
import { useSelectState } from 'react-stately';
import { Item } from 'react-stately';
function Select(props) {
const state = useSelectState(props);
const ref = useRef(null);
const { triggerProps, valueProps, menuProps } = useSelect(props, state, ref);
return (
<div className="relative inline-flex flex-col">
<HiddenSelect state={state} triggerRef={ref} label={props.label} />
<button
{...triggerProps}
ref={ref}
className="px-4 py-2 border rounded flex justify-between items-center"
>
<span {...valueProps}>
{state.selectedItem?.rendered || 'Select...'}
</span>
<span aria-hidden="true">▼</span>
</button>
{state.isOpen && (
<ListBoxPopup {...menuProps} state={state} />
)}
</div>
);
}---
Menu Hooks
useMenu / useMenuItem
Dropdown menus with keyboard navigation and submenus.
import { useRef } from 'react';
import { useMenu, useMenuItem, useMenuTrigger } from 'react-aria';
import { useMenuTriggerState } from 'react-stately';
function MenuButton(props) {
const state = useMenuTriggerState(props);
const ref = useRef(null);
const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
return (
<div className="relative">
<button {...menuTriggerProps} ref={ref}>
Actions ▼
</button>
{state.isOpen && (
<Menu {...menuProps} onAction={props.onAction} onClose={state.close} />
)}
</div>
);
}
function Menu(props) {
const ref = useRef(null);
const state = useTreeState(props);
const { menuProps } = useMenu(props, state, ref);
return (
<ul {...menuProps} ref={ref} className="absolute mt-1 border bg-white rounded shadow">
{[...state.collection].map((item) => (
<MenuItem key={item.key} item={item} state={state} onAction={props.onAction} onClose={props.onClose} />
))}
</ul>
);
}
function MenuItem({ item, state, onAction, onClose }) {
const ref = useRef(null);
const { menuItemProps } = useMenuItem(
{ key: item.key, onAction, onClose },
state,
ref
);
return (
<li {...menuItemProps} ref={ref} className="px-4 py-2 hover:bg-gray-100 cursor-pointer">
{item.rendered}
</li>
);
}---
Overlay Hooks
useDialog
Modal dialogs with proper ARIA semantics.
import { useRef } from 'react';
import { useDialog } from 'react-aria';
function Dialog({ title, children, ...props }) {
const ref = useRef(null);
const { dialogProps, titleProps } = useDialog(props, ref);
return (
<div {...dialogProps} ref={ref} className="bg-white rounded-lg p-6">
<h2 {...titleProps} className="text-xl font-semibold mb-4">
{title}
</h2>
{children}
</div>
);
}---
useModalOverlay
Full-screen overlay with focus management and dismissal.
import { useRef } from 'react';
import { useModalOverlay, FocusScope } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';
function Modal({ state, title, children }) {
const ref = useRef(null);
const { modalProps, underlayProps } = useModalOverlay(
{ isDismissable: true },
state,
ref
);
return (
<div
{...underlayProps}
className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"
>
<FocusScope contain restoreFocus autoFocus>
<div {...modalProps} ref={ref}>
<Dialog title={title}>{children}</Dialog>
</div>
</FocusScope>
</div>
);
}
// Usage with state management
function App() {
const state = useOverlayTriggerState({});
return (
<>
<button onClick={state.open}>Open Modal</button>
{state.isOpen && (
<Modal state={state} title="Example">
<p>Modal content here</p>
<button onClick={state.close}>Close</button>
</Modal>
)}
</>
);
}---
useTooltip / useTooltipTrigger
Accessible tooltips with hover/focus triggers.
import { useRef } from 'react';
import { useTooltip, useTooltipTrigger } from 'react-aria';
import { useTooltipTriggerState } from 'react-stately';
function TooltipTrigger({ children, tooltip, ...props }) {
const state = useTooltipTriggerState(props);
const ref = useRef(null);
const { triggerProps, tooltipProps } = useTooltipTrigger({}, state, ref);
return (
<>
<button {...triggerProps} ref={ref}>
{children}
</button>
{state.isOpen && (
<Tooltip {...tooltipProps}>{tooltip}</Tooltip>
)}
</>
);
}
function Tooltip(props) {
const ref = useRef(null);
const { tooltipProps } = useTooltip(props, ref);
return (
<div
{...tooltipProps}
ref={ref}
className="absolute z-50 px-2 py-1 bg-gray-900 text-white text-sm rounded"
>
{props.children}
</div>
);
}---
usePopover
Non-modal popovers for dropdowns, color pickers, etc.
import { useRef } from 'react';
import { usePopover, DismissButton, Overlay } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';
function Popover({ state, children, ...props }) {
const popoverRef = useRef(null);
const { popoverProps, underlayProps } = usePopover(
{
...props,
popoverRef,
},
state
);
return (
<Overlay>
<div {...underlayProps} className="fixed inset-0" />
<div
{...popoverProps}
ref={popoverRef}
className="absolute z-10 bg-white border rounded shadow-lg p-4"
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
}
// Usage
function App() {
const state = useOverlayTriggerState({});
return (
<>
<button onClick={state.open}>Open Popover</button>
{state.isOpen && (
<Popover state={state}>
<p>Popover content</p>
</Popover>
)}
</>
);
}---
Form Hooks
useTextField
Accessible text inputs with label association.
import { useRef } from 'react';
import { useTextField } from 'react-aria';
function TextField(props) {
const ref = useRef(null);
const { labelProps, inputProps, descriptionProps, errorMessageProps } = useTextField(props, ref);
return (
<div className="flex flex-col gap-1">
<label {...labelProps} className="font-medium">
{props.label}
</label>
<input
{...inputProps}
ref={ref}
className="border rounded px-3 py-2"
/>
{props.description && (
<div {...descriptionProps} className="text-sm text-gray-600">
{props.description}
</div>
)}
{props.errorMessage && (
<div {...errorMessageProps} className="text-sm text-red-600">
{props.errorMessage}
</div>
)}
</div>
);
}---
Focus Management
FocusScope
Manages focus containment and restoration for overlays.
Props:
contain- Trap focus within childrenrestoreFocus- Restore focus to trigger on unmountautoFocus- Auto-focus first focusable element
import { FocusScope } from 'react-aria';
<FocusScope contain restoreFocus autoFocus>
<div role="dialog">
<button>First focusable</button>
<button>Second focusable</button>
</div>
</FocusScope>---
useFocusRing
Detects keyboard focus for styling focus indicators.
import { useFocusRing } from 'react-aria';
function Component() {
const { focusProps, isFocusVisible } = useFocusRing();
return (
<button
{...focusProps}
className={isFocusVisible ? 'ring-2 ring-blue-500' : ''}
>
Focusable
</button>
);
}---
Utility Functions
mergeProps
Safely merges multiple prop objects (handles event handlers, className, etc.).
import { mergeProps } from 'react-aria';
const combinedProps = mergeProps(
{ onClick: handler1, className: 'base' },
{ onClick: handler2, className: 'extra' }
);
// Result: onClick calls both handlers, className="base extra"---
Integration with react-stately
React Aria hooks require state management from react-stately:
| Hook | State Hook |
|---|---|
| useSelect | useSelectState |
| useListBox | useListState |
| useComboBox | useComboBoxState |
| useMenu | useTreeState |
| useModalOverlay | useOverlayTriggerState |
import { useListBox } from 'react-aria';
import { useListState } from 'react-stately';
const state = useListState(props);
const { listBoxProps } = useListBox(props, state, ref);---
TypeScript Support
All hooks include TypeScript types from @types/react-aria:
import type { AriaButtonProps, AriaDialogProps } from 'react-aria';
function MyButton(props: AriaButtonProps) {
// Full type safety
}---
Resources
/**
* Accessible Component Templates
*
* Ready-to-use templates for common React Aria patterns.
* Copy and customize for your application.
*/
import { useRef, type ReactNode } from 'react';
import {
useButton,
useDialog,
useModalOverlay,
useMenu,
useMenuItem,
useMenuTrigger,
useFocusRing,
mergeProps,
FocusScope,
type AriaButtonProps,
} from 'react-aria';
import {
useOverlayTriggerState,
useMenuTriggerState,
useTreeState,
type OverlayTriggerState,
} from 'react-stately';
import { Item } from 'react-stately';
import { AnimatePresence, motion } from 'motion/react';
// ============================================================================
// TEMPLATE 1: Button Component with Focus Ring
// ============================================================================
interface ButtonProps extends AriaButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
children: ReactNode;
}
export function Button({ variant = 'primary', children, ...props }: ButtonProps) {
const ref = useRef<HTMLButtonElement>(null);
const { buttonProps, isPressed } = useButton(props, ref);
const { focusProps, isFocusVisible } = useFocusRing();
const variantClasses = {
primary: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
danger: 'bg-red-500 text-white hover:bg-red-600',
};
return (
<button
{...mergeProps(buttonProps, focusProps)}
ref={ref}
className={`
px-4 py-2 rounded font-medium transition-all
${variantClasses[variant]}
${isPressed ? 'scale-95' : ''}
${isFocusVisible ? 'ring-2 ring-offset-2 ring-blue-500' : ''}
disabled:opacity-50 disabled:cursor-not-allowed
`}
>
{children}
</button>
);
}
// Usage:
// <Button onPress={() => console.log('Clicked')}>Click Me</Button>
// <Button variant="danger" onPress={() => console.log('Delete')}>Delete</Button>
// ============================================================================
// TEMPLATE 2: Dialog Component with Overlay
// ============================================================================
interface DialogProps {
state: OverlayTriggerState;
title: string;
children: ReactNode;
isDismissable?: boolean;
}
export function Dialog({ state, title, children, isDismissable = true }: DialogProps) {
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(
{ isDismissable },
state,
ref
);
const { dialogProps, titleProps } = useDialog({ 'aria-label': title }, ref);
return (
<AnimatePresence>
{state.isOpen && (
<>
{/* Backdrop */}
<motion.div
{...underlayProps}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-50 bg-black/50"
/>
{/* Dialog Container */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<FocusScope contain restoreFocus autoFocus>
<motion.div
{...mergeProps(modalProps, dialogProps)}
ref={ref}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 pointer-events-auto"
>
<h2
{...titleProps}
className="text-xl font-semibold mb-4 text-gray-900"
>
{title}
</h2>
{children}
</motion.div>
</FocusScope>
</div>
</>
)}
</AnimatePresence>
);
}
// Usage:
// function App() {
// const state = useOverlayTriggerState({});
// return (
// <>
// <Button onPress={state.open}>Open Dialog</Button>
// <Dialog state={state} title="Confirm Action">
// <p>Are you sure?</p>
// <div className="flex gap-2 mt-4">
// <Button variant="secondary" onPress={state.close}>Cancel</Button>
// <Button onPress={state.close}>Confirm</Button>
// </div>
// </Dialog>
// </>
// );
// }
// ============================================================================
// TEMPLATE 3: Menu Component with Trigger
// ============================================================================
interface MenuButtonProps {
label: string;
children: ReactNode;
onAction: (key: string | number) => void;
}
export function MenuButton({ label, children, onAction }: MenuButtonProps) {
const state = useMenuTriggerState({});
const ref = useRef<HTMLButtonElement>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
const { buttonProps } = useButton(menuTriggerProps, ref);
return (
<div className="relative inline-block">
<button
{...buttonProps}
ref={ref}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 flex items-center gap-2"
>
{label}
<span aria-hidden="true">▼</span>
</button>
{state.isOpen && (
<MenuPopup
{...menuProps}
autoFocus={state.focusStrategy}
onClose={state.close}
onAction={(key) => {
onAction(key);
state.close();
}}
>
{children}
</MenuPopup>
)}
</div>
);
}
function MenuPopup(props: any) {
const ref = useRef<HTMLUListElement>(null);
const state = useTreeState({ ...props, selectionMode: 'none' });
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="absolute top-full left-0 mt-1 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg py-1 z-50"
>
{[...state.collection].map((item) => (
<MenuItem
key={item.key}
item={item}
state={state}
onAction={props.onAction}
onClose={props.onClose}
/>
))}
</ul>
);
}
function MenuItem({ item, state, onAction, onClose }: any) {
const ref = useRef<HTMLLIElement>(null);
const { menuItemProps, isFocused, isPressed } = useMenuItem(
{ key: item.key, onAction, onClose },
state,
ref
);
return (
<li
{...menuItemProps}
ref={ref}
className={`
px-4 py-2 cursor-pointer
${isFocused ? 'bg-blue-50' : ''}
${isPressed ? 'bg-blue-100' : ''}
`}
>
{item.rendered}
</li>
);
}
// Usage:
// <MenuButton
// label="Actions"
// onAction={(key) => {
// if (key === 'edit') console.log('Edit');
// if (key === 'delete') console.log('Delete');
// }}
// >
// <Item key="edit">Edit</Item>
// <Item key="delete">Delete</Item>
// <Item key="duplicate">Duplicate</Item>
// </MenuButton>
// ============================================================================
// TEMPLATE 4: Focus Ring Utility Hook
// ============================================================================
/**
* Reusable hook for adding keyboard-only focus indicators to any element.
*
* @example
* function CustomComponent() {
* const { focusProps, isFocusVisible } = useFocusRingStyles();
* return (
* <div
* {...focusProps}
* className={isFocusVisible ? 'ring-2 ring-blue-500' : ''}
* >
* Focusable content
* </div>
* );
* }
*/
export function useFocusRingStyles() {
const { focusProps, isFocusVisible } = useFocusRing();
return {
focusProps,
isFocusVisible,
focusClassName: isFocusVisible ? 'ring-2 ring-offset-2 ring-blue-500' : '',
};
}
// ============================================================================
// COMMON PATTERNS
// ============================================================================
/**
* Pattern: Loading Button with Disabled State
* Shows spinner and disables interaction during async operations.
*/
export function LoadingButton({
isLoading,
children,
...props
}: ButtonProps & { isLoading?: boolean }) {
return (
<Button {...props} isDisabled={props.isDisabled || isLoading}>
{isLoading ? (
<span className="flex items-center gap-2">
<span className="animate-spin">⏳</span>
Loading...
</span>
) : (
children
)}
</Button>
);
}
/**
* Pattern: Confirmation Dialog
* Pre-built dialog for confirming dangerous actions.
*/
export function ConfirmDialog({
state,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
onConfirm,
}: {
state: OverlayTriggerState;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
}) {
return (
<Dialog state={state} title={title}>
<p className="text-gray-700 mb-6">{message}</p>
<div className="flex gap-2 justify-end">
<Button variant="secondary" onPress={state.close}>
{cancelLabel}
</Button>
<Button
variant="danger"
onPress={() => {
onConfirm();
state.close();
}}
>
{confirmLabel}
</Button>
</div>
</Dialog>
);
}
// Usage:
// function App() {
// const state = useOverlayTriggerState({});
// return (
// <>
// <Button variant="danger" onPress={state.open}>Delete Item</Button>
// <ConfirmDialog
// state={state}
// title="Delete Item"
// message="Are you sure you want to delete this item? This action cannot be undone."
// onConfirm={() => console.log('Item deleted')}
// />
// </>
// );
// }
// ============================================================================
// ACCESSIBILITY UTILITIES
// ============================================================================
/**
* Screen Reader Only Text
* Visually hidden but announced by screen readers.
*/
export function ScreenReaderOnly({ children }: { children: ReactNode }) {
return (
<span className="sr-only">
{children}
</span>
);
}
/**
* Live Region for Dynamic Announcements
* Use for status updates, error messages, success notifications.
*/
export function LiveRegion({
children,
priority = 'polite',
}: {
children: ReactNode;
priority?: 'polite' | 'assertive';
}) {
return (
<div
role="status"
aria-live={priority}
aria-atomic="true"
className="sr-only"
>
{children}
</div>
);
}
// Usage:
// <LiveRegion priority="polite">Item added to cart</LiveRegion>
// <LiveRegion priority="assertive">Error: Form submission failed</LiveRegion>
// ============================================================================
// TYPESCRIPT TYPES FOR COMMON PATTERNS
// ============================================================================
export type ButtonVariant = 'primary' | 'secondary' | 'danger';
export interface AccessibleComponentProps {
/** Accessible label for screen readers */
'aria-label'?: string;
/** ID of element that labels this component */
'aria-labelledby'?: string;
/** ID of element that describes this component */
'aria-describedby'?: string;
}
export interface InteractiveProps extends AccessibleComponentProps {
/** Callback when component is pressed/clicked */
onPress?: () => void;
/** Whether component is disabled */
isDisabled?: boolean;
}