Chapter 7
Accessible components with React Aria
This chapter introduces React Aria and uses it to build three interactive components: Button, IconButton, and Input.
Layout primitives handle spatial arrangement. UI components handle interaction. This chapter introduces React Aria and uses it to build three interactive components: Button, IconButton, and Input. The pattern you learn here applies to every interactive component in the library.
Installing React Aria
npm install react-aria react-stately @react-aria/utils react-aria-components
npm install @iconify/react
react-aria provides the hooks that manage keyboard interaction, focus behavior, and ARIA attributes. react-stately provides the state management hooks that React Aria depends on, such as tracking whether a select is open or which item is highlighted. @react-aria/utils supplies the ref helper every one of these components needs.
react-aria-components is the higher-level sibling: prebuilt accessible components rather than hooks. This guide builds from hooks, because assembling a component from useButton is how you learn what the hook is doing for you. But some patterns are not worth hand-rolling, and the Menu family in the companion repository is built on react-aria-components for exactly that reason. Installing it now means the dependency is in place when you reach it.
@iconify/react renders the icons. It loads icon data on demand by name, so iconBefore="mdi:plus" fetches only that glyph instead of bundling a set. The Button below takes icons as name strings rather than React nodes, which is what makes that work.
How React Aria hooks work
React Aria hooks return props objects that you spread onto your DOM elements. The hook manages the complex interaction logic, and you manage the rendering. Here’s the concept, simplified:
const { buttonProps } = useButton({ onPress, isDisabled }, ref)
return (
<button {...buttonProps} ref={ref}>
Click me
</button>
)
The buttonProps object includes onClick, onKeyDown, and other attributes that the hook calculates based on the state you pass in. Which attributes appear depends on the element type: for a real <button> the hook returns the native disabled attribute, and it falls back to role, tabIndex, and aria-disabled only when you render something that is not a button. That is why the stylesheet below targets :disabled rather than [aria-disabled]. You don’t manage these attributes manually. React Aria handles the edge cases (pointer type normalization, touch delay cancellation, keyboard activation) so you don’t have to discover them in production.
Building Button
Button and IconButton both have a loading state, and they share identical logic for it: disable the button via useButton, and set aria-busy on the element. Extract that into a shared useLoadingButton hook so neither component duplicates the behavior.
// src/hooks/useLoadingButton.ts
import { useButton } from 'react-aria'
import type { AriaButtonProps } from 'react-aria'
import type { RefObject } from 'react'
export function useLoadingButton(
ariaProps: AriaButtonProps,
isLoading: boolean,
ref: RefObject<HTMLButtonElement | null>,
) {
const { buttonProps } = useButton(
{ ...ariaProps, isDisabled: ariaProps.isDisabled || isLoading },
ref,
)
return {
buttonProps: {
...buttonProps,
'aria-busy': isLoading || undefined,
},
}
}
useObjectRef from @react-aria/utils converts a forwarded ref (which may be a callback ref or null) into the stable RefObject that React Aria hooks require. Any component that uses React.forwardRef alongside a React Aria hook needs this utility.
Note the ref parameter type: RefObject<HTMLButtonElement | null>, not RefObject<HTMLButtonElement>. In React 19’s type definitions RefObject<T>.current is exactly T, so a ref that starts empty is a RefObject<T | null>, and that is the shape useObjectRef returns. Writing the non-null version produces a type error at every call site that is confusing to read, because the error points at the caller rather than the signature.
// src/components/Button/Button.tsx
import React from 'react'
import { useObjectRef } from '@react-aria/utils'
import type { AriaButtonProps } from 'react-aria'
import { cn } from '@/utils/cn'
import { RudiIcon } from '@/components/Icon/Icon'
import { useLoadingButton } from '@/hooks/useLoadingButton'
import './button.css'
export interface RudiButtonProps extends AriaButtonProps {
variant?: 'primary' | 'secondary' | 'destructive' | 'ghost'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
/** Iconify icon name rendered before the label, e.g. 'mdi:plus'. */
iconBefore?: string
/** Iconify icon name rendered after the label, e.g. 'mdi:arrow-right'. */
iconAfter?: string
className?: string
children: React.ReactNode
}
export const RudiButton = React.forwardRef<HTMLButtonElement, RudiButtonProps>(
function RudiButton(
{
variant = 'primary',
size = 'md',
isLoading = false,
iconBefore,
iconAfter,
className,
children,
...ariaProps
},
forwardedRef,
) {
const ref = useObjectRef(forwardedRef)
const { buttonProps } = useLoadingButton(ariaProps, isLoading, ref)
return (
<button
{...buttonProps}
ref={ref}
className={cn(
'rudi-button',
`rudi-button--${variant}`,
`rudi-button--${size}`,
isLoading && 'rudi-button--loading',
className,
)}
>
{isLoading ? (
<>
<span className="rudi-button__spinner" role="img" aria-hidden="true" />
<span>{children}</span>
</>
) : (
<>
{iconBefore && (
<RudiIcon icon={iconBefore} size={size} className="rudi-button__icon" />
)}
<span>{children}</span>
{iconAfter && (
<RudiIcon icon={iconAfter} size={size} className="rudi-button__icon" />
)}
</>
)}
</button>
)
},
)
Key points in this implementation:
useButton normalizes press events. The hook provides onPress instead of onClick. The difference matters. onClick fires on mouse click and, for a native button, on Enter and Space, but its behavior varies across pointer types and browsers. onPress fires consistently for mouse, touch, and keyboard, with proper handling for touch delay cancellation and preventing double-fires on Android.
Loading state disables the button. The useLoadingButton hook passes isDisabled: true to useButton when loading, so onPress won’t fire. It also sets aria-busy="true", because screen readers announce the two states differently: disabled means unavailable, aria-busy means processing. The disabling itself reaches the DOM as the native disabled attribute, since useButton is rendering a real <button>.
forwardRef exposes the underlying element. Wrapping the component in React.forwardRef lets consumers pass a ref and call imperative methods like .focus() or .blur(). useObjectRef from @react-aria/utils converts the forwarded ref (which may be a callback ref or null) into the stable RefObject that React Aria hooks require.
Icons are name strings, not nodes. iconBefore="mdi:plus" is less flexible than iconBefore={<PlusIcon />}, and that is deliberate. A string keeps the icon set out of the consumer’s bundle until it is used, keeps the prop serializable so Storybook can offer it as a text control, and stops consumers from passing arbitrary markup into a slot sized for a glyph. If you need a genuinely custom node, compose it into children.
The label is always wrapped in a <span>. Even with no icons, children goes inside an element rather than sitting as a bare text node. That gives the flexbox layout a real item to align and size, so an icon and its label share a baseline instead of the text collapsing against the icon box. It also gives tests a stable handle on the label independent of the icons.
The spinner carries role="img". It is an empty <span> styled into a ring by CSS and marked aria-hidden="true", so screen readers skip it entirely. The role is there for tests: chapter 11 finds the spinner with getByRole('img', { hidden: true }). The aria-busy attribute on the button exposes the loading state to assistive technology that reads it, but support is uneven. For an announcement every screen reader makes, pair it with a visually hidden live region that says something like “Saving…”.
The focus ring. React Aria handles focus management, but the visible focus indicator is your responsibility in CSS. The component’s CSS uses :focus-visible rather than :focus:
.rudi-button:focus-visible {
outline: 2px solid var(--rudi-component-button-focus-outline-color);
outline-offset: 2px;
}
:focus-visible shows the focus ring only for keyboard navigation, not for mouse clicks. Mouse users don’t need a focus indicator because they can see where they’re clicking. Keyboard users need the ring to track their position.
Button CSS
Create src/components/Button/button.css next to the component. This is the first stylesheet where the component token tier from chapter 3 earns its keep, so read the var() names carefully: every color, size, and spacing value is a component.button.* token, not a semantic or global one. Motion is the exception, and reads the global motion tokens.
.rudi-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--rudi-component-button-gap);
font-weight: var(--rudi-component-button-font-weight);
border-radius: var(--rudi-component-button-radius);
cursor: pointer;
transition:
background-color var(--rudi-motion-duration-base) var(--rudi-motion-easing-standard),
color var(--rudi-motion-duration-base) var(--rudi-motion-easing-standard);
border: 1px solid transparent;
}
.rudi-button:focus-visible {
outline: 2px solid var(--rudi-component-button-focus-outline-color);
outline-offset: 2px;
}
.rudi-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Sizes */
.rudi-button--sm {
padding: var(--rudi-component-button-padding-y-sm)
var(--rudi-component-button-padding-x-sm);
font-size: var(--rudi-component-button-font-size-sm);
}
.rudi-button--md {
padding: var(--rudi-component-button-padding-y-md)
var(--rudi-component-button-padding-x-md);
font-size: var(--rudi-component-button-font-size-md);
}
.rudi-button--lg {
padding: var(--rudi-component-button-padding-y-lg)
var(--rudi-component-button-padding-x-lg);
font-size: var(--rudi-component-button-font-size-lg);
}
/* Variants */
.rudi-button--primary {
background-color: var(--rudi-component-button-primary-bg);
color: var(--rudi-component-button-primary-text);
}
.rudi-button--primary:hover:not(:disabled) {
background-color: var(--rudi-component-button-primary-hover-bg);
}
.rudi-button--secondary {
background-color: var(--rudi-component-button-secondary-bg);
color: var(--rudi-component-button-secondary-text);
border-color: var(--rudi-component-button-secondary-border);
}
.rudi-button--secondary:hover:not(:disabled) {
background-color: var(--rudi-component-button-secondary-hover-bg);
}
.rudi-button--destructive {
background-color: var(--rudi-component-button-destructive-bg);
color: var(--rudi-component-button-destructive-text);
}
.rudi-button--ghost {
background-color: transparent;
color: var(--rudi-component-button-ghost-text);
}
.rudi-button--ghost:hover:not(:disabled) {
background-color: var(--rudi-component-button-ghost-hover-bg);
}
/* Icons */
.rudi-button .rudi-button__icon {
color: inherit;
}
/* Loading */
.rudi-button--loading {
position: relative;
}
.rudi-button__spinner {
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: rudi-spin var(--rudi-motion-duration-spinner) linear infinite;
}
@keyframes rudi-spin {
to {
transform: rotate(360deg);
}
}
/* A continuous rotation is a vestibular trigger, and WCAG 2.3.3 asks that
motion be avoidable. Both rules below collapse to a static, instant state
when the reader has asked their OS for reduced motion. */
@media (prefers-reduced-motion: reduce) {
.rudi-button {
transition: none;
}
.rudi-button__spinner {
animation: none;
border-right-color: currentColor;
opacity: 0.6;
}
}
No color, size, or spacing is hardcoded, and none of them reaches past the component tier to a semantic or global token. var(--rudi-component-button-primary-bg) resolves to {color.brand.primary}, which resolves to {color.neutral.900}, which resolves to a hex value. Three hops, all visible in dev tools.
The indirection buys two things. Someone rebranding the library changes color.brand.primary once and every component follows. Someone who wants a different button specifically, without disturbing anything else that uses the brand color, changes component.button.primary-bg and nothing else moves. Reading the semantic token directly from the stylesheet would give you the first without the second.
The transition reads motion tokens rather than a literal 150ms. That is what lets a theme slow the whole system down, or flatten it, without editing every stylesheet.
Add the matching entries to tokens/components.json under component.button before you build: gap, font-weight, radius, focus-outline-color, the padding-{x,y}-{sm,md,lg} and font-size-{sm,md,lg} sets, and the background, text, hover, and border values for each of the four variants.
Building Input
// src/components/Input/Input.tsx
import React from 'react'
import { useTextField } from 'react-aria'
import { useObjectRef } from '@react-aria/utils'
import { cn } from '@/utils/cn'
import './input.css'
export interface RudiInputProps {
label: string
type?: 'text' | 'email' | 'password' | 'url' | 'tel' | 'search' | 'number'
placeholder?: string
description?: string
errorMessage?: string
isRequired?: boolean
isDisabled?: boolean
value?: string
defaultValue?: string
onChange?: (value: string) => void
className?: string
}
export const RudiInput = React.forwardRef<HTMLInputElement, RudiInputProps>(
function RudiInput(
{
label,
type = 'text',
placeholder,
description,
errorMessage,
isRequired = false,
isDisabled = false,
value,
defaultValue,
onChange,
className,
},
forwardedRef,
) {
const ref = useObjectRef(forwardedRef)
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
isInvalid,
} = useTextField(
{
label,
type,
placeholder,
description,
errorMessage,
isRequired,
isDisabled,
value,
defaultValue,
onChange,
isInvalid: !!errorMessage,
},
ref,
)
return (
<div className={cn('rudi-input', className)}>
<label {...labelProps} className="rudi-input__label">
{label}
{isRequired && (
<span className="rudi-input__required" aria-hidden="true">
{' '}
*
</span>
)}
</label>
<input
{...inputProps}
ref={ref}
className={cn(
'rudi-input__field',
isInvalid && 'rudi-input__field--error',
)}
/>
{description && !isInvalid && (
<p {...descriptionProps} className="rudi-input__description">
{description}
</p>
)}
{isInvalid && errorMessage && (
<p {...errorMessageProps} className="rudi-input__error">
{errorMessage}
</p>
)}
</div>
)
},
)
useTextField handles all the ARIA wiring. The hook:
- Associates the label with the input via matching
idandhtmlForattributes. - Links the description and error message to the input via
aria-describedby. - Sets
aria-required="true"whenisRequiredis true. - Sets
aria-invalid="true"whenisInvalidis true. - Returns
isInvalidso you can use it for conditional rendering and styling.
You don’t set any of these attributes manually. The hook returns them inside inputProps, labelProps, descriptionProps, and errorMessageProps, and you spread them onto the corresponding elements.
The error state is conditional: when isInvalid is true (which happens when errorMessage is passed), the input shows the error message and hides the description. When the input is valid, it shows the description (if one exists). This prevents screen readers from announcing both the description and the error simultaneously.
IconButton
IconButton follows the same pattern as Button, but requires an aria-label because it has no visible text:
// src/components/IconButton/IconButton.tsx
import React from 'react'
import { useObjectRef } from '@react-aria/utils'
import type { AriaButtonProps } from 'react-aria'
import { cn } from '@/utils/cn'
import { useLoadingButton } from '@/hooks/useLoadingButton'
import './icon-button.css'
export interface RudiIconButtonProps extends AriaButtonProps {
'aria-label': string
variant?: 'primary' | 'secondary' | 'destructive' | 'ghost'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
className?: string
children: React.ReactElement
}
export const RudiIconButton = React.forwardRef<HTMLButtonElement, RudiIconButtonProps>(
function RudiIconButton(
{
variant = 'secondary',
size = 'md',
isLoading = false,
className,
children,
...ariaProps
},
forwardedRef,
) {
const ref = useObjectRef(forwardedRef)
const { buttonProps } = useLoadingButton(ariaProps, isLoading, ref)
return (
<button
{...buttonProps}
ref={ref}
className={cn(
'rudi-icon-button',
`rudi-icon-button--${variant}`,
`rudi-icon-button--${size}`,
className,
)}
>
{children}
</button>
)
},
)
The 'aria-label': string type (without the ? optional marker) forces consumers to provide an accessible label. TypeScript will produce a compile error if they forget. This is a deliberate design choice: an icon-only button without an accessible name is unusable for screen reader users (it’s announced as “button” with no label), and making it a required prop prevents that failure mode at development time rather than in an accessibility audit.
What you have now
Three interactive components (Button, IconButton, Input), each built on React Aria. The pattern is consistent: import a hook, pass state and configuration, spread the returned props onto your elements. React Aria handles the ARIA attributes, keyboard behavior, and focus management. You handle the rendering and styling.
The next chapter builds the remaining form components using the same approach.
This chapter has been corrected since it was first published: see what changed. Found an error? Report it.