Type something to search...
Building Accessible UI Components in Next.js with shadcn/ui

Building Accessible UI Components in Next.js with shadcn/ui

Building a dropdown menu that looks right takes an afternoon. Building one that works with a keyboard, announces itself to screen readers, traps and restores focus correctly, closes on Escape, and supports typeahead takes much longer, and most hand-rolled versions miss at least half of that list.

shadcn/ui is a popular answer to that problem in the Next.js world. It isn't a component library you install as a dependency. It's a collection of components you copy into your project with a CLI, built on headless primitives (Radix UI by default) that handle the hard accessibility behavior, and styled with Tailwind CSS. You own the code, so you can change anything, but you start from components that already follow the WAI-ARIA patterns.

In this post I'll set up shadcn/ui in a Next.js 16 App Router project, then build a few common pieces (an accessible dialog with a form, a user menu, and form fields with proper error messaging) and explain which accessibility details the primitives handle for you and which ones are still your job.

What shadcn/ui Gives You (and What It Doesn't)

It helps to be clear about the split of responsibilities:

Handled by the primitivesStill your responsibility
ARIA roles and states (role="dialog", aria-expanded, aria-controls)Meaningful labels and titles
Focus trapping in dialogs and restoring focus on closeVisible focus styles that aren't removed by your CSS
Keyboard navigation in menus (arrows, Home/End, typeahead)Color contrast of your theme
Escape to close, click outside to dismissAssociating error messages with inputs
Portals and stacking for overlaysAlt text, heading structure, page landmarks

The primitives make it hard to build a broken widget. They can't make up for an icon button with no label, a dialog with no title, or a gray-on-gray theme.

Setting Up shadcn/ui

Start from a Next.js project with Tailwind CSS v4 (the create-next-app defaults include it; if you're adding it by hand, see Setting Up Tailwind CSS v4 in a Next.js Project). Then run the init command:

npx shadcn@latest init --base radix

The --base flag picks the headless library underneath. This post uses Radix UI, which is the most established option and what most existing shadcn/ui examples use. The CLI also offers Base UI; its components look the same but use a slightly different composition API, so if you choose it, check the docs for the components you add.

The init command:

  • Creates components.json, which records your style, paths, and aliases so later add commands know where to put files.
  • Adds lib/utils.ts with the cn helper (clsx plus tailwind-merge).
  • Adds theme tokens to app/globals.css as CSS variables (--background, --foreground, --primary, --ring, and so on) for light and dark themes.

Now add the components this post uses:

npx shadcn@latest add button dialog input label dropdown-menu

Each component lands in components/ui/ as a regular file you can read and edit. Dependencies like radix-ui and lucide-react are installed automatically.

Server and Client Components

Interactive components such as dialog.tsx, dropdown-menu.tsx, and label.tsx start with "use client", because the primitives need state and event handlers. Simpler ones such as input.tsx and button.tsx don't, so they can render on the server.

You don't need to make your pages Client Components to use them. A Server Component can render a Client Component and pass Server Components to it as children. Only the interactive leaf ships JavaScript. The use client directive post explains how that boundary works.

Buttons: Labels and Icon-Only Buttons

The generated Button renders a native button element, so it's focusable, activates with Enter and Space, and announces as a button. Its classes include a focus-visible ring using the --ring token. Keep that ring when you customize the component; removing outlines is the most common way teams accidentally break keyboard navigation.

Icon-only buttons are the usual problem. A trash can icon means something visually, but a screen reader just hears "button". Give it a text label:

// components/delete-button.tsx
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";

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

sr-only hides the text visually but keeps it in the accessibility tree. aria-hidden on the icon keeps the SVG from adding noise. An aria-label on the button works too; visually hidden text has the small advantage of being translated by browser translation tools.

An Accessible Dialog with a Form

Dialogs are where the primitives earn their keep. When a Radix dialog opens, focus moves inside it, Tab cycles only within it, the rest of the page is hidden from assistive technology, Escape closes it, and focus returns to the trigger when it closes. You get all of that from the generated component.

What you must provide is a title, and ideally a description. Here's a "New post" dialog that submits to a Server Action:

// app/posts/actions.ts
"use server";

import { revalidatePath } from "next/cache";

export type CreatePostState = {
  ok: boolean;
  errors?: { title?: string };
};

export async function createPost(
  _prev: CreatePostState,
  formData: FormData,
): Promise<CreatePostState> {
  const title = String(formData.get("title") ?? "").trim();

  if (title.length < 3) {
    return {
      ok: false,
      errors: { title: "Title must be at least 3 characters." },
    };
  }

  // Save to your database here, for example:
  // await db.post.create({ data: { title } });

  revalidatePath("/posts");
  return { ok: true };
}
// app/posts/new-post-dialog.tsx
"use client";

import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createPost, type CreatePostState } from "./actions";

const initialState: CreatePostState = { ok: false };

export function NewPostDialog() {
  const [open, setOpen] = useState(false);

  async function submit(prev: CreatePostState, formData: FormData) {
    const result = await createPost(prev, formData);
    if (result.ok) setOpen(false);
    return result;
  }

  const [state, formAction, pending] = useActionState(submit, initialState);
  const titleError = state.errors?.title;

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button>New post</Button>
      </DialogTrigger>
      <DialogContent>
        <form action={formAction} className="grid gap-4">
          <DialogHeader>
            <DialogTitle>Create a post</DialogTitle>
            <DialogDescription>
              Give your post a title. You can add content later.
            </DialogDescription>
          </DialogHeader>

          <div className="grid gap-2">
            <Label htmlFor="title">Title</Label>
            <Input
              id="title"
              name="title"
              required
              aria-invalid={titleError ? true : undefined}
              aria-describedby={titleError ? "title-error" : undefined}
            />
            {titleError && (
              <p id="title-error" className="text-sm text-destructive">
                {titleError}
              </p>
            )}
          </div>

          <DialogFooter>
            <DialogClose asChild>
              <Button type="button" variant="outline">
                Cancel
              </Button>
            </DialogClose>
            <Button type="submit" disabled={pending}>
              {pending ? "Creating..." : "Create"}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}

The accessibility details, in order:

  • DialogTrigger asChild merges the trigger behavior into your Button instead of nesting a button inside a button. The rendered element gets aria-haspopup, aria-expanded, and aria-controls automatically.
  • DialogTitle becomes the dialog's accessible name through aria-labelledby. Radix logs a console error in development if a dialog has no title, because an unnamed dialog is confusing to screen reader users. If your design has no visible title, keep the element and hide it with className="sr-only".
  • DialogDescription is wired to aria-describedby, so it's read after the title. If you really have nothing to describe, pass aria-describedby={undefined} to DialogContent to opt out explicitly and silence the warning.
  • Label htmlFor="title" matches the input's id. Clicking the label focuses the input, and screen readers announce "Title, edit text". Placeholder text is not a substitute for a label.
  • aria-invalid and aria-describedby connect the error message to the input. When the field is focused, a screen reader reads the label, then the error. The generated Input also has aria-invalid: styles, so the red border comes for free.
  • DialogClose asChild gives you a Cancel button that closes the dialog and returns focus to the trigger.

The dialog is controlled with open and onOpenChange so it can close after a successful submission. Server-side validation is shown here for brevity; for schema-based validation, see Form Validation in Next.js with Zod and Server Actions.

Using the Dialog from a Server Component

Because NewPostDialog is a Client Component, the page that renders it can stay on the server:

// app/posts/page.tsx
import { NewPostDialog } from "./new-post-dialog";

export default async function PostsPage() {
  // Fetch posts on the server here
  return (
    <main className="mx-auto max-w-3xl p-8">
      <div className="mb-6 flex items-center justify-between">
        <h1 className="text-2xl font-semibold">Posts</h1>
        <NewPostDialog />
      </div>
      {/* post list */}
    </main>
  );
}

A Keyboard-Friendly User Menu

Menus have more keyboard behavior than people expect. The WAI-ARIA menu pattern calls for arrow keys to move between items, Home and End to jump, typing a letter to jump to a matching item, Escape to close and return focus, and Enter or Space to activate. The Radix dropdown implements all of it.

// components/user-menu.tsx
"use client";

import Link from "next/link";
import { LogOut, Settings, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

type UserMenuProps = {
  name: string;
  email: string;
  signOut: () => Promise<void>;
};

export function UserMenu({ name, email, signOut }: UserMenuProps) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon" className="rounded-full">
          <User aria-hidden="true" />
          <span className="sr-only">Open account menu</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-56">
        <DropdownMenuLabel>
          <span className="block font-medium">{name}</span>
          <span className="block text-xs text-muted-foreground">{email}</span>
        </DropdownMenuLabel>
        <DropdownMenuSeparator />
        <DropdownMenuItem asChild>
          <Link href="/account">
            <User aria-hidden="true" />
            Profile
          </Link>
        </DropdownMenuItem>
        <DropdownMenuItem asChild>
          <Link href="/settings">
            <Settings aria-hidden="true" />
            Settings
          </Link>
        </DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem onSelect={() => signOut()}>
          <LogOut aria-hidden="true" />
          Sign out
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Points worth noting:

  • The trigger has a text label. "Open account menu" tells screen reader users what the avatar button does.
  • DropdownMenuItem asChild with Link renders a real link with the menu item role and keyboard handling, so navigation still goes through Next.js client-side routing and prefetching.
  • onSelect is the menu's activation event. It fires for mouse clicks, Enter, and Space alike, so you don't need separate keyboard handlers. Here it calls a Server Action passed in from a Server Component parent.
  • DropdownMenuLabel and separators are presentational groupings. The label isn't focusable, so arrow keys skip it.

One rule to keep in mind: menus are for actions and navigation inside a widget, not for site navigation. Your main site navigation should stay a list of links inside a nav element, which is simpler for everyone. The responsive navigation menu post covers that pattern.

Form Fields Beyond the Basics

The dialog example showed the core pattern for a field: label, input, error, all connected. A few more rules cover most forms.

Wrap the Pattern in a Component

When every field needs the same id, aria-describedby, and error wiring, a small wrapper prevents mistakes:

// components/form-field.tsx
"use client";

import { useId } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

type FormFieldProps = React.ComponentProps<typeof Input> & {
  label: string;
  hint?: string;
  error?: string;
};

export function FormField({
  label,
  hint,
  error,
  ...inputProps
}: FormFieldProps) {
  const id = useId();
  const hintId = `${id}-hint`;
  const errorId = `${id}-error`;

  const describedBy =
    [hint ? hintId : null, error ? errorId : null].filter(Boolean).join(" ") ||
    undefined;

  return (
    <div className="grid gap-2">
      <Label htmlFor={id}>{label}</Label>
      <Input
        id={id}
        aria-invalid={error ? true : undefined}
        aria-describedby={describedBy}
        {...inputProps}
      />
      {hint && (
        <p id={hintId} className="text-sm text-muted-foreground">
          {hint}
        </p>
      )}
      {error && (
        <p id={errorId} className="text-sm text-destructive">
          {error}
        </p>
      )}
    </div>
  );
}

useId generates IDs that match between server and client rendering, so there are no hydration mismatches, and every instance gets a unique ID even when the same field appears twice on a page. Hints and errors are both included in aria-describedby, separated by a space, so screen readers read both.

Required Fields and Error Summaries

  • Mark required fields with the native required attribute. Browsers expose it to assistive technology, and you can show an asterisk visually as long as the label also makes sense without it.
  • After a failed submit, move focus to the first invalid field, or to an error summary at the top of the form that links to each field. Otherwise keyboard users may not realize the submission failed.
  • Don't rely on color alone for errors. The message text matters more than the red border.

Theming Without Breaking Contrast

shadcn/ui themes are CSS variables in globals.css, which makes rebranding easy and makes it easy to ship low-contrast combinations. When you change tokens:

  • Check --foreground on --background, --primary-foreground on --primary, and --muted-foreground on --background against WCAG AA: 4.5:1 for body text, 3:1 for large text and UI component boundaries.
  • Check both themes. A palette that passes in light mode often fails in dark mode, especially muted text.
  • Keep --ring clearly visible against both backgrounds. It's the only thing showing keyboard users where they are.

DevTools in Chromium and Firefox both show contrast ratios in the color picker, which is the quickest way to check as you go.

Testing Accessibility

The primitives give you a strong starting point, but your composition can still break things. A quick routine catches most issues:

  1. Keyboard only. Unplug the mouse (figuratively). Tab through the page, open every dialog and menu, submit forms, and close everything with Escape. Focus should always be visible and should never get lost behind an overlay.
  2. Automated checks. Run axe DevTools or Lighthouse's accessibility audit on key pages. In end-to-end tests, @axe-core/playwright can fail the build on violations; the Playwright testing post shows how to set up the test runner.
  3. A real screen reader. VoiceOver on macOS (Cmd+F5) or NVDA on Windows. Open the dialog and listen: you should hear the title and description. Open the menu: you should hear the trigger's label and the item count.
  4. Lint. Next.js's ESLint config includes eslint-plugin-jsx-a11y rules that catch issues like missing alt text and invalid ARIA attributes while you code.

Conclusion

shadcn/ui gives you accessible behavior without a black-box dependency. The Radix primitives underneath handle focus management, ARIA wiring, and keyboard interaction for dialogs, menus, and other complex widgets, and because the components live in your repo, you can restyle them freely.

The parts that remain yours are about content and composition: give every dialog a title, every icon button a label, and every input a Label plus an error connected with aria-describedby; use asChild to merge behavior into your own buttons and links instead of nesting them; keep focus rings and contrast intact when you theme. Do those consistently and test with a keyboard, and your Next.js UI will work for everyone who uses it.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading