Skip to content

Making Your Website Accessible with shadcn/ui

Written By Ajay Patel Categories: tutorials
Published: Updated:
11 min read

Accessible website interface built with shadcn/ui components

Accessibility is not only a legal or ethical obligation. It is one of the most direct ways to make a product easier to use for everyone.

In practice, web accessibility means that someone using a keyboard, screen reader, screen magnifier, voice control, or another assistive technology can understand and operate your interface. It also helps people navigating in bright sunlight, using a temporary injury workaround, or simply moving quickly through a form.

shadcn/ui gives you a strong starting point. Interactive components are built on accessible headless primitives, with Base UI used by default in new projects and Radix UI and React Aria also supported. Components such as dialogs, dropdown menus, selects, and tabs inherit much of their keyboard and ARIA behavior from those primitives.

However, shadcn/ui copies component code into your project. The result remains accessible only if the markup, styling, and composition you add preserve that behavior.

This guide covers five practical habits that make the biggest difference:

  • Give every control an accessible name.
  • Keep keyboard focus visible.
  • Preserve the behavior of component triggers.
  • Connect form labels, descriptions, and errors.
  • Announce important dynamic changes.

What shadcn/ui Handles for You

Primitive-backed components provide much of the difficult interaction logic that teams often get wrong when building widgets from scratch. Depending on the component and primitive library, this can include:

  • Appropriate roles, states, and ARIA attributes
  • Keyboard interaction with Tab, arrow keys, Enter, Space, Home, End, and Escape
  • Focus management when a dialog or menu opens and closes
  • Modal focus containment and protection of background content
  • Correct relationships between triggers and popups

That foundation covers a meaningful part of an accessibility audit, but it does not make every page automatically accessible. Your application still controls names, labels, content structure, color contrast, error messages, and custom event handling.

If you want to compare alternative patterns before building your own, the shadcn ui components collection provides useful starting points. Review the final source carefully, because accessibility still depends on how each component is named, configured, and used in your application.

Remember: Components such as Card, Badge, Table, and Skeleton are largely styled HTML. Their accessibility depends on the semantics and content you put inside them.

Give Icon Buttons an Accessible Name

Icon-only buttons are one of the most common accessibility gaps in component-based applications.

A trash icon may clearly mean “delete” to a sighted user, but an icon alone does not reliably give a screen reader enough information. Without an accessible name, the control may be announced as only “button.”

Use visually hidden text

shadcn/ui includes the Tailwind sr-only utility in many examples. It hides text visually while keeping that text available to assistive technology.

import { TrashIcon } from "lucide-react";

import { Button } from "@/components/ui/button";

export function DeleteInvoiceButton() {
  return (
    <Button size="icon" variant="ghost">
      <TrashIcon aria-hidden="true" />
      <span className="sr-only">Delete invoice</span>
    </Button>
  );
}

The button now has the accessible name “Delete invoice.” The icon uses aria-hidden="true" because it is decorative once the text supplies the meaning.

You can also name the button directly:

<Button size="icon" variant="ghost" aria-label="Delete invoice">
  <TrashIcon aria-hidden="true" />
</Button>

Both approaches work. Visually hidden text is often easier for translators and reviewers to find because the label appears as normal JSX content.

Tip: Do not rely on a tooltip as the button’s only label. Tooltips can be missed by screen readers, keyboard users, and people on touch devices. Give the control an accessible name first and treat the tooltip as extra help.

Make the name specific

The label should describe the action and, when useful, its target. “Delete invoice” is more helpful than “Delete,” especially when a screen contains several delete buttons.

Good accessible names include:

  • Open navigation menu
  • Copy API key
  • Remove Sarah from project
  • Close payment dialog

Keep Keyboard Focus Visible

Keyboard users rely on focus indicators to know which control will respond when they press Enter or Space. shadcn/ui components include focus-visible styles, but custom classes, global resets, and low-contrast theme tokens can weaken or remove them.

Avoid global CSS like this:

/* Do not remove focus indicators globally. */
*:focus {
  outline: none;
}

If you remove the browser outline, replace it with an equally clear focus indicator. Otherwise, a keyboard user can move through the page without seeing their current position.

Style focus with the ring token

The --ring theme token lets your focus indicators remain consistent across components and color modes.

/* globals.css */
:root {
  --ring: oklch(0.55 0.18 265);
}

.dark {
  --ring: oklch(0.75 0.15 265);
}

You can then use the token in a custom component:

<Button className="focus-visible:ring-ring focus-visible:ring-[3px]">
  Save changes
</Button>

Use focus-visible when you want a strong keyboard focus treatment without displaying the same ring after every pointer click. Do not remove the component’s default focus styles unless your replacement is at least as clear.

The shadcn studio theme generator can help you preview ring, background, and foreground colors together while refining a theme. After choosing the tokens, test their contrast in the actual interface and in both color modes.

WCAG 2.2 includes detailed rules for focus visibility and appearance. As a practical baseline, make the indicator easy to distinguish in both light and dark themes and check it against every adjacent background. A thin or transparent ring can disappear even when its color looks strong in a design token file.

Preserve Built-in Keyboard Navigation

One of the best reasons to use shadcn/ui primitives is that you do not need to recreate complex keyboard interactions.

Here is a dropdown menu using the current Base UI composition API:

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

export function AccountMenu() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger render={<Button variant="outline" />}>
        Options
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuItem>Profile</DropdownMenuItem>
        <DropdownMenuItem>Settings</DropdownMenuItem>
        <DropdownMenuItem>Log out</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

The primitive provides the menu interaction model: it opens from the trigger, moves between items with the keyboard, closes with Escape, and returns focus to the trigger. It also manages state such as aria-expanded and the relationship between the trigger and menu.

Base UI render versus Radix UI asChild

The composition syntax depends on the primitive library selected for the project. Current Base UI components use render:

<DropdownMenuTrigger render={<Button variant="outline" />}>
  Options
</DropdownMenuTrigger>

Radix-based components commonly use asChild:

<DropdownMenuTrigger asChild>
  <Button variant="outline">Options</Button>
</DropdownMenuTrigger>

Both patterns allow the primitive to attach behavior and accessibility attributes to the actual button. Avoid inserting an unnecessary wrapper around the trigger or replacing it with a non-interactive div, because doing so can break keyboard access, focus handling, or ARIA attributes.

This matters just as much when using larger, ready-made shadcn blocks. A block can provide a polished composition, but you should still tab through the complete section and confirm that its triggers, dialogs, and menus retain their expected behavior.

Build Forms Screen Readers Can Understand

An input needs a persistent label. Placeholder text is not a substitute: it disappears when the user types, often has low contrast, and may not be announced consistently.

shadcn/ui provides the Field family for composing labels, descriptions, controls, and validation messages.

Accessibility decisions are easier to preserve when they begin in design. Teams working with Shadcn Figma can annotate persistent labels, error states, focus behavior, and reading order before the interface reaches implementation.

import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";

export function EmailField() {
  const hasError = true;

  return (
    <Field data-invalid={hasError || undefined}>
      <FieldLabel htmlFor="email">Email address</FieldLabel>
      <Input
        id="email"
        type="email"
        autoComplete="email"
        aria-describedby="email-description email-error"
        aria-invalid={hasError || undefined}
        aria-errormessage={hasError ? "email-error" : undefined}
      />
      <FieldDescription id="email-description">
        We'll only use this for order updates.
      </FieldDescription>
      {hasError && (
        <FieldError id="email-error">Enter a valid email address.</FieldError>
      )}
    </Field>
  );
}

Several relationships work together here:

  • htmlFor="email" and id="email" connect the visible label to the input.
  • aria-describedby associates the supporting text with the control.
  • data-invalid applies the field’s visual error styling.
  • aria-invalid communicates the invalid state to assistive technology.
  • aria-errormessage associates the validation message with the input.

Use both visual and programmatic error states. Color alone should never be the only indication that a field is invalid.

For related checkboxes or radio buttons, use FieldSet and FieldLegend. They provide the semantic grouping that a visual heading alone cannot.

Announce Dynamic Changes with aria-live

Screen readers do not necessarily announce content just because React added it to the page. A status message can appear visually while remaining unnoticed by someone who cannot see that area of the screen.

Keep a live region mounted and update its text when the status changes:

type SaveStatus = "idle" | "saving" | "saved" | "error";

const messages: Record<SaveStatus, string> = {
  idle: "",
  saving: "Saving changes",
  saved: "Changes saved",
  error: "Changes could not be saved",
};

export function SaveAnnouncement({ status }: { status: SaveStatus }) {
  return (
    <p aria-live="polite" aria-atomic="true" className="sr-only">
      {messages[status]}
    </p>
  );
}

aria-live="polite" asks the screen reader to announce the update at the next reasonable pause. aria-atomic="true" makes it announce the complete message when the content changes.

Polite live regions work well for:

  • Save and loading states
  • Search result counts
  • Applied filters
  • Items added to a cart
  • Non-critical toast messages

Reserve aria-live="assertive" for urgent information that genuinely needs to interrupt the current announcement, such as a critical error. Overusing assertive announcements creates a noisy and confusing experience.

Do Not Forget Images and Page Structure

Component accessibility is only part of an accessible website. The surrounding content still needs meaningful HTML.

Complete layouts from Shadcn Pages can provide a useful structural starting point. Before shipping, review the page’s heading order, landmarks, link text, and image alternatives against your actual content.

Write useful alternative text

Use alt text that communicates the image’s purpose in context:

<img
  src="/images/revenue-dashboard.png"
  alt="Revenue dashboard showing a 24 percent increase over six months"
/>

If an image is purely decorative, use an empty alt attribute so assistive technology can skip it:

<img src="/images/decorative-grid.svg" alt="" />

Do not begin alt text with “image of” or repeat a nearby caption word for word. Describe the information the user would otherwise miss.

Keep the document semantic

Use native elements before adding ARIA:

  • Use a button for an action and an a element for navigation.
  • Keep heading levels in a logical hierarchy.
  • Use lists for groups of related items.
  • Add a main landmark and clear navigation labels.
  • Use table headers for tabular data.

Native HTML gives browsers and assistive technologies behavior that a styled div does not.

Test the Complete Experience

Automated checks are useful, but they cannot prove that an interface is accessible. Combine tools with hands-on testing.

The same rule applies when starting from Shadcn templates: a production-ready layout can speed up development, but the finished user journey still needs keyboard, screen-reader, zoom, and automated testing.

1. Use only the keyboard

Disconnect the mouse or set it aside. Complete an important user flow using Tab, Shift+Tab, arrow keys, Enter, Space, and Escape.

Check that:

  • Every interactive element is reachable.
  • Focus order matches the visual and reading order.
  • Focus is always visible.
  • Dialogs and menus can be opened and closed.
  • Focus does not get trapped or disappear unexpectedly.

2. Run automated checks

Use axe DevTools and Chrome Lighthouse to find missing names, invalid ARIA, contrast failures, and other common issues. Treat the results as a starting point, not a complete audit.

3. Test a screen reader

Try NVDA on Windows or VoiceOver on macOS and iOS. Listen for control names, roles, states, form descriptions, errors, and status announcements.

4. Check zoom and reflow

Zoom the browser to 200% and test at a narrow viewport. Content should remain readable and operable without controls overlapping, text being clipped, or horizontal scrolling becoming necessary for ordinary page content.

Accessibility Checklist for shadcn/ui Projects

Before shipping a new screen, verify the following:

  • Icon-only buttons have specific accessible names.
  • Decorative icons use aria-hidden="true" when appropriate.
  • Focus indicators remain visible in light and dark themes.
  • Primitive triggers preserve their render or asChild composition.
  • Every form control has a persistent label.
  • Descriptions and errors are programmatically associated with their controls.
  • Invalid fields expose both visual and assistive-technology states.
  • Important async updates use an appropriate status or live region.
  • Images have meaningful alt text or alt="" when decorative.
  • The full flow works with only a keyboard.

Key Takeaways

  • Start with shadcn/ui’s accessible primitives instead of rebuilding complex interactions.
  • Treat the generated source as code you are responsible for maintaining.
  • Give every control a clear accessible name.
  • Keep focus visible and preserve the primitive’s trigger behavior.
  • Connect labels, descriptions, and errors explicitly in forms.
  • Announce important dynamic updates without overwhelming the user.
  • Test with a keyboard and screen reader in addition to automated tools.

Accessibility is not a separate phase to schedule after a product is finished. It is a set of small, repeatable habits applied while building the interface. When those habits become part of your normal shadcn/ui workflow, everyone gets a more understandable and dependable product.

Resources