Skip to content

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.

tsx
// 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.

tsx
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:

tsx
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.

tsx
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.

tsx
// Charts can't be made node-accessible, so describe them with text.
// The recharts subtree is hidden; the wrapper carries the summary.
<div
role="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.

tsx
// 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([]);

Manual checks still matter

Automated scans catch contrast, names, and landmarks, but keyboard-only walkthroughs and a screen-reader pass remain part of the review.