Accessibility
How Visor approaches WCAG 2.1 AA compliance and how to write accessible components and tests.
Overview
Visor components are built to meet WCAG 2.1 AA accessibility standards. This guide documents the patterns every component author must follow, how to use the axe-core test helper, and a checklist for reviewing new components.
All components ship with automated accessibility tests using axe-core integrated into the Vitest test suite.
Testing with axe-core
A checkA11y helper is provided in test-utils/a11y.ts. It runs axe-core WCAG 2.1 AA checks against a rendered container and throws a descriptive error if violations are found.
Usage
import { render } from "@testing-library/react"
import { describe, it } from "vitest"
import { Button } from "../button"
import { checkA11y } from "../../../../test-utils/a11y"
describe("accessibility", () => {
it("has no WCAG 2.1 AA violations", async () => {
const { container } = render(<Button>Click me</Button>)
await checkA11y(container)
})
})Every component test file must include an accessibility describe block
describe("accessibility", () => {
it("has no WCAG 2.1 AA violations", async () => {
const { container } = render(<MyComponent />)
await checkA11y(container)
})
})For interactive components with open/closed states (dialogs, tooltips, dropdowns, sheets), test both states:
describe("accessibility", () => {
it("has no WCAG 2.1 AA violations (closed state)", async () => {
const { container } = render(<Dialog><DialogTrigger>Open</DialogTrigger>...</Dialog>)
await checkA11y(container)
})
it("has no WCAG 2.1 AA violations (open state)", async () => {
const { container } = render(<Dialog open>...</Dialog>)
await checkA11y(container)
})
})Required Patterns for Component Authors
1. Semantic HTML
Use the correct HTML element for the job. Avoid using <div> or <span> where a semantic element exists.
| Use case | Correct element |
|---|---|
| Navigation | <nav> |
| Lists | <ol>, <ul> |
| Buttons | <button> |
| Form controls | <input>, <select>, <textarea> |
| Headings | <h1>–<h6> |
2. ARIA Labels
Every interactive control that doesn't have visible text must have an accessible name via:
aria-label— short, inline labelaria-labelledby— references a visible label elementhtmlFor+idpairing on<label>+ form control
// Good — visible label via htmlFor
<label htmlFor="email">Email address</label>
<input id="email" type="email" />
// Good — aria-label for icon-only button
<button aria-label="Close dialog">
<XIcon />
</button>
// Bad — no accessible name
<button><XIcon /></button>3. Form Controls Must Be Labeled
Every <input>, <textarea>, <select>, <Checkbox>, and <Switch> must have an accessible name:
// Option 1: htmlFor + id
<Label htmlFor="name">Full name</Label>
<Input id="name" />
// Option 2: aria-label
<Input aria-label="Search" type="search" />
// Option 3: aria-labelledby
<p id="hint">Enter your username</p>
<Input aria-labelledby="hint" />4. Keyboard Navigation
All interactive components must be fully keyboard-operable:
- Focus must be visible (never remove
:focus-visiblestyles) - Dialogs and sheets must trap focus when open
- Tooltips must be accessible on keyboard focus, not just hover
- Dropdown menus must follow arrow key navigation patterns
Radix UI primitives handle most keyboard interactions automatically for complex components (Dialog, DropdownMenu, Select, Tabs, etc.).
5. Color and Contrast
- Text must meet 4.5:1 contrast ratio against its background (AA normal text)
- Large text (18px+ or 14px+ bold) must meet 3:1
- UI components (focus rings, button borders) must meet 3:1 against adjacent colors
- Never use color alone to convey information — always pair with text, icons, or patterns
Since components use CSS custom properties from @loworbitstudio/visor-core, contrast is defined at the token level. Check token values when authoring new themes.
Text Token Contrast Reference
The following table documents the contrast ratio of each text token against a black (#000000) background, as measured against the stock dark theme (post-VI-180 fix values):
| Token | Dark bg (#000) ratio | WCAG level |
|---|---|---|
--text-primary | 8.6:1 | AAA |
--text-secondary | 9.92:1 | AAA |
--text-tertiary | 6.16:1 | AA |
--text-disabled | 1.17:1 | Exempt (WCAG 1.4.3 Note — disabled UI) |
--text-disabled must not be used for legible content — it intentionally falls below AA contrast and is exempt per WCAG 1.4.3 Note. Use it only for visually muted, non-interactive disabled-state labels where the disabled affordance is communicated by other means (e.g., disabled attribute, reduced opacity, aria-disabled).
6. Focus Management
- Dialogs and sheets: focus must move to the first focusable element inside when opened
- Dialogs and sheets: focus must return to the trigger element when closed
- Tooltips: use
aria-describedbyto associate tooltip content with the trigger
Radix UI handles this automatically for Dialog, Sheet, Select, and DropdownMenu.
7. ARIA Roles
Use ARIA roles only when native HTML semantics are insufficient:
// Built-in roles from Radix
<DropdownMenuContent role="menu" /> // handled by Radix
<DialogContent role="dialog" /> // handled by Radix
// Custom roles where needed
<div role="alert">...</div> // Alert, FieldError
<div role="group">...</div> // Field
<nav aria-label="breadcrumb">...</nav> // BreadcrumbAvoid adding redundant ARIA roles on elements that already have implicit roles (e.g., <button role="button">).
8. Decorative and Loading States
Purely decorative elements must be hidden from assistive technology:
// Decorative icon — hidden from AT
<XIcon aria-hidden="true" />
// Loading skeleton — decorative placeholder
<Skeleton aria-hidden="true" />
// Or provide a visible status elsewhere
<>
<p>Loading content...</p>
<Skeleton aria-hidden="true" />
</>Avoid using aria-label on <div> elements without a matching role. If you need to label a container, give it a role first:
// Wrong
<div aria-label="Loading">...</div>
// Correct
<div role="status" aria-label="Loading content">...</div>
// Or just hide it
<div aria-hidden="true">...</div>9. Live Regions
Use role="alert" or aria-live for dynamic content that needs to be announced:
// Alert component uses role="alert" — announced immediately
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to save changes.</AlertDescription>
</Alert>
// FieldError uses role="alert" — announced when it appears
<FieldError>This field is required</FieldError>WCAG 2.1 AA Checklist for New Components
Before submitting a component, confirm:
- All interactive elements have accessible names
- Focus order is logical and matches visual order
- Focus is visible (
:focus-visiblestyles present) - Keyboard navigation is fully functional
- Color contrast meets 4.5:1 (normal text) and 3:1 (large text, UI)
- Color is not used as the only means to convey information
- Decorative images and icons have
aria-hidden="true" - ARIA attributes match the element's role
- Interactive controls are not nested inside other interactive controls
- The component test file includes a
describe("accessibility")block - All a11y tests pass (
npm test)
Common axe-core Violations and Fixes
| Violation ID | Cause | Fix |
|---|---|---|
label | Input without accessible name | Add <label htmlFor>, aria-label, or aria-labelledby |
color-contrast | Insufficient contrast | Update token values or CSS |
nested-interactive | Button inside button | Use asChild prop or restructure markup |
aria-prohibited-attr | aria-label on element with incompatible role | Add a role or use aria-hidden |
button-name | Icon button with no label | Add aria-label to the button |
image-alt | <img> without alt | Add descriptive alt text, or alt="" if decorative |