Files
v10/.claude/skills/aria/references/react.md
T

9.6 KiB

React Accessibility Patterns

React-specific patterns for accessibility. Covers hook architecture, ref management, and framework considerations.

Contents


Hook Architecture

Separate accessibility logic into composable hooks:

Layer Separation

Layer Responsibility Example
State Component data useToggleState
Behavior ARIA + events useButton, useSlider
Render DOM output Component JSX

This separation allows:

  • Sharing accessibility logic across components
  • Testing behavior independently
  • Framework-agnostic core patterns

Behavior Hook Pattern

function useButton(props, ref) {
  return {
    buttonProps: {
      role: 'button',
      tabIndex: 0,
      onKeyDown: handleKeyDown,
      onClick: props.onPress,
      'aria-disabled': props.isDisabled,
    },
  };
}

Focus Management Hooks

Focus Scope

Contain focus within a subtree:

function useFocusScope(options: {
  contain?: boolean; // Trap focus
  restoreFocus?: boolean; // Restore on unmount
  autoFocus?: boolean; // Focus first element
}) {
  const scopeRef = useRef(null);
  const previousFocus = useRef(null);

  // Implementation handles:
  // - Finding focusable elements
  // - Tab wrapping at boundaries
  // - Restoring focus on unmount

  return { scopeRef };
}

Focus Ring Detection

Detect keyboard vs pointer focus:

function useFocusRing() {
  const [isFocusVisible, setFocusVisible] = useState(false);

  // Track input modality globally
  // Set true on keyboard events
  // Set false on pointer events

  return {
    isFocusVisible,
    focusProps: {
      onFocus: () => {
        /* check modality */
      },
      onBlur: () => setFocusVisible(false),
    },
  };
}

Roving Tabindex Hook

function useRovingTabindex(items: RefObject<HTMLElement>[]) {
  const [activeIndex, setActiveIndex] = useState(0);

  // Returns props to spread on each item
  return items.map((ref, i) => ({
    tabIndex: i === activeIndex ? 0 : -1,
    onKeyDown: (e) => {
      // Handle arrow keys, Home, End
      // Update activeIndex
      // Call focus() on new active item
    },
  }));
}

Ref Patterns

Merging Refs

When component accepts ref but you also need internal ref:

function useMergedRef<T>(...refs: Ref<T>[]): RefCallback<T> {
  return useCallback((value) => {
    refs.forEach(ref => {
      if (typeof ref === 'function') {
        ref(value);
      } else if (ref) {
        ref.current = value;
      }
    });
  }, refs);
}

// Usage
const Component = forwardRef((props, forwardedRef) => {
  const internalRef = useRef(null);
  const ref = useMergedRef(forwardedRef, internalRef);
  return <div ref={ref} />;
});

Callback Refs for Dynamic Elements

function useCallbackRef<T>(callback: (node: T | null) => void) {
  const ref = useRef(callback);

  useLayoutEffect(() => {
    ref.current = callback;
  });

  return useCallback((node: T | null) => {
    ref.current(node);
  }, []);
}

Event Handling

Keyboard Event Normalization

Handle cross-browser keyboard events:

function useKeyboard(handlers: { onKeyDown?: (e: KeyboardEvent) => void; onKeyUp?: (e: KeyboardEvent) => void }) {
  return {
    keyboardProps: {
      onKeyDown: (e: ReactKeyboardEvent) => {
        // Normalize key values
        // Handle IME composition
        // Call appropriate handler
      },
    },
  };
}

Press Events

Unified press handling for mouse, touch, keyboard:

function usePress(props: {
  onPress?: () => void;
  onPressStart?: () => void;
  onPressEnd?: () => void;
  isDisabled?: boolean;
}) {
  return {
    pressProps: {
      onClick: props.onPress,
      onKeyDown: (e) => {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          props.onPress?.();
        }
      },
    },
  };
}

Announcements

Live Region Hook

function useAnnounce() {
  const announce = useCallback((message: string, politeness: 'polite' | 'assertive' = 'polite') => {
    // Get or create live region
    // Clear existing content
    // Set new content (triggers announcement)
  }, []);

  return { announce };
}

Status Message Pattern

function useFormValidation() {
  const { announce } = useAnnounce();

  const validate = (value) => {
    const error = getError(value);
    if (error) {
      announce(error, 'assertive');
    }
    return error;
  };

  return { validate };
}

SSR Considerations

ID Generation

Generate stable IDs for ARIA relationships:

function useId(prefix?: string): string {
  // Use React 18's useId if available
  // Otherwise, generate stable ID
  // Avoid hydration mismatches
}

// Usage
function Dialog({ title, children }) {
  const titleId = useId('dialog-title');

  return (
    <div role="dialog" aria-labelledby={titleId}>
      <h2 id={titleId}>{title}</h2>
      {children}
    </div>
  );
}

Hydration Safety

Avoid client-only APIs in initial render:

function useSafeLayoutEffect(effect, deps) {
  // useLayoutEffect on client
  // useEffect (or skip) on server
  const isClient = typeof window !== 'undefined';
  const useIsomorphicEffect = isClient ? useLayoutEffect : useEffect;
  useIsomorphicEffect(effect, deps);
}

Portal Accessibility

Focus Containment with Portals

Portaled content (modals, popovers) needs special handling:

function AccessiblePortal({ children, containFocus }) {
  return createPortal(
    <FocusScope contain={containFocus} restoreFocus autoFocus>
      {children}
    </FocusScope>,
    document.body
  );
}

ARIA Relationships Across Portals

When trigger and content are in different DOM locations:

function Popover({ trigger, content }) {
  const triggerId = useId('trigger');
  const contentId = useId('content');

  return (
    <>
      <button
        id={triggerId}
        aria-controls={contentId}
        aria-expanded={isOpen}
      >
        {trigger}
      </button>
      {isOpen && createPortal(
        <div
          id={contentId}
          role="dialog"
          aria-labelledby={triggerId}
        >
          {content}
        </div>,
        document.body
      )}
    </>
  );
}

State Hook Patterns

Toggle State

function useToggleState(props: {
  defaultSelected?: boolean;
  isSelected?: boolean;
  onChange?: (isSelected: boolean) => void;
}) {
  const [isSelected, setSelected] = useControlledState(
    props.isSelected,
    props.defaultSelected ?? false,
    props.onChange
  );

  return {
    isSelected,
    toggle: () => setSelected(!isSelected),
    setSelected,
  };
}

Selection State (Single/Multi)

function useSelectionState(props: {
  selectionMode: 'none' | 'single' | 'multiple';
  selectedKeys?: Set<Key>;
  defaultSelectedKeys?: Set<Key>;
  onSelectionChange?: (keys: Set<Key>) => void;
}) {
  // Handles controlled/uncontrolled
  // Enforces selection mode rules
  // Provides select/deselect/toggle methods
}

Component Prop Patterns

Spreading Props Safely

// Separate ARIA/DOM props from component props
function splitProps<T>(props: T, ariaKeys: string[]) {
  const ariaProps = {};
  const restProps = {};

  for (const key in props) {
    if (ariaKeys.includes(key) || key.startsWith('aria-')) {
      ariaProps[key] = props[key];
    } else {
      restProps[key] = props[key];
    }
  }

  return [ariaProps, restProps];
}

Render Props for Flexibility

interface ButtonProps {
  children: ReactNode | ((state: ButtonState) => ReactNode);
}

interface ButtonState {
  isPressed: boolean;
  isFocused: boolean;
  isFocusVisible: boolean;
  isDisabled: boolean;
}

// Allows consumers to access state for custom rendering
<Button>
  {({ isPressed }) => (
    <span className={isPressed ? 'pressed' : ''}>Click me</span>
  )}
</Button>

Testing Patterns

Accessibility Testing Setup

import { render } from '@testing-library/react';
import { axe } from 'jest-axe';

test('component has no accessibility violations', async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Focus Testing

import { fireEvent } from '@testing-library/react';

test('arrow keys navigate options', () => {
  const { getAllByRole } = render(<Listbox />);
  const options = getAllByRole('option');

  options[0].focus();
  fireEvent.keyDown(options[0], { key: 'ArrowDown' });

  expect(document.activeElement).toBe(options[1]);
});

Screen Reader Simulation

test('announces state changes', () => {
  const { getByRole } = render(<Toggle />);
  const button = getByRole('button');
  const liveRegion = getByRole('status');

  fireEvent.click(button);

  expect(liveRegion).toHaveTextContent('Enabled');
});

See Also

  • component/react.md — React component architecture (context, controlled state, render props)