Skip to content

Reference · a11y

Accessible patterns (paste-ready)

a11y/references/patterns.md445 linesupdated 16 Sept 2026

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; } }
}
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:

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 )

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("[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 Enter, Space aria-pressed (toggle), aria-expanded (disclosure), disabled/aria-disabled
Link Enter aria-current="page" for the current nav item
Disclosure
or button + region
Enter, Space aria-expanded, aria-controls
Modal 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 Arrows, typeahead, Enter, Esc aria-expanded, aria-activedescendant
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