Building Accessible UIs with Radix UI and shadcn/ui
Frontend14 min read

Building Accessible UIs with Radix UI and shadcn/ui

Accessible interactive components are hard to build by hand. Learn how Radix UI primitives handle focus, keyboard navigation, and ARIA for you, how shadcn/ui turns them into components you own, and how to build a genuinely accessible Dialog, Dropdown, and Combobox.

Taha Kocal

Taha Kocal

Full-Stack & Mobile Developer

May 18, 2026
#Accessibility#Radix UI#shadcn/ui#React#Tailwind CSS#TypeScript#ARIA

Radix UI is a library of unstyled, accessible React primitives that implement the WAI-ARIA authoring patterns (focus management, keyboard navigation, and ARIA attribute wiring) for components such as dialogs, dropdown menus, and comboboxes, while leaving all visual styling to you. shadcn/ui builds on Radix by providing pre-styled, Tailwind CSS-based components that a CLI copies directly into your codebase, typically under components/ui, so you own and edit the source rather than installing a black-box npm dependency. Together they solve the hardest problem in front-end accessibility: interactive widgets like a combobox demand a large behavioral contract (arrow-key navigation, typeahead, focus trapping, aria-expanded state) that can take days to hand-build correctly. Using Radix for behavior and shadcn/ui for design gives you WCAG-friendly components with full styling control, needing only accessible names, such as titles, labels, and sr-only text, from you.

Every front-end developer eventually reaches for a modal, a dropdown menu, or a searchable select. And most of us, at some point, have built one from scratch with a div, a bit of state, and an onClick handler. It looks fine. It demos well. Then someone tries to close it with the Escape key, or tab through it with a keyboard, or use it with a screen reader, and the whole thing falls apart. This article is about why that happens, and how the combination of Radix UI and shadcn/ui lets you ship components that are correct, accessible, and still fully yours to style.

Why Does Accessibility Matter, and Why Are Custom Widgets Expensive?

Accessibility is often framed as a compliance checkbox: WCAG, ADA, the European Accessibility Act. Those are real pressures, and for many products they are legally binding. But the more useful way to think about it is that accessibility is just correctness for input methods you do not personally use every day. A keyboard user, a screen reader user, someone with a motor impairment using switch control, or simply a power user who never touches the mouse, all rely on the same underlying contract: interactive elements must announce what they are, receive focus in a sensible order, and respond to standard keyboard interactions.

The trouble is that this contract is enormous for anything beyond a button or a link. Consider a dropdown menu. The WAI-ARIA Authoring Practices Guide specifies that it should use roles like menu and menuitem, that ArrowDown and ArrowUp move between items, that Home and End jump to the first and last item, that typing a letter moves focus to the next item starting with that letter, that Escape closes the menu and returns focus to the trigger, that focus is trapped while open, and that the trigger exposes aria-expanded and aria-haspopup. Miss any one of these and you have a menu that works for you but silently excludes a segment of your users.

The most accessible interactive widget is one you did not hand-roll. Every custom dropdown, tooltip, and dialog in a codebase is a fresh opportunity to reimplement the same accessibility bugs.

This is the core economic argument. Building a truly accessible combobox by hand can take days, and it will need to be re-tested every time the markup changes. Multiply that across every interactive pattern in a design system and the cost becomes prohibitive. The pragmatic answer is to not own the behavior, and to only own the appearance. That is exactly the seam that Radix UI and shadcn/ui exploit.

Radix UI: unstyled primitives that get the behavior right

Radix UI (specifically Radix Primitives) is a library of low-level, unstyled React components that implement the WAI-ARIA patterns for you. It ships no visual design at all. What it provides is the hard part: focus management, keyboard navigation, ARIA attributes, collision-aware positioning, portal rendering, and a composable API built around a Root and a set of parts. You bring the CSS.

Radix components are exposed as compound components. You compose a set of named parts under a Root, and Radix wires the ARIA relationships and focus behavior between them. Here is a minimal Radix Dialog, entirely unstyled, to show the shape of the API:

tsx
import * as Dialog from '@radix-ui/react-dialog';

export function ConfirmDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger>Delete account</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>
          <Dialog.Title>Are you absolutely sure?</Dialog.Title>
          <Dialog.Description>
            This action cannot be undone. It permanently deletes your account.
          </Dialog.Description>
          <Dialog.Close>Cancel</Dialog.Close>
          <button onClick={handleDelete}>Yes, delete</button>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

There is no CSS here, yet this dialog is already accessible. Radix renders the content in a portal so it escapes any overflow: hidden or z-index trap in the parent tree. It sets role="dialog" and aria-modal="true" on the content. It wires Dialog.Title and Dialog.Description to the content via aria-labelledby and aria-describedby automatically, so a screen reader announces both when the dialog opens. It traps focus inside the content while open, moves focus to the first focusable element, closes on Escape and on outside click, and, crucially, restores focus to the trigger when it closes. Every one of those behaviors is a bug you did not have to write or test.

The three things Radix hands you for free

Radix takes ownership of the behaviors that are hardest to get right by hand:

  • Focus management: moving focus into a component when it opens, trapping it while open, and restoring it to the trigger when it closes.
  • Keyboard navigation: the full set of arrow, Home/End, typeahead, Enter, Space, and Escape interactions specified by the ARIA Authoring Practices for each pattern.
  • ARIA wiring: roles, states like aria-expanded and aria-selected, and the aria-labelledby / aria-describedby relationships between parts, all kept in sync with component state.

shadcn/ui: components you own, built on Radix and Tailwind

Radix gives you correct behavior but no styling, and styling a compound component consistently across a whole app is its own effort. This is where shadcn/ui comes in, and it is worth being precise about what it actually is, because it works differently from every component library you have used before.

shadcn/ui is not an npm dependency. It is a collection of pre-built, pre-styled components that you copy into your own codebase using a CLI. When you run the add command, it writes the component source directly into your project, typically under components/ui. From that moment the component is yours. There is no black box in node_modules, no version to upgrade, no props API to fight when you need a variation the maintainer did not anticipate. You edit the file.

bash
# Initialize shadcn/ui in a project (sets up Tailwind tokens and paths)
npx shadcn@latest init

# Add components; each one is copied into components/ui as editable source
npx shadcn@latest add dialog dropdown-menu command popover

Under the hood, the vast majority of shadcn/ui interactive components are thin, styled wrappers around Radix primitives. The Dialog wraps Radix Dialog, the DropdownMenu wraps Radix DropdownMenu, the Select wraps Radix Select. shadcn/ui adds Tailwind classes, sensible defaults, class-variance-authority for variants, and a cn helper for merging classes, but the accessibility comes from Radix underneath. You get the correctness of Radix and the aesthetics of a polished design system, in code you can read and change.

Traditional component libraries give you a dependency you configure. shadcn/ui gives you source code you own. The behavior is Radix; the design is yours; the maintenance boundary sits inside your repo, not in a package upgrade.

How Do You Build an Accessible Dialog?

Here is roughly what the shadcn/ui Dialog looks like once it lands in your project. It re-exports the Radix parts with Tailwind styling applied. Note how the DialogContent composes the overlay, the content, and a built-in close button, and how animation states are driven by Radix data attributes like data-[state=open].

tsx
'use client';

import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';

const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogTitle = DialogPrimitive.Title;
const DialogDescription = DialogPrimitive.Description;

const DialogOverlay = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Overlay
    ref={ref}
    className={cn(
      'fixed inset-0 z-50 bg-black/80 backdrop-blur-sm',
      'data-[state=open]:animate-in data-[state=closed]:animate-out',
      'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
      className
    )}
    {...props}
  />
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;

const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPortal>
    <DialogOverlay />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2',
        '-translate-y-1/2 gap-4 border bg-background p-6 shadow-lg rounded-lg',
        'focus:outline-none',
        className
      )}
      {...props}
    >
      {children}
      <DialogPrimitive.Close
        className={cn(
          'absolute right-4 top-4 rounded-sm opacity-70 transition-opacity',
          'hover:opacity-100 focus-visible:outline-none focus-visible:ring-2',
          'focus-visible:ring-ring'
        )}
      >
        <X className="h-4 w-4" aria-hidden="true" />
        <span className="sr-only">Close</span>
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;

export {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogTitle,
  DialogDescription,
};

Two details in that close button are easy to overlook but matter a great deal. The X icon carries aria-hidden="true" so the screen reader does not try to announce a decorative SVG, and the accessible name is supplied by a visually hidden span with the sr-only class. That is the standard pattern for an icon-only control: hide the icon from assistive technology and provide text that only assistive technology reads. Consuming the dialog is then a matter of composition, and you must always include a DialogTitle for the accessible name:

tsx
import {
  Dialog, DialogTrigger, DialogContent, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';

export function DeleteAccountDialog() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="destructive">Delete account</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogTitle>Delete your account?</DialogTitle>
        <DialogDescription>
          This permanently removes your data. This action cannot be undone.
        </DialogDescription>
        <div className="flex justify-end gap-2">
          <Button variant="outline">Cancel</Button>
          <Button variant="destructive">Delete</Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}

Focus trapping and restoration in practice

When the trigger is activated, Radix moves focus into the dialog content, typically to the first focusable element, and constrains the tab order so that pressing Tab on the last element wraps to the first and Shift+Tab on the first wraps to the last. Focus cannot escape to the page behind the overlay. When the dialog closes, whether by Escape, outside click, or the close button, focus returns to the element that opened it. This restoration is what keeps a keyboard user from being dumped at the top of the document after every interaction, and it is one of the most commonly forgotten behaviors in hand-rolled modals. If you need focus to land somewhere specific, Radix exposes onOpenAutoFocus and onCloseAutoFocus so you can call preventDefault and move focus yourself.

How Do You Build an Accessible Dropdown Menu?

A dropdown menu is a menu of actions, and it follows the ARIA menu pattern rather than the listbox pattern. The distinction matters: a menu is for commands, and its items get roles like menuitem, menuitemcheckbox, and menuitemradio. Radix DropdownMenu, wrapped by shadcn/ui, implements arrow-key navigation, typeahead, Home/End, submenu handling, and Escape-to-close automatically.

tsx
import {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuCheckboxItem,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';

export function AccountMenu({
  showEmail,
  onToggleEmail,
}: {
  showEmail: boolean;
  onToggleEmail: (value: boolean) => void;
}) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline">Account</Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-56">
        <DropdownMenuLabel>My account</DropdownMenuLabel>
        <DropdownMenuSeparator />
        <DropdownMenuItem onSelect={() => navigate('/profile')}>
          Profile
        </DropdownMenuItem>
        <DropdownMenuItem onSelect={() => navigate('/settings')}>
          Settings
        </DropdownMenuItem>
        <DropdownMenuCheckboxItem
          checked={showEmail}
          onCheckedChange={onToggleEmail}
        >
          Show email in header
        </DropdownMenuCheckboxItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem
          className="text-destructive focus:text-destructive"
          onSelect={() => signOut()}
        >
          Sign out
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Notice onSelect rather than onClick. Radix fires onSelect for both mouse clicks and keyboard activation via Enter or Space, so your handler runs regardless of input method, and the menu closes afterward on its own. The trigger automatically receives aria-haspopup="menu" and aria-expanded that flips as the menu opens and closes. The DropdownMenuCheckboxItem gets role="menuitemcheckbox" with aria-checked kept in sync with the checked prop. None of this required you to think about ARIA at all.

A note on menus versus selects

A frequent accessibility mistake is using a menu for what is really a form input. If the control chooses a value that gets submitted, such as picking a country or a plan, it is a select or a combobox and should use the listbox pattern with option roles, not a menu. If the control triggers an action such as Edit, Duplicate, or Delete, it is a menu. Reach for Radix DropdownMenu in the second case and Radix Select or the shadcn/ui Combobox in the first. Choosing the right pattern is the single most important accessibility decision, because it determines the entire keyboard and screen reader contract.

How Do You Build an Accessible Combobox?

The combobox is the hardest of the three and the one people almost always get wrong when building by hand. It combines a text input, a filtered listbox, and a set of keyboard interactions that must keep the input, the visible options, and screen reader announcements all in sync. shadcn/ui builds its Combobox by composing two primitives: a Popover (Radix) for the floating panel and a Command component (built on the cmdk library) for the searchable, keyboard-navigable list.

tsx
'use client';

import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from '@/components/ui/command';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover';

type Framework = { value: string; label: string };

const frameworks: Framework[] = [
  { value: 'next', label: 'Next.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
  { value: 'vite', label: 'Vite' },
];

export function FrameworkCombobox() {
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState('');

  const selected = frameworks.find((f) => f.value === value);

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          role="combobox"
          aria-expanded={open}
          className="w-64 justify-between"
        >
          {selected ? selected.label : 'Select framework...'}
          <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" aria-hidden="true" />
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-64 p-0">
        <Command>
          <CommandInput placeholder="Search framework..." />
          <CommandList>
            <CommandEmpty>No framework found.</CommandEmpty>
            <CommandGroup>
              {frameworks.map((framework) => (
                <CommandItem
                  key={framework.value}
                  value={framework.value}
                  onSelect={(current) => {
                    setValue(current === value ? '' : current);
                    setOpen(false);
                  }}
                >
                  <Check
                    className={cn(
                      'mr-2 h-4 w-4',
                      value === framework.value ? 'opacity-100' : 'opacity-0'
                    )}
                    aria-hidden="true"
                  />
                  {framework.label}
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

A lot of accessibility is happening in that small component. The trigger declares role="combobox" and aria-expanded so assistive technology knows it opens a popup. The Command component from cmdk manages the filtered list: as the user types in CommandInput it filters options, moves an active highlight with ArrowUp and ArrowDown, activates the highlighted item with Enter, and exposes the active option to screen readers via aria-activedescendant so the input keeps focus while the announced selection moves through the list. When no options match, CommandEmpty renders and is announced. The onSelect callback fires for both mouse and keyboard, and the checkmark reflects the current selection while staying hidden from screen readers.

One thing to watch: this pattern uses a button as the trigger rather than the input itself, which is a common and valid design, but if you need the classic editable combobox where the input is always visible and typing filters in place, you would render CommandInput as the trigger and manage the open state on focus. Either way, the keyboard and ARIA plumbing lives in cmdk and Radix, not in your handlers.

Labeling and ARIA: the parts you still own

Radix wires up the internal ARIA relationships, but it cannot invent meaning. You are still responsible for giving every control an accessible name. For a Dialog that means always rendering a DialogTitle; if a design truly has no visible title, wrap it so it stays in the accessibility tree while hidden from view, rather than omitting it. For form fields, associate a real label with the input. shadcn/ui ships a Label component wrapping Radix Label, which links to the control via htmlFor so clicking the label focuses the field and the screen reader announces the label with it.

tsx
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';

export function EmailField({ error }: { error?: string }) {
  return (
    <div className="grid gap-1.5">
      <Label htmlFor="email">Email address</Label>
      <Input
        id="email"
        type="email"
        aria-invalid={!!error}
        aria-describedby={error ? 'email-error' : undefined}
      />
      {error && (
        <p id="email-error" role="alert" className="text-sm text-destructive">
          {error}
        </p>
      )}
    </div>
  );
}

Here the htmlFor and id pair gives the input its name, aria-invalid signals the error state, aria-describedby points to the message so a screen reader reads it after the label, and role="alert" makes the error announce the moment it appears. These are the connections Radix cannot make for you, because only you know which text describes which control. Icon-only buttons need the same care: always provide an sr-only text label or an aria-label, and mark the icon aria-hidden.

How Do You Theme with CSS Variables and Dark Mode?

shadcn/ui theming is built on CSS custom properties mapped to Tailwind color tokens. Instead of hard-coding colors, components reference semantic tokens like bg-background, text-foreground, and ring-ring. Those tokens resolve to CSS variables defined on the root, and dark mode is simply a second set of values under a .dark selector. Flip a class on the html element and the entire component tree re-themes without touching a single component file.

typescript
/* globals.css */
:root {
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;
  --primary: 222.2 47.4% 11.2%;
  --primary-foreground: 210 40% 98%;
  --destructive: 0 84.2% 60.2%;
  --ring: 222.2 84% 4.9%;
  --radius: 0.5rem;
}

.dark {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
  --primary: 210 40% 98%;
  --primary-foreground: 222.2 47.4% 11.2%;
  --destructive: 0 62.8% 30.6%;
  --ring: 212.7 26.8% 83.9%;
}

The values are stored as raw HSL channels rather than full color strings, which lets Tailwind compose them with opacity modifiers such as bg-primary/90. In your Tailwind config each token is declared as hsl(var(--primary)). Because Radix drives open and closed states through data attributes, you can also theme and animate purely from CSS with selectors like data-[state=open]:animate-in, keeping motion out of your JavaScript. When you build a custom theme, respect contrast: WCAG requires at least 4.5:1 for normal text and 3:1 for large text and for the visible focus ring, and dark mode is where these ratios most often quietly break.

Composing and extending components

Because the components live in your repo, extending them is ordinary React work rather than a battle with a props API. The pattern that makes this clean is asChild, a Radix feature that shadcn/ui inherits. Instead of rendering its own DOM element, a component with asChild merges its behavior and props onto the single child you provide. That is how DialogTrigger can wrap your own Button while still forwarding the ARIA attributes and click handling.

tsx
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md text-sm font-medium ' +
    'transition-colors focus-visible:outline-none focus-visible:ring-2 ' +
    'focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        outline: 'border border-input bg-background hover:bg-accent',
        destructive:
          'bg-destructive text-destructive-foreground hover:bg-destructive/90',
      },
      size: { default: 'h-10 px-4 py-2', sm: 'h-9 px-3', lg: 'h-11 px-8' },
    },
    defaultVariants: { variant: 'default', size: 'default' },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button';
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = 'Button';

The focus-visible:ring-2 in the base classes is doing quiet but essential accessibility work. It shows a visible focus indicator only for keyboard users, thanks to the focus-visible pseudo-class, so mouse users are not distracted by rings while keyboard users always know where they are. Never remove focus outlines without replacing them; an invisible focus state is one of the most common and most damaging accessibility regressions. Because Button owns its own file, adding a new variant or a loading state is a local edit, not a feature request to an upstream maintainer.

What Are Common A11y Pitfalls, and How Do You Test?

Radix and shadcn/ui remove most of the behavioral bugs, but they cannot stop you from reintroducing problems through styling and composition. These are the mistakes that survive even a good primitive layer:

Watch for these recurring pitfalls:

  • Removing focus outlines with outline: none and never adding a visible replacement, leaving keyboard users lost.
  • Icon-only buttons with no accessible name; always add an sr-only label or aria-label and set aria-hidden on the icon.
  • Omitting a Dialog title or field label, which strips the accessible name Radix expects you to supply.
  • Using a DropdownMenu for value selection instead of a Select or Combobox, applying the wrong ARIA pattern.
  • Color contrast that fails WCAG, most often in dark mode, on muted text, placeholders, and the focus ring.
  • Disabled buttons that convey nothing to assistive tech about why they are disabled; prefer explaining the requirement in nearby text.
  • Overriding Radix data-state styling in a way that hides the open, checked, or selected state visually while it remains true in the tree.

A three-layer testing routine

No single tool catches everything, so combine three passes. First, the keyboard test, which requires no tooling and catches the most issues: put the mouse away and operate the whole feature with Tab, Shift+Tab, arrows, Enter, Space, and Escape. Can you reach every control, is the focus order logical, is focus always visible, does Escape close overlays, and does focus return to the trigger afterward? If a flow is painful with the keyboard, it is broken for a real segment of users.

Second, run an automated audit. axe-core, exposed through the axe DevTools browser extension or the @axe-core/react package in development, catches missing names, contrast failures, and invalid ARIA. It is fast and finds the mechanical problems, though it only detects roughly a third to a half of real issues, so it is a floor, not a ceiling. You can also wire it into component tests with jest-axe:

tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { DeleteAccountDialog } from './delete-account-dialog';

expect.extend(toHaveNoViolations);

it('dialog trigger has no a11y violations', async () => {
  const { container } = render(<DeleteAccountDialog />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Third, test with an actual screen reader, because only a human listening confirms the experience makes sense. Use VoiceOver on macOS (Cmd+F5), NVDA on Windows (free), or TalkBack on Android. Open your dialog and confirm the title and description are announced, that the role is spoken as dialog, and that navigating the combobox announces each option and the current selection. This step routinely surfaces things automated tools cannot judge, such as an announcement that is technically present but confusing. Together, keyboard, axe, and screen reader form a routine that keeps regressions from shipping.

Key Takeaways

The essentials to carry forward:

  • Accessible interactive widgets are expensive to hand-build because each one reimplements a large, error-prone behavioral contract; the cheapest accessible component is one whose behavior you did not write.
  • Radix UI supplies unstyled primitives that own the hard parts: focus management, keyboard navigation, and ARIA wiring, all kept in sync with component state.
  • shadcn/ui copies styled, Tailwind-based components built on Radix directly into your codebase, so you own the source and the design while inheriting Radix accessibility.
  • Dialogs, dropdown menus, and comboboxes each follow a distinct ARIA pattern; picking the right primitive (Dialog, DropdownMenu, or Popover plus Command) is the most important decision you make.
  • Radix handles focus trapping and restoration and internal ARIA, but you still own accessible names: titles, labels, sr-only text on icon buttons, and describedby wiring.
  • Theme with semantic CSS variables and drive dark mode and animations from data-state attributes, while guarding WCAG contrast, especially in dark mode and on the focus ring.
  • Never delete a focus indicator without replacing it, and verify every feature with the three-layer routine: keyboard, axe, and a real screen reader.

Share this article

Related Articles