Accessibility
Accessibility is part of the component contract
Cooud UI components ship with keyboard behavior, visible focus, semantic structure, and framework integration notes so teams do not rediscover the same edge cases.
Quality bars
Keyboard
Every interactive primitive has a visible focus state and expected arrow-key behavior.
Screen readers
Dialogs, sheets, command menus, forms, and toasts expose names, descriptions, and live states.
Contrast
Theme presets are validated against semantic foreground/background pairs before release.
Framework handoff
Each adapter documents where focus should move after navigation, submit, and dismiss actions.
Keyboard & focus
Every interactive primitive follows the WAI-ARIA keyboard pattern for its role, and shows a visible focus ring in both the dark and light presets.
Tab order matches the visual order, focus is managed inside overlays and menus, and Escape, arrow keys, and Enter behave as the pattern expects. The ring is driven by focus-visible so it appears for keyboard users without distracting pointer users. Each route also ships a skip link to #main-content and exactly one <main> landmark, so keyboard and screen-reader users can jump straight past the navigation.
// app/layout.tsx — one skip link, then exactly one <main> per route.<a href="#main-content" className="sr-only focus-visible:not-sr-only ...">Skip to content</a>{/* ...elsewhere in the route... */}<main id="main-content">{children}</main>
Accessible names
Everything interactive needs a name. Tie form controls to a Label, and give icon-only buttons an explicit aria-label.
Associate inputs with a Label via htmlFor, and name icon-only buttons — like the toast dismiss control — with an aria-label so they are not announced as unnamed buttons.
import { Button } from "@cooud-ui/ui";import { Label } from "@cooud-ui/ui";import { X } from "lucide-react";// Form controls get a real label tied to the input.<Label htmlFor="org-name">Organization</Label><Input id="org-name" />// Icon-only buttons have no text, so name them with aria-label.<Button variant="ghost" size="icon" aria-label="Dismiss"><X /></Button>
Dialog-like overlays must expose a title and description even when the visual design is compact, so the accessible name and context come through:
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@cooud-ui/ui";<Dialog><DialogContent><DialogTitle>Invite teammate</DialogTitle><DialogDescription>Send an invite with the correct role and organization scope.</DialogDescription></DialogContent></Dialog>
Color & contrast
Semantic tokens are authored as foreground/background pairs validated for WCAG AA contrast.
Components reference tokens such as fg on surface-base rather than raw colors, so restyling through a theme keeps text legible when the palette changes. Define new presets as paired values and contrast is preserved across modes.
See Theming for how the token pairs are structured and overridden.
Reduced motion
Animated components honour the visitor's prefers-reduced-motion setting by default.
Marquee defaults to motionPreference="respect", so it scrolls for everyone except visitors who prefer reduced motion, who see a static row. Reveal, Shimmer, and the other animated primitives collapse their motion under the same media query.
import { Marquee } from "@cooud-ui/ui";// Default motionPreference="respect" — scrolls for everyone except// visitors with prefers-reduced-motion, who get a static row.<Marquee>{logos}</Marquee>// Force or disable motion explicitly when a layout requires it.<Marquee motionPreference="always">{logos}</Marquee><Marquee motionPreference="never">{logos}</Marquee>
Complex widgets
When a widget — like a chart — can't be made node-accessible, provide a text alternative instead.
Data viz built on an SVG library exposes dozens of nameless shapes. Wrap the widget in role="img" with an aria-label that summarizes the data, and mark the rendered subtree aria-hidden so its internals are not announced.
// Charts can't be made node-accessible, so describe them with text.// The recharts subtree is hidden; the wrapper carries the summary.<divrole="img"aria-label="Donut chart of traffic sources by visitors: Direct 4,200, Organic 3,100, Referral 1,900, Social 1,400, Email 800."className="h-64 w-full"><div aria-hidden="true" className="h-full w-full"><ChartContainer config={chartConfig}>{/* recharts subtree */}</ChartContainer></div></div>
Release checklist
A new component is not considered ready until these checks are represented in docs or tests.
- Component has an accessible name or documents how consumers provide one.
- Keyboard behavior matches the WAI-ARIA pattern for the primitive.
- Focus is visible in dark and light presets.
- Disabled, invalid, loading, and empty states are represented.
- Examples include labels, descriptions, and error messages where relevant.
- Framework docs mention navigation focus handoff when routing can change focus.
Testing
Accessibility is gated automatically — axe-core scans the routes on every run.
The suite in e2e/a11y runs the full axe-core rule set scoped to WCAG 2 A and AA over each core route, and fails the build on any serious or critical violation. Run it with bun run test:a11y; teams can add their own routes to the list.
// e2e/a11y/core-routes.a11y.spec.ts — axe-core over every core route.const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();// Fail the gate on any serious/critical WCAG A/AA violation.const blocking = results.violations.filter((v) => v.impact === "serious" || v.impact === "critical",);expect(blocking).toEqual([]);
Run the same axe gate on your own components with @cooud-ui/ui/testing — Testing Library helpers that render under a scoped CooudUIProvider, find Radix surfaces through their portals, and assert zero axe violations. Both testing packages are optional peer dependencies of @cooud-ui/ui, so they never touch your app bundle:
npm i -D @testing-library/react vitest-axe
// invite-panel.test.tsx — Vitest + jsdom, using @cooud-ui/ui/testing.import { expectNoA11yViolations, findDialog, renderWithCooud } from "@cooud-ui/ui/testing";import { screen } from "@testing-library/react";import userEvent from "@testing-library/user-event";import { it } from "vitest";it("invite dialog stays accessible", async () => {const user = userEvent.setup();// render() wrapped in a scoped CooudUIProvider (a wrapper div, not <html>).const { baseElement, rerenderWithTheme } = renderWithCooud(<InvitePanel />, {theme: "aurora",mode: "dark",});await user.click(screen.getByRole("button", { name: "Invite teammate" }));await findDialog("Invite teammate"); // portaled to document.body — still foundawait expectNoA11yViolations(baseElement); // axe-core, fails with the violationsrerenderWithTheme("neutral", "light"); // remount the scope in another themeawait expectNoA11yViolations(baseElement);});
Manual checks still matter
Conformance
For the target standard, how it's enforced, and an honest list of what's not yet covered, see the conformance statement.
The accessibility conformance statement is a self-assessment against WCAG 2.2 Level AA — it lays out the enforcement layers, what is supported, and the known limitations, without overclaiming.