Learn

/

Accessibility Props

Accessibility Props

5 patterns

Making aria-label required, typing ARIA roles as unions, and wiring up labelledby/describedby.

Icon button missing accessible label
easy
Avoid
interface IconButtonProps
  extends React.ComponentProps<'button'> {
  /** The icon to display. */
  icon: React.ReactNode;
  /** Visual style variant. @default 'default' */
  variant?: 'default' | 'outlined';
  /** Button size. @default 'md' */
  size?: 'sm' | 'md' | 'lg';
}

Prefer
interface IconButtonProps
  extends React.ComponentProps<'button'> {
  /** The icon to display. */
  icon: React.ReactNode;
  /** Accessible label for screen readers. */
  'aria-label': string;
  /** Visual style variant. @default 'default' */
  variant?: 'default' | 'outlined';
  /** Button size. @default 'md' */
  size?: 'sm' | 'md' | 'lg';
}

Icon-only buttons have no visible text — screen readers announce them as just "button" with no context. Making aria-label required (not optional) ensures every usage site provides accessible text.

A screen reader user hears "Delete, button" instead of just "button". This is one of the most common accessibility failures in component libraries.

MDN: aria-label
Image with optional alt text
easy
Avoid
interface ImageProps {
  src: string;
  alt?: string;
  width: number;
  height: number;
  isLazy?: boolean;
}

Prefer
interface ImageProps {
  src: string;
  /** Descriptive text, or "" for decorative images. */
  alt: string;
  width: number;
  height: number;
  isLazy?: boolean;
}

Making alt required forces a conscious decision: provide a description, or explicitly pass "" for decorative images. Optional alt means consumers can simply forget it — and undefined alt is different from empty alt in HTML.

An <img> with no alt attribute is flagged by every accessibility audit tool. An <img alt=""> is intentionally decorative and skipped by screen readers.

MDN: img accessibility
ARIA role typed as string
medium
Avoid
interface LiveRegionProps {
  children: React.ReactNode;
  /**
   * ARIA role for the live region.
   * @default 'status'
   */
  role?: string;
  /**
   * How assertively the region announces.
   * @default 'polite'
   */
  'aria-live'?: string;
}

Prefer
interface LiveRegionProps {
  children: React.ReactNode;
  /**
   * ARIA role for the live region.
   * @default 'status'
   */
  role?: 'status' | 'alert' | 'log' | 'timer';
  /**
   * How assertively the region announces.
   * @default 'polite'
   */
  'aria-live'?: 'polite' | 'assertive' | 'off';
}

ARIA roles and attributes have a defined vocabulary — role="statis" (typo) silently does nothing. Union types catch typos at compile time and light up IDE autocomplete with the valid options.

The same principle applies to aria-live: only 'polite', 'assertive', and 'off' are valid. A string type accepts anything.

MDN: ARIA Live Regions
Redundant aria-label vs labelledby
medium
Avoid
interface DialogProps {
  children: React.ReactNode;
  /** The dialog title text. */
  title: string;
  /**
   * Accessible label for screen readers.
   * Should match the title text.
   */
  'aria-label'?: string;
  isOpen: boolean;
  onClose: () => void;
}

Prefer
interface DialogProps {
  children: React.ReactNode;
  /** The dialog title text. Renders as an h2. */
  title: string;
  /**
   * ID of the element that labels this dialog.
   * Auto-generated from the title element.
   * @default auto-generated
   */
  'aria-labelledby'?: string;
  isOpen: boolean;
  onClose: () => void;
}

When a visible title already exists, aria-labelledby points to it — the label stays in sync automatically. aria-label duplicates the title text, creating a maintenance risk: update the title prop but forget aria-label and screen readers announce stale text.

MUI's Dialog auto-generates the aria-labelledby ID from the title element, so consumers rarely need to pass it explicitly.

MDN: aria-labelledby
Custom form control accessibility
hard
Avoid
interface CustomSelectProps {
  /** The selectable options. */
  options: Array<{ value: string; label: string }>;
  /** The currently selected value. */
  value: string;
  /** Called when the user picks an option. */
  onChange: (value: string) => void;
  /** Text shown when no value is selected. */
  placeholder?: string;
  /** Whether the field is disabled. */
  isDisabled?: boolean;
  /** Error message to display. */
  errorMessage?: string;
  /** Whether the field is required. */
  isRequired?: boolean;
}

Prefer
interface CustomSelectProps {
  /** The selectable options. */
  options: Array<{ value: string; label: string }>;
  /** The currently selected value. */
  value: string;
  /** Called when the user picks an option. */
  onChange: (value: string) => void;
  /** Text shown when no value is selected. */
  placeholder?: string;
  /** Whether the field is disabled. */
  isDisabled?: boolean;
  /** Visible error message. Sets aria-invalid. */
  errorMessage?: string;
  /** Whether the field is required. Sets aria-required. */
  isRequired?: boolean;
  /** ID of the element that labels this select. */
  'aria-labelledby'?: string;
  /** ID of the element that describes this select. */
  'aria-describedby'?: string;
}

Custom form controls must replicate the accessibility contract of their native equivalents. aria-labelledby connects an external <label>, and aria-describedby connects help text or error messages. Without these, the select is an unlabeled, undescribed control to screen readers.

The component should internally set aria-invalid when errorMessage is present and aria-required when isRequired is true — consumers shouldn't have to manage these separately.

MDN: ARIA Listbox Role