Accessibility contract
Lumora's accessibility is part of the public API. Components ship keyboard states, ARIA semantics, focus rings, and AA-tested contrast — verified in CI on every release.
The promise
Every Lumora primitive is designed around five hard constraints. If a release would break any of them, we don't ship it.
Verified in CI
Two automated suites run on every commit and block merges if they fail.
Iterates every theme and asserts WCAG AA contrast ratio (4.5:1) on 10 critical foreground/background pairs — surface/text, primary/primary-fg, every semantic pair.
tests/themes.test.ts
Asserts that every documented selector, ARIA-state class, and component contract survives a release. Snapshots primary controls so styling regressions show up as diffs.
tests/core.test.ts
Run it yourself
pnpm test — both suites finish in under 2 seconds. Use them to validate custom themes too.Component patterns
Each component below ships specific keyboard, ARIA, and focus contracts. Treat this as the audit reference for your screen-reader and keyboard testing.
Button
role=Native button or link with button-like stylingtype on form buttonsaria-busy when loadingaria-disabled only when non-native
- ↹Enter activates
- ↹Space activates when rendered as button
Use the built-in focus-visible ring and never remove focus outlines without replacement.
Use native disabled for button elements; reserve aria-disabled for links or custom elements.
Form controls
role=Native input, select, textarea, checkbox, radio, or switchlabel or aria-labelaria-invalid for errorsaria-describedby for hints/errors
- ↹Tab moves between fields
- ↹Space toggles checkbox, radio, and switch
Focus stays on the edited field; validation messages must be announced through descriptions.
Do not rely on color alone for invalid, warning, or success states.
Tabs
role=tablist, tab, and tabpanelaria-selectedaria-controlsaria-labelledbytabindex
- ↹Arrow keys move between tabs
- ↹Home moves to first tab
- ↹End moves to last tab
Focus should move to the active tab; tab panels should not trap focus.
Use roving tabindex for interactive tab lists.
Dropdown
role=button plus menu/menuitem when used as an action menuaria-haspopuparia-expandedaria-controlsrole=menuitem
- ↹Enter or Space opens
- ↹Escape closes
- ↹Arrow keys move through menu items
Move focus into the menu on open and return focus to the trigger on close.
Use aria-disabled on disabled menu items and keep them focusable only when the pattern requires it.
Modal
role=dialogaria-modal=truearia-labelledby or aria-label
- ↹Escape closes when safe
- ↹Tab cycles inside the dialog
- ↹Shift+Tab cycles backward
Move focus to the first meaningful control on open and restore focus to the trigger on close.
Background content should be inert while the modal is open.
Drawer
role=dialog or complementary depending on behavioraria-modal=true for blocking drawersaria-labelledby or aria-label
- ↹Escape closes blocking drawers
- ↹Tab order remains contained when modal
Blocking drawers follow modal focus rules; persistent drawers must not trap focus.
Choose drawer semantics based on whether it blocks interaction with the page.
Tooltip
role=tooltiparia-describedby on triggerrole=tooltip on content
- ↹Appears on focus
- ↹Escape dismisses when interactive dismissal is implemented
Tooltip content does not receive focus unless it contains interactive content.
Tooltips must be dismissible, hoverable, and persistent enough to read.
Toast
role=status or alertrole=status for passive updatesrole=alert for urgent errors
- ↹Dismiss button is keyboard reachable when present
Do not steal focus for passive notifications.
Use polite announcements for success/info and assertive only for urgent failures.
Table
role=Native tablecaption or accessible name for complex tablesscope on header cells
- ↹Native reading and browser table navigation should remain intact
Interactive cells must have visible focus and predictable tab order.
Prefer native table markup for enterprise data grids until richer grid behavior is implemented.
Audit checklist
Run through this list when shipping a feature built on Lumora. Most items are already handled by the components — this is the residual surface area in your application code.
- Every interactive element receives keyboard focus and shows the Lumora focus ring.
- All form inputs have associated <label> elements (or aria-label).
- Hint text and error messages are linked via aria-describedby.
- Toast and live-update regions wrap content in role='status' or aria-live='polite'.
- Modals and drawers trap focus and restore it to the trigger on close.
- Color is never the only signal for state — pair with text or icon.
- Skip-link is the first focusable element on every page.
- Animations honor prefers-reduced-motion (Lumora handles this for shipped components).
- Heading order is not skipped — every page starts with a single <h1>.