Accessibility Props
Making aria-label required, typing ARIA roles as unions, and wiring up labelledby/describedby.
Icon button missing accessible label
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';
}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.
Image with optional alt text
interface ImageProps {
src: string;
alt?: string;
width: number;
height: number;
isLazy?: boolean;
}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.
ARIA role typed as string
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;
}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.
Redundant aria-label vs labelledby
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;
}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.
Custom form control accessibility
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;
}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.