Accessibility review checklist for React/Next.js components built on Radix UI / shadcn/ui. Covers component library misuse, form accessibility, accessible names, keyboard interaction, focus management, and dynamic content. Loaded by pr-review-frontend.
This is the highest-signal section for this codebase. Radix handles a11y correctly when used correctly — bugs come from misuse.
Dialog/Sheet without title: Radix Dialog and require / for screen reader announcement. If is omitted or visually hidden without on , screen readers announce an unlabeled dialog.
SheetDialogTitleSheetTitleDialogTitlearia-labelDialogContent<DialogContent> with no <DialogTitle> and no aria-label<VisuallyHidden><DialogTitle>...</DialogTitle></VisuallyHidden> is a valid pattern for dialogs where a visible title doesn't fit the designAlertDialog without description: AlertDialogContent should include AlertDialogDescription for screen readers to understand the confirmation context. If omitted, add aria-describedby={undefined} to explicitly opt out (otherwise Radix warns).
Select/Combobox without accessible trigger label: Radix Select needs aria-label on the trigger when there's no visible label. Custom generic-select.tsx and generic-combo-box.tsx wrappers should propagate labels.
<Select> inside a form field that has a visual label, but the label isn't associated via htmlFor or wrappingDropdownMenu items without accessible names: Icon-only menu items need text content or aria-label. Menu items that are just icons (e.g., copy, delete, edit) need text.
<DropdownMenuItem><TrashIcon /> Delete</DropdownMenuItem> (icon + text)<DropdownMenuItem><TrashIcon /></DropdownMenuItem> (icon only, no text, no aria-label)Tooltip as only accessible name: Tooltip text is not reliably announced by all screen readers. If a control's only accessible name is in a tooltip, it needs aria-label as well.
<Tooltip><TooltipTrigger><Button><Icon /></Button></TooltipTrigger><TooltipContent>Delete</TooltipContent></Tooltip> — Button needs aria-label="Delete"Overriding Radix's keyboard handling: If a component wraps a Radix primitive and adds onKeyDown that calls e.preventDefault() or e.stopPropagation(), it may break Radix's built-in keyboard navigation.
The codebase uses react-hook-form + Zod with shadcn/ui's Form component, which auto-associates labels via FormItem context. Issues arise when forms bypass this pattern.
Every form input must have an accessible name: Via <FormLabel>, <label htmlFor={id}>, aria-label, or aria-labelledby. Placeholder text alone is NOT a label.
<FormField> / <FormItem> that don't get auto-association<Input placeholder="Enter name" /> used standalone without any labelError messages must be associated with their input: shadcn/ui's <FormMessage> auto-associates via aria-describedby when inside <FormItem>. Custom error rendering outside this pattern loses the association.
<FormMessage> or manual aria-describedbyRequired fields must be indicated programmatically: Use aria-required="true" or native required, not just a visual asterisk. The Form component doesn't add this automatically — it comes from the Zod schema validation at submit time, not at the HTML level.
Grouped controls need group semantics: Radio groups and checkbox groups should use <RadioGroup> (Radix) or <fieldset>/<legend>. Loose radio buttons or checkboxes without group context confuse screen readers.
With 48 shadcn/ui components and heavy icon usage (Lucide React), icon-only interactive elements are a primary risk area.
Icon-only buttons must have aria-label: Buttons containing only an icon (no visible text) need aria-label describing the action.
<Button variant="ghost" size="icon"><TrashIcon /></Button> without aria-labelDialog close button already includes <span className="sr-only">Close</span> — don't flag thisIcon-only links need accessible names: Same as buttons — <a> or <Link> with only an icon needs aria-label.
sr-only text is a valid alternative to aria-label: <Button><TrashIcon /><span className="sr-only">Delete item</span></Button> is correct. Don't flag this pattern as missing a label.
Decorative icons should be hidden: Icons that are purely decorative (next to visible text) should have aria-hidden="true" to avoid redundant announcements.
<Button><PlusIcon aria-hidden="true" /> Add item</Button>aria-hidden by default — check before flaggingThe codebase currently has no <div onClick> anti-patterns. This section guards against regressions.
Interactive elements must use native interactive HTML: <button> for actions, <a>/<Link> for navigation. NOT <div>, <span>, or <p> with onClick.
<div onClick> or <span onClick> in the diff as CRITICALTables must use semantic HTML: <table>, <thead>, <tbody>, <th>, <td>. The codebase already does this. Flag any new data display that should be a table but uses <div> grid instead.
<th> elements should have scope="col" or scope="row" for complex tablesDon't disable zoom: Flag user-scalable=no or maximum-scale=1 in viewport meta tags.
Radix Dialog handles focus trap and restore automatically. This section covers what Radix doesn't handle.
Custom modals/overlays must manage focus: Any modal-like UI NOT built on Radix Dialog (e.g., custom overlays, fullscreen panels, React Flow side panels) must:
Focus visible indicator must not be removed: outline-none / outline: none without a focus-visible:ring-* replacement removes the only visual cue for keyboard users.
focus-visible:ring-* alongside outline-none — this is correct. Only flag if a new component uses outline-none without the replacement.Route change focus (Next.js App Router): After client-side navigation, focus should move to the main content. Next.js App Router may handle this — only flag if a custom route change mechanism bypasses the framework's handling.
Positive tabIndex is an anti-pattern: tabIndex={0} and tabIndex={-1} are fine. tabIndex={1} or higher overrides natural order and creates unpredictable navigation. Flag any positive tabIndex values.
With 287 toast usages (Sonner) and chat streaming interfaces, announcements for screen readers matter.
Sonner toasts: Sonner uses role="status" with aria-live="polite" by default. This is correct. Only flag if:
role="alert" (assertive) instead of role="status" (polite) for critical errorsLoading states should be communicated: Skeleton loaders and spinners should be accompanied by screen reader announcements. Options:
aria-busy="true" on the loading container<span className="sr-only">Loading...</span> inside the spinneraria-live="polite" region that announces "Loading..." then announces when content is readySpinner component already has aria-label — check that new loading patterns follow suitChat streaming messages: For the copilot/playground chat interfaces, new messages should be announced to screen readers. The @inkeep/agents-ui library should handle this — only flag if custom chat rendering bypasses the library's announcements.
Inline form validation: When validation errors appear dynamically (without page reload), they should either:
aria-describedby (shadcn/ui's <FormMessage> does this)aria-live="polite" to announce the error<FormMessage> patternThese components have unique a11y considerations beyond standard patterns.
Monaco Editor: Has known a11y limitations for screen reader users. When Monaco is used for required input (not just optional code editing), consider providing an alternative text input fallback. Flag only if a new Monaco instance is introduced without consideration.
React Flow (node graph editor): Keyboard navigation in visual node editors is inherently difficult. When React Flow is used:
Data tables with actions: Tables with row-level action buttons (common in this codebase) should ensure action buttons have accessible names and the table structure allows screen reader navigation.
aria-label| Finding | Severity | Rationale |
|---|---|---|
<div onClick> or <span onClick> (non-semantic interactive element) | CRITICAL | Completely blocks keyboard/screen reader users |
| Keyboard trap (user cannot Tab out of a component) | CRITICAL | Completely blocks keyboard users |
| Custom modal without focus management (not using Radix Dialog) | MAJOR | Major disorientation for keyboard/screen reader users |
| Form input without accessible name (no label, no aria-label) | MAJOR | Screen reader users cannot identify the input |
Icon-only button without aria-label or sr-only text | MAJOR | Screen reader users cannot identify the action |
| Dialog without DialogTitle and no aria-label | MAJOR | Screen reader users don't know what the dialog is for |
aria-hidden="true" on container with focusable children | MAJOR | Creates ghost focus for screen reader users |
| Error message not associated with input (outside FormMessage) | MAJOR | Screen reader users don't know about validation errors |
outline-none without focus-visible:ring replacement | MAJOR | Keyboard users lose their place |
| Radix keyboard handling overridden via stopPropagation | MAJOR | Breaks built-in a11y of the component library |
| Missing alt text on informational image | MINOR | Information not conveyed, but usually not blocking |
Decorative icon missing aria-hidden="true" | MINOR | Redundant announcement — annoying, not blocking |
| Custom notification/toast without live region | MINOR | Status not announced, but visually evident |
| Redundant ARIA on native elements | MINOR | Noise, not breakage — indicates misunderstanding |
Missing scope on <th> in complex tables | INFO | Navigation degraded in complex tables, not blocking |