Accessibility
WCAG compliance, ARIA patterns, keyboard navigation, and screen reader support in Nim UI
Nim UI is built with accessibility as a core requirement, not an afterthought. All components target WCAG 2.1 Level AA compliance and follow WAI-ARIA authoring practices. This guide covers the accessibility features built into the library and how to maintain accessibility in your applications.
WCAG 2.1 AA Compliance
Nim UI components are designed to meet the four WCAG principles:
Perceivable
- Text and interactive colours are chosen against WCAG's ratios; the focus indicator is the one measured and build-guarded case (see Color Contrast Guidelines)
- Non-text content has text alternatives
- Content can be presented in different ways without losing meaning
- Components work without color as the sole indicator of state
Operable
- All functionality is available from the keyboard
- Users have enough time to interact with content (configurable timeouts on Toast)
- No content causes seizures or physical reactions
- Navigation mechanisms are consistent and predictable
Understandable
- Text is readable and understandable
- Components behave in predictable ways
- Error messages are clear and actionable
- Labels and instructions are provided for user input
Robust
- Content is compatible with assistive technologies
- Valid ARIA attributes are used correctly
- Components maintain accessibility across browsers and screen readers
ARIA Attributes by Component
Nim UI components include the appropriate ARIA attributes out of the box.
Button
// Standard button - no extra ARIA needed
<Button variant="primary">Save Changes</Button>
// Renders: <button>Save Changes</button>
// Button sets no default `type`, so the HTML default applies: inside a <form>
// this button SUBMITS. Pass type="button" when that is not what you want.
// Loading button - suppresses click activation, but stays focusable
<Button loading>Saving...</Button>
// Renders: <button aria-disabled="true" aria-busy="true"><span role="status">
// <svg aria-hidden="true" />
// </span>Saving...</button>
// NOT `disabled` on purpose: a native disabled button leaves the tab order, so a
// keyboard user loses focus to <body> on the interaction they just triggered.
// Accessible name is unchanged ("Saving...") — pass `loadingLabel` to add
// sr-only text, which renames the button while it loads.
// Icon-only button - requires aria-label
<Button variant="ghost" aria-label="Close dialog">
<XIcon />
</Button>Modal
<Modal isOpen={isOpen} onClose={handleClose} title="Confirm Action">
<p>Are you sure you want to proceed?</p>
</Modal>
// Renders with:
// - role="dialog"
// - aria-modal="true"
// - aria-labelledby pointing to the title
// - Focus trap keeping focus within the modal
// - Focus restored to trigger element on closeAlert
<Alert variant="destructive" title="Error">
Your session has expired. Please log in again.
</Alert>
// Renders with:
// - role="alert"
// - aria-live="assertive" (for destructive/warning)
// - aria-live="polite" (for info/success)Toast
showToast({
message: 'Changes saved successfully',
variant: 'success',
});
// Renders with:
// - role="status"
// - aria-live="polite"
// - Auto-dismissal on a plain timer (duration prop, default 5000ms) -- it is
// not motion-aware, so give anything a user must read a longer durationSkeleton
<SkeletonGroup loading={isLoading} fallback={<Skeleton className="h-4 w-40" />}>
<p>{user.name}</p>
</SkeletonGroup>
// Renders:
// <div>
// <span role="status" class="sr-only">Loading</span>
// <div aria-busy="true">
// <div aria-hidden="true" class="animate-pulse …" />
// </div>
// </div>
//
// - Each Skeleton is aria-hidden: placeholders carry no information, and a
// surface shows several of them
// - SkeletonGroup owns the single role="status" region (SC 4.1.3), so the
// announcement belongs to the surface, not to each placeholder
// - The region is a SIBLING of the aria-busy host: aria-busy defers
// announcements for its own subtree, which is the window we announce in
// - The region stays mounted across the transition; only its text changesTabs
<Tabs
tabs={[
{ id: 'profile', label: 'Profile', content: <ProfileTab /> },
{ id: 'settings', label: 'Settings', content: <SettingsTab /> },
]}
/>
// Renders with:
// - role="tablist" on the tab container
// - role="tab" on each tab button
// - role="tabpanel" on each content panel
// - aria-selected on the active tab
// - aria-controls linking tabs to panels
// - Arrow key navigation between tabsTooltip
<Tooltip content="Edit this item">
<Button variant="ghost" aria-label="Edit">
<EditIcon />
</Button>
</Tooltip>
// Renders with:
// - role="tooltip" on the tooltip content
// - aria-describedby linking the trigger to the tooltip
// - Shows on focus and hover
// - Dismisses on EscapeForm Inputs
<Input
aria-label="Email address"
aria-invalid={!!errors.email}
aria-describedby="email-error"
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}Keyboard Navigation
All interactive Nim UI components support keyboard navigation. Below is a reference for the keyboard patterns used.
Global Patterns
| Key | Action |
|---|---|
| Tab | Move focus to next interactive element |
| Shift + Tab | Move focus to previous interactive element |
| Enter | Activate focused button or link |
| Space | Activate focused button, toggle checkbox |
| Escape | Close modal, popover, tooltip, or dropdown |
Button
| Key | Action |
|---|---|
| Enter | Activate the button |
| Space | Activate the button |
Modal
| Key | Action |
|---|---|
| Escape | Close the modal |
| Tab | Cycle focus within the modal (focus trap) |
| Shift + Tab | Cycle focus backwards within the modal |
Tabs
| Key | Action |
|---|---|
| Arrow Left | Move to previous tab |
| Arrow Right | Move to next tab |
| Home | Move to first tab |
| End | Move to last tab |
| Enter / Space | Activate the focused tab |
Select / Dropdown
| Key | Action |
|---|---|
| Enter / Space | Open dropdown |
| Arrow Down | Highlight next option |
| Arrow Up | Highlight previous option |
| Enter | Select highlighted option |
| Escape | Close dropdown |
Popover
| Key | Action |
|---|---|
| Enter / Space | Toggle popover (click trigger) |
| Escape | Close popover |
| Tab | Move focus within popover content |
Screen Reader Support
Nim UI components are tested with popular screen readers including VoiceOver (macOS), NVDA (Windows), and JAWS (Windows).
Announcements
Components announce meaningful state changes to screen readers:
// Loading states — focus stays on the button and the name does NOT change
<Button loading>Submitting...</Button>
// Screen reader: "Submitting..., button, busy, unavailable"
// Opt-in sr-only text. It joins the accessible NAME (a button's descendants are
// presentational, so it is not a separate live announcement) — which means the
// focused button is re-announced in full. Use it deliberately, and put a live
// region OUTSIDE the button when you need a guaranteed status announcement.
<Button loading loadingLabel="Submitting">Save</Button>
// Screen reader: "Submitting Save, button, busy, unavailable"
// Error states
<Input error="Email is required" />
// Screen reader: "Email, invalid entry, Email is required"
// Progress
<Progress value={75} showLabel />
// Screen reader: "Progress, 75 percent"
// Alerts
<Alert variant="success">File uploaded successfully</Alert>
// Screen reader announces: "File uploaded successfully"Live Regions
Dynamic content updates are announced through ARIA live regions:
// Polite announcements (non-urgent)
<div aria-live="polite">
{searchResults.length} results found
</div>
// Assertive announcements (urgent)
<div role="alert" aria-live="assertive">
{errorMessage}
</div>Hidden Decorative Elements
Icons and decorative elements that do not convey information are hidden from screen readers:
// Decorative icon - hidden from AT
<span aria-hidden="true">★</span>
// Informative icon - accessible label provided
<span role="img" aria-label="Warning">⚠️</span>Focus Management
Proper focus management is critical for keyboard and screen reader users.
Focus Visible Styles
All Nim UI components include visible focus indicators for keyboard navigation. These styles only appear when navigating with the keyboard, not when clicking with a mouse.
The indicator is applied per component, in the component's own class string — the kit does not install an application-wide :focus-visible rule on your behalf, for the same reason it does not install a motion reset (section 3). So this is what a component ships, not a stylesheet you inherit:
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:focus-visible:outline-primary-400'The light/dark pair is mandatory, not decoration. WCAG 2.2 SC 1.4.11 wants 3:1 for a focus indicator and no single step of the steel scale clears it in both themes — primary-400 measures 2.84:1 on white, primary-500 measures 2.86:1 on the 800 surface. Two colours, one per theme, is the only way both pass.
That has a consequence when you override it. tailwind-merge keys on the modifier set, so a lone light-half class replaces only the light half and you get your colour in light mode and steel in dark. Replace both — the same override the customization guide shows:
<Button className="focus-visible:outline-purple-500 dark:focus-visible:outline-purple-500" />Two details worth knowing:
- It is an outline, not a ring. A
focus-visible:ring-*class will not replace the indicator — it paints a second one alongside. The kit moved off rings because a ring's offset band is an opaquebox-shadowthat must be given a colour, and a component library cannot know which surface you placed the control on. An outline's offset is a genuinely transparent gap, so it is correct on any background. - Do not add
focus-visible:outline-noneto a component that draws an indicator. It setsoutline-style: none, which erases the outline while leaving its width and colour computing normally — invisible in the browser and invisible to a class-name assertion.
A handful of components draw the indicator flush, without the offset utility. They are transparent at rest, so the indicator meets the surface behind them on both edges.
Focus Trapping
Modal and Popover components trap focus within their content area to prevent users from accidentally interacting with background content:
// Focus is automatically trapped when the modal opens
<Modal isOpen={isOpen} onClose={handleClose}>
<Input placeholder="First field (receives focus)" />
<Input placeholder="Second field" />
<Button onClick={handleClose}>Close</Button>
{/* Tab cycles between these elements only */}
</Modal>Focus Restoration
When a modal or popover closes, focus is returned to the element that triggered it:
function EditDialog() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
{/* Focus returns here when the modal closes */}
<Button onClick={() => setIsOpen(true)}>
Edit
</Button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<p>Edit content...</p>
<Button onClick={() => setIsOpen(false)}>Done</Button>
</Modal>
</>
);
}Color Contrast Guidelines
One combination is measured and guarded: the focus indicator. focus-ring-contrast.test.ts reads the compiled stylesheet, runs each shipped indicator colour through WCAG's contrast arithmetic, and fails the build under 3:1 in either theme — every indicator currently clears it at 4.09:1--5.16:1 light and 5.19:1--6.96:1 dark. Its surface list is hand-maintained, though, so a component on a background outside that list is unverified, and nothing here measures body text against every surface. Treat the thresholds below as what to check your own pairings against, not as a promise already kept for every combination.
Minimum Contrast Ratios
| Content Type | Minimum Ratio | Standard |
|---|---|---|
| Normal text | 4.5:1 | WCAG AA |
| Large text (24px+ regular, or 18.66px+ / 14pt bold) | 3:1 | WCAG AA |
| Interactive elements (borders, icons) | 3:1 | WCAG AA |
| Decorative elements | No requirement | - |
Testing Contrast
Use these tools to verify contrast ratios in your custom themes:
- Chrome DevTools -- Inspect element, view contrast ratio in the color picker
- WebAIM Contrast Checker -- webaim.org/resources/contrastchecker
- Stark -- Browser extension and Figma plugin for accessibility checking
- axe DevTools -- Automated accessibility testing in the browser
Do Not Rely on Color Alone
Always pair color with another visual indicator:
// Good - icon + color + text
<Alert variant="destructive">
<AlertIcon /> {/* Visual icon indicator */}
Error: File upload failed {/* Descriptive text */}
</Alert>
// Good - badge with text label
<Badge variant="success">Active</Badge>
// Bad - color is the only indicator
<span className="text-red-500">●</span> {/* No text alternative */}Reduced Motion
Nim UI honours prefers-reduced-motion automatically. There is no prop, no provider, and no consumer code to write: import the stylesheet and every entrance and exit animation in the kit -- and every transition that moves something -- switches itself off for users who have asked their OS for reduced motion.
It does that per component, in the component's own class string. The kit does not install an application-wide reset on your behalf; there is one available, opt-in, described in section 3.
1. Per-component motion-reduce:animate-none
Every component that animates on open or close ships a reduced-motion counterpart next to the animation itself. Popover's content, as shipped:
'z-50 w-72 rounded-md outline-none data-[state=open]:animate-fade-in data-[state=open]:motion-reduce:animate-none data-[state=closed]:animate-fade-out data-[state=closed]:motion-reduce:animate-none'The repeated data-[state=...] prefix on the counterpart is load-bearing, not noise. Tailwind compiles a data-* variant to an attribute selector, so data-[state=open]:animate-fade-in lands at specificity (0,2,0) while a bare motion-reduce:animate-none is only (0,1,0) -- a media query adds no specificity of its own. The counterpart has to carry the same modifier to reach the same specificity, and Tailwind emits the two-variant utility after the one-variant one, so it wins the tie.
This applies to Accordion, AlertDialog, Badge, Collapsible, Combobox, Drawer, DropdownMenu, Modal, Popover, Select, Toast, and Tooltip -- with the exception of Badge, whose animation carries no modifier and therefore uses the bare motion-reduce:animate-none.
Loading indicators are deliberately excluded, and they keep animating. Spinner, Skeleton, Dot, StatusPill, and Button's loading state carry a bare animate-spin / animate-pulse with no counterpart, on purpose: for an activity indicator the motion is the information -- it is the only signal that work is in flight -- and WCAG 2.2 SC 2.2.2 exempts an activity indicator on exactly that basis. A frozen spinner does not read as "reduced motion", it reads as a hung interface. If your product needs them damped, section 3 is how.
2. Per-component transition cover for the things that move
The same idea applies to transitions, on a different longhand. Every transition in the kit that can actually change an element's position or size -- the accordion and tree-view chevrons, the switch thumb, the meter / progress / bar-chart fills, the product-card zoom, the card lift, the toast swipe -- ships a counterpart that switches the property list off. Switch's thumb, as shipped:
'pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform motion-reduce:transition-none duration-(--duration-fast) ease-out dark:bg-neutral-100'transition-property: none means the transition never runs, rather than running an imperceptibly short one. The thumb still arrives at the checked position -- it just gets there instantly.
Colour and opacity transitions (about 55 of them) deliberately carry no counterpart. A crossfade moves nothing, so clamping it buys no accessibility and makes every hover in the kit feel broken. Nor does the 2-3% press squeeze on Button, CTA and the Toast action: it is an in-place squeeze the user initiates and releases, a control's own affordance rather than the large movement or parallax WCAG 2.3.3 is about.
3. The application-wide reset is opt-in
The kit also publishes a blanket reset, but it is not part of the default stylesheet. Deciding motion policy for a whole document is the application's call, not a component library's: the rule below has a * selector, !important declarations and no cascade layer, so it reaches every element on the page -- yours, ours, and any third-party widget rendered alongside -- with no way for anything to opt out of it.
If you want it, ask for it by name. One line, anywhere in your stylesheet:
@import '@nim-ui/components/reduced-motion.css';That is exactly this, and nothing else:
/* @nim-ui/components/reduced-motion.css */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}Two things to weigh before adding it. It reaches far beyond Nim -- every transition-* in your own code collapses to 0.01ms too. And it does not slow a looping animation, it stops it: after one 0.01ms iteration the element sits motionless at its un-animated position, so the kit's spinners and skeletons freeze rather than easing off. That trade is yours to make; the kit no longer makes it for you.
Sections 1 and 2 do not depend on this import. Entrance and exit animations, and the transitions that move, are covered either way.
Overriding an overlay animation yourself
cn() runs class names through tailwind-merge, but tailwind-merge resolves conflicts per modifier. A class with no modifier never overrides a class that has one:
// Does NOT work -- both classes survive, the animation still plays
<PopoverContent className="animate-none" />
// Works -- the modifier matches, so tailwind-merge sees a real conflict
<PopoverContent className="data-[state=open]:animate-none data-[state=closed]:animate-none" />The same rule applies to swapping one animation for another, and note that Tooltip opens on data-[state=delayed-open] rather than data-[state=open].
Swapping under a modifier the component already uses keeps its reduced-motion cover: tailwind-merge replaces only the animation, and the shipped data-[state=open]:motion-reduce:animate-none survives the merge. Introduce a new modifier and you own the counterpart:
// Reduced motion still handled -- the shipped counterpart is untouched
<PopoverContent className="data-[state=open]:animate-scale-in" />
// New modifier -- pair it yourself
<PopoverContent className="data-[side=top]:animate-slide-in-from-bottom data-[side=top]:motion-reduce:animate-none" />In your custom components, respect this preference:
import { useEffect, useState } from 'react';
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mediaQuery.matches);
const handler = (e: MediaQueryListEvent) => {
setPrefersReducedMotion(e.matches);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
return prefersReducedMotion;
}Accessibility Checklist
Use this checklist when building with Nim UI:
Structure
- Pages have a logical heading hierarchy (h1 > h2 > h3)
- Landmark regions are used (main, nav, aside, footer)
- Page title is descriptive and unique
Interactive Elements
- All buttons have visible text or
aria-label - All form inputs have associated labels
- Error messages are programmatically connected to inputs
- Custom interactive elements are keyboard accessible
Visual
- Text contrast meets 4.5:1 (normal) or 3:1 (large)
- Focus indicators are visible on all interactive elements
- Content does not rely on color alone to convey meaning
- Layout works at 200% zoom
Dynamic Content
- Modals trap focus and restore it on close
- Toast and alert content is announced via live regions
- Loading states are communicated to screen readers
- Route changes are announced in single-page applications
Testing
- Tested with keyboard only (no mouse)
- Tested with VoiceOver or NVDA
- Tested with axe DevTools or Lighthouse
- Tested with
prefers-reduced-motionenabled
Resources
- WAI-ARIA Authoring Practices -- Component patterns and best practices
- WebAIM -- Web accessibility evaluation tools and training
- The A11Y Project -- Community-driven accessibility resource
- Inclusive Components -- Accessible component design patterns
- axe DevTools -- Automated accessibility testing
What's Next?
- Best Practices -- General tips for building with Nim UI
- Customization -- Customizing component styles
- Colors -- Color palette with contrast guidelines