---
title: "Accessible patterns (paste-ready) (a11y)"
description: "React 19 + Tailwind v4 with void tokens (bg-surface-raised, text-fg, border-line, outline-line-focus…)."
canonical: https://void-design.vercel.app/docs/a11y/patterns
lastModified: 2026-09-16
---

# Accessible patterns (paste-ready)

React 19 + Tailwind v4 with void tokens (`bg-surface-raised`, `text-fg`, `border-line`, `outline-line-focus`…). Each pattern follows the WAI-ARIA Authoring Practices keyboard model. **Prefer a primitive library (Radix, Base UI, React Aria) for menus, comboboxes, selects and date pickers.** These snippets are for zero-dependency cases and for understanding what a primitive must do.

Contents: 1 utilities · 2 skip link · 3 icon button · 4 disclosure (details, nav menu) · 5 dialog · 6 tabs · 7 actions menu · 8 toast / live region · 9 form field · 10 checklist per widget

## 1. Utilities

```css
/* globals.css: Tailwind v4 ships sr-only / not-sr-only; these are for non-Tailwind stacks */
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }

@layer base {
  :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible { outline: 2px solid var(--line-focus); outline-offset: 2px; }
  html:has(dialog:modal) { overflow: hidden; }       /* scroll lock while a modal is open */
  html { scrollbar-gutter: stable; }                  /* no layout shift when scroll locks */
  @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 1ms !important; animation-duration: 1ms !important; animation-delay: 0ms !important; } }
}
```

## 2. Skip link

```tsx
// first child of <body>
<a href="#main"
   className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-surface-raised focus:px-4 focus:py-2 focus:text-fg focus:shadow-2">
  Skip to content
</a>
// …
<main id="main">{children}</main>
```

## 3. Icon button (name is type-required)

```tsx
import type { ButtonHTMLAttributes, ReactNode } from "react";

type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "children"> & {
  label: string;           // required: becomes the accessible name
  icon: ReactNode;         // e.g. <X aria-hidden />
};

export function IconButton({ label, icon, className = "", type = "button", ...rest }: IconButtonProps) {
  return (
    <button type={type} aria-label={label} className={`relative inline-grid size-9 place-items-center rounded-md text-fg-muted hover:bg-surface-hover hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus before:absolute before:-inset-1 before:content-[''] pointer-coarse:before:-inset-1.5 ${className}`} {...rest}>
      {icon}
    </button>
  );
}
```

`size-9` (36px) plus a `-inset-1` pseudo-element gives a 44px hit area; `pointer-coarse:` widens it further on touch. Tooltips supplement the label, they don't replace it.

## 4. Disclosure

### 4a. Zero-JS: `<details>`

```tsx
<details className="group rounded-lg border border-line" name="faq">   {/* same name = only one open at a time */}
  <summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-4 py-3 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus [&::-webkit-details-marker]:hidden">
    How much does Acme cost?
    <ChevronDown aria-hidden className="size-4 transition-transform duration-(--duration-fast) group-open:rotate-180" />
  </summary>
  <div className="px-4 pb-4 text-fg-muted">Free for open source. Team is $12 per seat per month.</div>
</details>
```

Key facts that search and AI should read: render them `open` or as plain sections (see the `seo` skill).

### 4b. Mobile navigation menu (disclosure, not `role="menu"`)

Site navigation is a list of links behind a toggle. **Don't** use `role="menu"` for it (menu roles are for app-style action menus).

```tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useId, useRef, useState } from "react";

export function MobileNav({ links }: { links: { href: string; label: string }[] }) {
  const [open, setOpen] = useState(false);
  const id = useId();
  const pathname = usePathname();
  const button = useRef<HTMLButtonElement>(null);

  useEffect(() => setOpen(false), [pathname]);                 // close on navigation

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { setOpen(false); button.current?.focus(); } };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open]);

  return (
    <nav aria-label="Primary" className="md:hidden">
      <button ref={button} type="button" aria-expanded={open} aria-controls={id} onClick={() => setOpen((o) => !o)}
              className="inline-flex min-h-11 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus">
        <span aria-hidden>{open ? "✕" : "☰"}</span> Menu
      </button>
      <ul id={id} hidden={!open} className="absolute inset-x-0 top-full border-b border-line bg-surface-raised p-2">
        {links.map((l) => (
          <li key={l.href}>
            <Link href={l.href} aria-current={pathname === l.href ? "page" : undefined}
                  className="block min-h-11 rounded-md px-3 py-2.5 hover:bg-surface-hover aria-[current=page]:text-brand-text">
              {l.label}
            </Link>
          </li>
        ))}
      </ul>
    </nav>
  );
}
```

The desktop nav renders the same links as a plain visible list. Don't ship two full copies of a large nav; hide with CSS breakpoints on one list where possible.

## 5. Modal dialog (native `<dialog>`)

`showModal()` makes the rest of the page inert, traps Tab inside, closes on Esc (`cancel` event), focuses the first focusable element (or `initialFocus`), and returns focus to the opener on close. Support: all browsers since 2022. `closedby="any"` (light dismiss) is Chromium 134+ and Firefox 141+ only, so the component below handles backdrop clicks itself.

```tsx
"use client";
import { useEffect, useId, useRef, type ReactNode, type RefObject } from "react";

type ModalProps = {
  open: boolean;
  onClose: () => void;                          // parent sets open=false
  title: string;
  description?: string;
  initialFocus?: RefObject<HTMLElement | null>; // e.g. the least destructive button
  children: ReactNode;
};

export function Modal({ open, onClose, title, description, initialFocus, children }: ModalProps) {
  const ref = useRef<HTMLDialogElement>(null);
  const downOnBackdrop = useRef(false);
  const titleId = useId();
  const descId = useId();

  useEffect(() => {
    const d = ref.current;
    if (!d) return;
    if (open && !d.open) { d.showModal(); initialFocus?.current?.focus(); }
    if (!open && d.open) d.close();
  }, [open, initialFocus]);

  return (
    <dialog
      ref={ref}
      aria-labelledby={titleId}
      aria-describedby={description ? descId : undefined}
      onClose={onClose}                                              // fires for Esc, form[method=dialog], and d.close()
      onPointerDown={(e) => { downOnBackdrop.current = e.target === e.currentTarget; }}
      onClick={(e) => { if (downOnBackdrop.current && e.target === e.currentTarget) ref.current?.close(); }} // backdrop click
      className="m-auto w-[min(32rem,calc(100vw-2rem))] rounded-xl border border-line bg-surface-raised p-0 text-fg shadow-3 backdrop:bg-black/50"
    >
      <div className="p-6">                                          {/* fills the dialog so clicks inside never hit the backdrop test */}
        <h2 id={titleId} className="text-lg font-semibold">{title}</h2>
        {description && <p id={descId} className="mt-1 text-fg-muted">{description}</p>}
        <div className="mt-4">{children}</div>
        <form method="dialog" className="mt-6 flex justify-end gap-2">
          <button className="min-h-10 rounded-md px-4 hover:bg-surface-hover">Cancel</button>
        </form>
      </div>
    </dialog>
  );
}
```

```tsx
// usage
const [open, setOpen] = useState(false);
const cancelRef = useRef<HTMLButtonElement>(null);
<button type="button" onClick={() => setOpen(true)}>Delete project</button>
<Modal open={open} onClose={() => setOpen(false)} title="Delete project?" description="This removes 12 deployments. You can't undo it.">
  <button type="button" className="bg-danger text-fg-on-brand …" onClick={() => { deleteProject(); setOpen(false); }}>Delete</button>
</Modal>
```

Rules:
- The title is required (`aria-labelledby`). Destructive dialogs initially focus Cancel.
- Portal isn't needed: `showModal()` renders in the top layer above everything, including `transform` ancestors.
- Entry animation with `@starting-style` (see `speed/references/rendering-smoothness.md` §6). No animation under reduced motion.
- Drawers and sheets: the same element, positioned at an edge (`m-0 ml-auto h-dvh max-h-none`).
- Non-modal overlays (tooltips, simple link popovers): use the `popover` attribute instead of `show()`.

Zero-JS open with invoker commands (Baseline newly available, Safari 26.2+; keep the React version for older browsers):

```html
<button type="button" commandfor="confirm" command="show-modal">Delete project</button>
<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Delete project?</h2>
  <button type="button" commandfor="confirm" command="close">Cancel</button>
</dialog>
```

## 6. Tabs (roving tabindex, automatic activation)

```tsx
"use client";
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";

type TabItem = { id: string; label: string; panel: ReactNode };

export function Tabs({ items, label, defaultId }: { items: TabItem[]; label: string; defaultId?: string }) {
  const base = useId();
  const [active, setActive] = useState(defaultId ?? items[0]?.id);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);

  function onKeyDown(e: KeyboardEvent<HTMLButtonElement>, i: number) {
    const last = items.length - 1;
    const next =
      e.key === "ArrowRight" ? (i === last ? 0 : i + 1) :
      e.key === "ArrowLeft" ? (i === 0 ? last : i - 1) :
      e.key === "Home" ? 0 :
      e.key === "End" ? last : -1;
    if (next === -1) return;
    e.preventDefault();
    setActive(items[next].id);
    tabs.current[next]?.focus();
  }

  return (
    <div>
      <div role="tablist" aria-label={label} className="flex gap-1 border-b border-line">
        {items.map((t, i) => {
          const selected = t.id === active;
          return (
            <button
              key={t.id}
              ref={(el) => { tabs.current[i] = el; }}
              type="button"
              role="tab"
              id={`${base}-tab-${t.id}`}
              aria-selected={selected}
              aria-controls={`${base}-panel-${t.id}`}
              tabIndex={selected ? 0 : -1}
              onClick={() => setActive(t.id)}
              onKeyDown={(e) => onKeyDown(e, i)}
              className="-mb-px min-h-11 border-b-2 border-transparent px-3 text-fg-muted aria-selected:border-brand aria-selected:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus"
            >
              {t.label}
            </button>
          );
        })}
      </div>
      {items.map((t) => (
        <div key={t.id} role="tabpanel" id={`${base}-panel-${t.id}`} aria-labelledby={`${base}-tab-${t.id}`}
             hidden={t.id !== active} tabIndex={0} className="py-4 focus-visible:outline-2 focus-visible:outline-line-focus">
          {t.panel}
        </div>
      ))}
    </div>
  );
}
```

- Tab moves into the tablist, arrow keys move between tabs, and Tab again moves into the panel.
- Don't animate tab switches triggered by the keyboard.
- If the tab state should survive reload or sharing, put it in the URL (`?tab=`).
- Content in hidden panels is still in the HTML but hidden. Don't put the page's key facts only in non-default tabs.

## 7. Actions menu (menu button)

For app-style action lists ("Rename, Duplicate, Delete"). Use Radix `DropdownMenu` / Base UI `Menu` / React Aria `Menu` when you also need submenus, typeahead, checkbox items or collision-aware positioning.

```tsx
"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";

type Item = { label: string; onSelect: () => void; destructive?: boolean };

export function ActionsMenu({ label, items }: { label: string; items: Item[] }) {
  const [open, setOpen] = useState(false);
  const id = useId();
  const root = useRef<HTMLDivElement>(null);
  const trigger = useRef<HTMLButtonElement>(null);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const focusAt = (i: number) => refs.current[(i + items.length) % items.length]?.focus();

  const openAt = (i: number) => { setOpen(true); requestAnimationFrame(() => focusAt(i)); };
  const close = (restore = true) => { setOpen(false); if (restore) trigger.current?.focus(); };

  useEffect(() => {
    if (!open) return;
    const onPointerDown = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); };
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open]);

  function onTriggerKey(e: KeyboardEvent) {
    if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") { e.preventDefault(); openAt(0); }
    if (e.key === "ArrowUp") { e.preventDefault(); openAt(items.length - 1); }
  }
  function onItemKey(e: KeyboardEvent, i: number) {
    const map: Record<string, () => void> = {
      ArrowDown: () => focusAt(i + 1), ArrowUp: () => focusAt(i - 1),
      Home: () => focusAt(0), End: () => focusAt(items.length - 1),
      Escape: () => close(), Tab: () => close(false),
    };
    const fn = map[e.key];
    if (!fn) return;
    if (e.key !== "Tab") e.preventDefault();
    fn();
  }

  return (
    <div ref={root} className="relative inline-block">
      <button ref={trigger} type="button" aria-haspopup="menu" aria-expanded={open} aria-controls={id}
              onClick={() => (open ? close() : openAt(0))} onKeyDown={onTriggerKey}
              className="min-h-10 rounded-md border border-line px-3 hover:bg-surface-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus">
        {label}
      </button>
      <div id={id} role="menu" aria-label={label} hidden={!open}
           className="absolute right-0 z-20 mt-1 min-w-44 rounded-lg border border-line bg-surface-raised p-1 shadow-2">
        {items.map((it, i) => (
          <button key={it.label} ref={(el) => { refs.current[i] = el; }} type="button" role="menuitem" tabIndex={-1}
                  onClick={() => { close(); it.onSelect(); }} onKeyDown={(e) => onItemKey(e, i)}
                  className={`block w-full min-h-9 rounded-md px-3 text-left hover:bg-surface-hover focus:bg-surface-hover focus:outline-hidden ${it.destructive ? "text-danger" : ""}`}>
            {it.label}
          </button>
        ))}
      </div>
    </div>
  );
}
```

- Menu items get `tabIndex={-1}` and are reached with arrows. Focus shows as a background highlight (`focus:bg-surface-hover`), which must reach ≥ 3:1 against the menu surface or be paired with a ring.
- Right-click context menus appear instantly, with no animation.

## 8. Toasts and live regions

Recommended: `sonner` (~9.4 KB gzip), mounted once in the root layout, wired to tokens:

```tsx
// app/layout.tsx
import { Toaster } from "sonner";
<Toaster position="bottom-right" toastOptions={{ className: "bg-surface-raised text-fg border border-line" }} />
// anywhere in a client component
import { toast } from "sonner";
toast.success("Project renamed");
toast.error("Couldn't save. Check your connection and try again.");
```

Zero-dependency version. The regions exist from first render and only their text changes:

```tsx
"use client";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";

type Toast = { id: number; text: string; kind: "status" | "alert" };
const Ctx = createContext<(text: string, kind?: Toast["kind"]) => void>(() => {});
export const useToast = () => useContext(Ctx);

export function ToastProvider({ children }: { children: ReactNode }) {
  const [toasts, setToasts] = useState<Toast[]>([]);
  const dismiss = (id: number) => setToasts((t) => t.filter((x) => x.id !== id));
  const notify = useCallback((text: string, kind: Toast["kind"] = "status") => {
    const id = Date.now() + Math.random();
    setToasts((t) => [...t, { id, text, kind }]);
    if (kind === "status") setTimeout(() => dismiss(id), 6000);   // errors stay until dismissed
  }, []);

  const list = (kind: Toast["kind"]) =>
    toasts.filter((t) => t.kind === kind).map((t) => (
      <div key={t.id} className="pointer-events-auto flex items-start gap-3 rounded-lg border border-line bg-surface-raised px-4 py-3 text-fg shadow-2">
        <p className="flex-1">{t.text}</p>
        <button type="button" onClick={() => dismiss(t.id)} aria-label="Dismiss notification" className="text-fg-muted hover:text-fg">✕</button>
      </div>
    ));

  return (
    <Ctx.Provider value={notify}>
      {children}
      <div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-2">
        <div role="status" aria-live="polite" className="flex flex-col gap-2">{list("status")}</div>
        <div role="alert" aria-live="assertive" className="flex flex-col gap-2">{list("alert")}</div>
      </div>
    </Ctx.Provider>
  );
}
```

- Toasts are for the results of mutations, never for navigation or selection.
- Position them `fixed` so they don't cause layout shift. Keep them visible ≥ 6 s, and errors until dismissed. Never put the only copy of important information in a toast.
- An action inside a toast (Undo) must also be reachable another way, or the toast persists until handled.

## 9. Form field with error

```tsx
"use client";
import { useId } from "react";

export function EmailField({ error }: { error?: string }) {
  const id = useId();
  const errId = `${id}-error`;
  const hintId = `${id}-hint`;
  return (
    <div className="grid gap-1.5">
      <label htmlFor={id} className="text-sm font-medium text-fg">Work email</label>
      <input
        id={id} name="email" type="email" autoComplete="email" spellCheck={false} required
        aria-invalid={error ? true : undefined}
        aria-describedby={`${hintId}${error ? ` ${errId}` : ""}`}
        className="min-h-11 rounded-md border border-line-strong bg-bg px-3 text-base text-fg placeholder:text-fg-subtle aria-[invalid=true]:border-danger focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-line-focus"
        placeholder="dana@company.com"
      />
      <p id={hintId} className="text-sm text-fg-muted">We'll send the invite here.</p>
      {error && <p id={errId} className="text-sm text-danger">⚠ {error}</p>}
    </div>
  );
}
```

On submit with errors: `form.querySelector<HTMLElement>("[aria-invalid=true]")?.focus()`. Server Actions: return field errors and render them the same way. `useActionState` keeps the typed values.

## 10. Checklist per widget

| Widget | Role / element | Keyboard | State attributes |
|---|---|---|---|
| Button | `<button type="button">` | Enter, Space | `aria-pressed` (toggle), `aria-expanded` (disclosure), `disabled`/`aria-disabled` |
| Link | `<a href>` | Enter | `aria-current="page"` for the current nav item |
| Disclosure | `<details>` or button + region | Enter, Space | `aria-expanded`, `aria-controls` |
| Modal dialog | `<dialog>` + `showModal()` | Tab trapped, Esc closes | `aria-labelledby`, `aria-describedby` |
| Tabs | `tablist` / `tab` / `tabpanel` | ←/→, Home/End | `aria-selected`, `aria-controls`, roving `tabIndex` |
| Menu button | button + `menu` / `menuitem` | ↓/↑ open, arrows move, Esc closes and returns focus | `aria-haspopup="menu"`, `aria-expanded` |
| Combobox / select | Use Radix/Base UI/React Aria, or native `<select>` | Arrows, typeahead, Enter, Esc | `aria-expanded`, `aria-activedescendant` |
| Switch | `<button role="switch">` or `<input type="checkbox" role="switch">` | Space | `aria-checked` |
| Toast | `role="status"` / `role="alert"` region | Dismiss button reachable | — |
| Carousel | `region` + `aria-roledescription="carousel"`, slides as groups | Prev/next buttons, pause button if autoplay | `aria-live="off"` while autoplaying, `polite` when manual |
| Tooltip | `popover` or primitive; supplements, never replaces, a label | Shows on focus, Esc hides | `aria-describedby` |

## Sources

- https://www.w3.org/WAI/ARIA/apg/patterns/ (dialog-modal, tabs, menu-button, disclosure, carousel, switch)
- https://www.w3.org/TR/WCAG22/ · https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum
- https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
- https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog · https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
- https://api.webstatus.dev/v1/features/dialog-closedby · …/invoker-commands · …/popover · …/details-name
- https://tailwindcss.com/docs/outline-style
- https://www.radix-ui.com/primitives · https://base-ui.com · https://react-spectrum.adobe.com/react-aria/ · https://sonner.emilkowal.ski
- https://bundlephobia.com (sizes checked 2026-09-16: @radix-ui/react-dialog 1.1.23, react-tabs, react-popover; @base-ui/react 1.8.0; react-aria-components 1.21.1; sonner 2.0.8)
