Skip to main content

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 Button is a <button>. A Main is 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:

  1. Mount <ThemeProvider>, <TooltipProvider>, and <Toaster> at the root of the document tree.
  2. Place <SkipToMainLink /> as the very first focusable element in <body>, paired with a <Main> landmark wrapping the page's primary content.
  3. Render a single <h1> per page that names the page.
  4. Use real form elements with associated <Label>s.
  5. Set accessible names on icon-only controls: IconButton requires a label prop.

Skip-to-main and landmarks

Every page should be navigable by landmarks. The minimum structure:

import { SkipToMainLink } from "@ngrok/mantle/skip-to-main-link";import { Main } from "@ngrok/mantle/main"; export default function App() {	return (		<>			<SkipToMainLink />			<header>{/* nav */}</header>			<Main>				<h1>Page title</h1>				{/* primary content */}			</Main>			<footer>{/* … */}</footer>		</>	);}

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.

ComponentKeys
Button / IconButtonTab to focus, Enter or Space to activate.
AnchorTab to focus, Enter to follow.
Checkbox / SwitchTab to focus, Space to toggle.
RadioGroupTab to enter the group, Arrow keys to move and select within the group.
TabsTab to enter the tab list, Arrow Left/Right to move, Home/End to jump, Enter/Space to activate.
Dialog / AlertDialogFocus is trapped inside while open, Esc closes (Dialog only — AlertDialog requires explicit confirmation), Tab/Shift+Tab cycles focus.
SheetSame as Dialog.
Popover / HoverCardEsc closes; Tab cycles focusable content. HoverCard opens on hover/focus and is non-essential.
DropdownMenu / CommandArrow Up/Down to navigate items, Enter to select, Esc to close, type-ahead jumps to matching items.
Combobox / MultiSelect / SelectArrow Up/Down through options, Enter selects, type filters, Esc closes.
SliderArrow Left/Right (or Up/Down) increment/decrement, Home/End jump to min/max, PageUp/PageDown for larger steps.
DataTableRow click handlers respond to Enter when used with asChild rendering of an <a> or <button>. Header sort buttons follow Button keys above.
ToastAuto-dismissing toasts respect prefers-reduced-motion; F8 (browser default) cycles to the toast region.

Reserved keyboard shortcuts

mantle binds two global chords on window. Each listener calls preventDefault() when it handles the chord.

ChordOwnerTurn it off
⌘K (macOS), Ctrl+KCommand.DialogRoot opens the palettekeyboardShortcut={false}
⌘B (macOS), Ctrl+BSidebar.Root toggles the sidebarkeyboardShortcut={false}

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, and Sheet trap focus while open and restore it to the trigger on close.
  • Don't trap on non-modal surfaces. Popover and HoverCard allow focus to escape; that's intentional.
  • Programmatic focus is fine. When a route changes, focus the page's <h1> (or the <Main> landmark via document.getElementById("main")?.focus()) so screen reader users hear the page change.
  • Visible focus rings. Mantle uses :focus-visible so focus styles appear for keyboard users without distracting mouse users. Don't override outline without setting an equivalent ring.

ARIA roles and labels

Mantle components emit appropriate roles automatically. Consumers need to add:

  • label on IconButton, which it renders as aria-label. aria-label on any Button whose content 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).
  • Live-region semantics: use Toast for transient announcements; use the Alert component with role="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. Badge and Alert do 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:

@media (prefers-reduced-motion: reduce) {	.my-animation {		animation: none;	}}

Testing

Manual smoke checks per page or PR:

  1. Keyboard-only. Hide your mouse. 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.
  2. Skip link. First Tab from the top of the page should reveal the skip link; activating it should land focus inside <Main>.
  3. Screen reader. A quick pass with VoiceOver (Mac, Cmd+F5) or NVDA (Windows): every interactive element should announce its role and accessible name.
  4. Reduced motion. In OS settings, enable Reduce Motion. Repeat any animated flow.
  5. Forced colors. Turn on a Windows contrast theme, or emulate forced-colors: active in the Chrome DevTools Rendering panel. Confirm that every control and boundary stays visible.
  6. 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:

  1. Document it in a code comment that explains the why and links to the tracking issue.
  2. Communicate the limitation to the user: don't ship invisible inaccessibility.
  3. 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.