Accessibility
Mantle is built so the accessible thing and the easy thing are the same thing. Components render real semantic HTML, lean on battle-tested primitives (Radix, Ariakit, Headless UI) for complex interaction patterns, and ship with the keyboard, focus, and ARIA behavior already wired up. This page collects the cross-cutting guidance that doesn't fit on any single component page.
Principles
- Semantic HTML first. A
Buttonis a<button>. AMainis a<main>. Form controls are real<input>/<select>/<textarea>elements. See Philosophy for the full argument. - Composition over re-implementation. Where the platform doesn't ship a primitive (combobox, dialog, tabs), we wrap a vetted unstyled library; we never re-implement keyboard or ARIA logic from scratch.
- Progressive enhancement. CSS-failure and no-JS rendering should still produce a usable page where possible. The skip link, semantic landmarks, and form constraint validation all work without script.
- Contrast and reduced motion are not opt-ins. The light and dark themes both target WCAG AA contrast, and animations respect
prefers-reduced-motion.
Required app-level setup
For full accessibility, an app embedding mantle must:
- Mount
<ThemeProvider>,<TooltipProvider>, and<Toaster>at the root of the document tree. - Place
<SkipToMainLink />as the very first focusable element in<body>, paired with a<Main>landmark wrapping the page's primary content. - Render a single
<h1>per page that names the page. - Use real form elements with associated
<Label>s. - Set accessible names on icon-only controls:
IconButtonrequires alabelprop.
Skip-to-main and landmarks
Every page should be navigable by landmarks. The minimum structure:
SkipToMainLink is visually hidden until focused and uses the History API directly so it works in any framework. Main renders <main id="main" tabIndex={-1}> so the skip link can deliver focus without leaving a visible focus ring on the region itself.
Keyboard interactions per component
The table below summarizes the keyboard contract for the most common interactive components. Underlying primitives (Radix, Ariakit) implement the full WAI-ARIA pattern; these are the keys consumers most often need to remember.
Reserved keyboard shortcuts
mantle binds two global chords on window. Each listener calls preventDefault() when it handles the chord.
Do not bind these chords in your app as well. A listener on document or on an element runs before mantle's window listener, so both handlers run. If your handler calls preventDefault(), mantle's handler does nothing. To own a chord, set keyboardShortcut={false} and bind it yourself.
Focus management
- Trap on modal surfaces.
Dialog,AlertDialog, andSheettrap focus while open and restore it to the trigger on close. - Don't trap on non-modal surfaces.
PopoverandHoverCardallow focus to escape; that's intentional. - Programmatic focus is fine. When a route changes, focus the page's
<h1>(or the<Main>landmark viadocument.getElementById("main")?.focus()) so screen reader users hear the page change. - Visible focus rings. Mantle uses
:focus-visibleso focus styles appear for keyboard users without distracting mouse users. Don't overrideoutlinewithout setting an equivalent ring.
ARIA roles and labels
Mantle components emit appropriate roles automatically. Consumers need to add:
labelonIconButton, which it renders asaria-label.aria-labelon anyButtonwhose content is icon-only.aria-labelor visually-hidden text onKbdwhen the visible glyph is a symbol (e.g.⌘).aria-describedbylinking error/help text to form controls.Inputand friends do this for you when you use the validation prop, but custom messages should be wired manually.aria-current="page"on the active item in primary navigation (use react-router'sNavLinkfor this).- Live-region semantics: use
Toastfor transient announcements; use theAlertcomponent withrole="alert"for inline warnings.
Color contrast and themes
- Color tokens (
text-strong,text-muted,bg-form,border-accent-600, etc.) target WCAG AA contrast in both light and dark themes. - Mantle ships one light theme and one dark theme, and no separate high-contrast themes. People who need more contrast turn on their OS contrast setting, such as a Windows contrast theme, which forces its own colors onto the page.
- Don't communicate state by color alone. Use color and a label, icon, or copy.
BadgeandAlertdo this by default; custom components should follow suit.
Reduced motion
Animations across mantle (toasts, transitions, the password-input eye blink) honor prefers-reduced-motion. Custom animations should too:
Testing
Manual smoke checks per page or PR:
- Keyboard-only. Hide your mouse.
Tabfrom the top of the page. You should be able to reach every interactive element, see focus on each, activate it with the appropriate key, and never get stuck. - Skip link. First
Tabfrom the top of the page should reveal the skip link; activating it should land focus inside<Main>. - Screen reader. A quick pass with VoiceOver (Mac,
Cmd+F5) or NVDA (Windows): every interactive element should announce its role and accessible name. - Reduced motion. In OS settings, enable Reduce Motion. Repeat any animated flow.
- Forced colors. Turn on a Windows contrast theme, or emulate
forced-colors: activein the Chrome DevTools Rendering panel. Confirm that every control and boundary stays visible. - Zoom. Zoom to 200% in Chrome; content should reflow without horizontal scroll.
For automated testing, axe-core integrated via Playwright catches the bulk of WCAG issues. Mantle does not ship a custom axe runner, but the conventions in CONVENTIONS.md favor structures that pass it.
When you can't fix the accessibility issue
If a third-party widget or constraint makes a component temporarily inaccessible:
- Document it in a code comment that explains the why and links to the tracking issue.
- Communicate the limitation to the user: don't ship invisible inaccessibility.
- Add a fallback path (e.g. a plain
<a>with the same target) so keyboard and screen-reader users are not blocked.
The goal isn't perfect compliance forever; it's making the right choice the default and surfacing the exceptions honestly.