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.
Button is a <button>. A Main is a <main>. Form controls are real <input> / <select> / <textarea> elements. See Philosophy for the full argument.prefers-reduced-motion.For full accessibility, an app embedding mantle must:
<ThemeProvider>, <TooltipProvider>, and <Toaster> at the root of the document tree.<SkipToMainLink /> as the very first focusable element in <body>, paired with a <Main> landmark wrapping the page's primary content.<h1> per page that names the page.<Label>s.IconButton requires aria-label.Every page should be navigable by landmarks. The minimum structure:
1import { SkipToMainLink } from "@ngrok/mantle/skip-to-main-link";2import { Main } from "@ngrok/mantle/main";3 4export default function App() {5 return (6 <>7 <SkipToMainLink />8 <header>{/* nav */}</header>9 <Main>10 <h1>Page title</h1>11 {/* primary content */}12 </Main>13 <footer>{/* … */}</footer>14 </>15 );16}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.
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.
Dialog, AlertDialog, and Sheet trap focus while open and restore it to the trigger on close.Popover and HoverCard allow focus to escape — that's intentional.<h1> (or the <Main> landmark via document.getElementById("main")?.focus()) so screen reader users hear the page change.:focus-visible so focus styles appear for keyboard users without distracting mouse users. Don't override outline without providing an equivalent ring.Mantle components emit appropriate roles automatically. Consumers need to add:
aria-label on IconButton, and any Button whose label is icon-only.aria-label or visually-hidden text on Kbd when the visible glyph is a symbol (e.g. ⌘).aria-describedby linking error/help text to form controls. Input and 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's NavLink for this).Toast for transient announcements; use the Alert component with role="alert" for inline warnings.text-strong, text-muted, bg-form, border-accent-600, etc.) target WCAG AA contrast in both light and dark themes.@ngrok/mantle/mantle-light-high-contrast.css or @ngrok/mantle/mantle-dark-high-contrast.css to opt in.Badge and Alert do this by default; custom components should follow suit.Animations across mantle (toasts, transitions, the password-input eye blink) honor prefers-reduced-motion. Custom animations should too:
1@media (prefers-reduced-motion: reduce) {2 .my-animation {3 animation: none;4 }5}Manual smoke checks per page or PR:
Tab from 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.Tab from the top of the page should reveal the skip link; activating it should land focus inside <Main>.Cmd+F5) or NVDA (Windows) — every interactive element should announce its role and accessible name.*-high-contrast.css. Confirm legibility.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.
If a third-party widget or constraint makes a component temporarily inaccessible:
<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.