Skip to content

Accessibility

shadcn/ui gets its behaviour from Radix, which only runs in React. partialkit reaches the same place from two directions: browser primitives where the platform already implements the pattern, and an explicit ARIA layer where it does not.

Attributes you set yourself are never overwritten — every ARIA attribute is applied only when absent.

ConcernSource
Modal semantics, focus trap, Esc, background inert, focus restore<dialog>.showModal()
Menu top layer, light dismisspopover API
aria-labelledby / aria-describedby on dialogswired from .dialog-title / .dialog-description
role="menu", role="menuitem", role="separator"applied on mount and on every open
aria-haspopup, aria-controls, aria-expanded on triggerskept in sync on toggle
data-state="open" | "closed" on overlays and triggersthe runtime
data-disabled mirrored to aria-disabled, skipped by keyboard navdropdown menu runtime
role="tab", role="tabpanel", aria-selected, roving tabindextabs runtime
role="menubar" and a roving tab order across its triggersmenubar runtime
aria-roledescription="carousel", and “n of m” on every slidecarousel runtime
aria-describedby from a trigger to its tooltiptooltip runtime
Disclosure semantics on <details> / <summary>the browser — accordion and collapsible add none
Focus rings, :disabled, aria-invalid error statecomponent CSS, on native elements
prefers-reduced-motiontransitions collapse to 1 ms

The platform is not uniform, and the test suite runs on Chromium, Firefox and WebKit to catch it.

  • WebKit does not focus a button when you click it (a macOS convention), so document.activeElement is not a reliable record of what opened a dialog. partialkit tracks the opener explicitly.
  • WebKit clears focus after close instead of returning it. partialkit restores it, checking both during and after the event.
  • Tab inside a menu would let the default action move focus on before the trigger is restored, so the runtime handles it directly.
KeyAction
EscCloses and returns focus to whatever opened it
TabCycles within the dialog; the rest of the page is inert
KeyAction
/ Move between enabled items, wrapping at the ends
Home / EndFirst / last enabled item
a–z, 0–9Type-ahead to the first item starting with what you type
EscCloses and returns focus to the trigger
TabCloses and returns focus to the trigger
Enter / SpaceActivates the item — it is a real <button>

Disabled items carry aria-disabled="true" rather than disabled, so a screen reader still announces them while arrow navigation skips them.

The same table applies to the context menu — it is the same panel, opened by right click instead of by a trigger.

KeyAction
TabReaches the selected tab, then the panel — one stop for the whole list
/ Move between tabs, wrapping and skipping disabled ones
/ The same, when the list is vertical
Home / EndFirst / last tab
Enter / SpaceSelects, under data-activation="manual"
KeyAction
EscCloses either one; a popover returns focus to its trigger
TabMoves through a popover and out of it — it is not modal

A tooltip never takes focus: it opens when its trigger is focused and describes it through aria-describedby.

KeyAction
Enter / SpaceToggles the section
TabReaches each trigger, then the revealed content

Both are <details>/<summary>, so this comes from the browser rather than from partialkit.

  • Accessible names for icon-only controls. Use aria-label, or .sr-only text inside the button.
  • A heading in every dialog. .dialog-title becomes the accessible name.
  • lang on <html>.