Skip to content
CodeAtlas UI
On this page What you get

Getting started

Accessibility

What the components handle for you (focus, keyboard, labels, motion, transparency, contrast) and what stays your responsibility.

Interactive components run on a headless layer of our own that implements the WAI-ARIA Authoring Practices patterns: roles, keyboard handling, focus management and dismissal, using native elements (<dialog>, <input>, <details>) wherever the platform provides the behavior. The package adds tokens, motion and touch rules on top, and every component is tested with axe in a real browser.

Credits: interaction patterns inspired by Bits UI and Melt UI. See NOTICE.

What you get

Area Behavior
Focus ring Every focusable component draws a 2px outline in --ca-focus-ring with a 2px offset. The generator solves the token per preset and scheme to keep 3:1 against every surface and the hover/selected fills; override the one token (never --ca-brand-main) to recolor all rings. Never remove it.
Overlays Dialog, AlertDialog, Drawer, Sheet, Popover and menus trap focus, restore it on close, close on Escape and lock page scroll.
Keyboard Tabs, menus, selects, comboboxes, the command palette, date pickers, toggle groups and segmented controls follow their WAI-ARIA keyboard patterns.
Names IconButton requires aria-label at the type level. Built-in strings (close, dismiss, copy, loading) come from the i18n catalog, so they are announced in the active language.
Status Alert uses role="alert". Toast items use role="status" inside a polite live region. Spinner has role="status" with a label, Progress has role="progressbar" with aria-valuenow. CopyButton announces “Copied” through the shared live region.
Loading buttons Button and IconButton with loading set aria-busy, keep their label and ignore clicks. When loading ends they announce “Finished loading”.
Forms TextField and the other fields link hint and error text with aria-describedby and set aria-invalid on error. Errors render with role="alert".
Touch targets Controls grow to 44px (--ca-touch-target) under (pointer: coarse).
Zoom The package never disables zoom. Fields are 16px on touch so iOS does not zoom on focus, see Mobile.
Skip link AppShellLayout, DocsShell and AuthLayout render a SkipLink as the first focusable element and own the #main target.
Forced colors Under forced-colors: active glass, blur and shadows are removed, surfaces get a CanvasText border and focus uses Highlight.
Increased contrast Under prefers-contrast: more, --ca-text-secondary, --ca-text-hint and --ca-outline-* reach at least 7:1 (outlines 3:1 or more) in every built-in and brand preset.

Motion

When the user prefers reduced motion, --ca-duration-fast, --ca-duration-base and --ca-duration-slow become 0ms, so every token-driven transition is instant. Components with JavaScript-driven motion check the reducedMotion store: Sheet disables its swipe spring and SegmentedControl turns off its indicator animation.

import { reducedMotion, reducedTransparency } from "@codeatlasdev/ui";

reducedMotion.init();       // start following the media query (returns cleanup)
reducedMotion.value;        // boolean, reactive
reducedTransparency.value;  // prefers-reduced-transparency

Use the duration tokens in your own CSS and your animations respect the setting for free:

.panel {
  transition: transform var(--ca-duration-base) var(--ca-ease-standard);
}

Visually hidden text

.ca-sr-only hides an element visually and keeps it in the accessibility tree: a 1px clipped box, absolutely positioned so it never affects layout. Use it for text only assistive tech needs: a table <caption>, a label for a control the design leaves unlabeled, an extra “(required)” cue.

<table>
  <caption class="ca-sr-only">Invoices, newest first</caption>
  …
</table>

Do not use display: none, visibility: hidden, hidden or aria-hidden for this: they remove the element from the accessibility tree.

.ca-sr-only--focusable is for elements that must appear when they receive focus (skip links, a “jump to results” shortcut). It stays hidden until the element, or something inside it, is focused, then it returns to normal flow and your own styles decide how it looks.

<a class="ca-sr-only ca-sr-only--focusable" href="#results">Skip to results</a>

Both live in the ca.utilities layer, so they are available wherever global.css or core.css is imported. The kit itself uses the class for the dialog and search titles, the required cue on Select and the month heading of the date pickers.

SkipLink is the first tab stop of every page shell. It is hidden until focused, then jumps to #main and moves focus there (tabindex="-1", no focus ring on the container).

Shell Who owns #main
DocsShell, AuthLayout The shell: its own <main id="main" tabindex="-1">.
AppShellLayout A plain div.ca-shell__content (not a landmark), so a <main> you render inside never nests another. Pass mainId={null} when your page renders <main id="main" tabindex="-1"> yourself.
PageShell Nobody: it always sits inside a shell or a page that already has a <main>.

Never put a second id="main" or a second <main> on a page. When a shell is embedded in a page that already has a skip link (a framed demo, a preview), pass skipLink={false} to AppShellLayout.

<AppShellLayout {modules} {pathname}>
  <!-- content region is the skip target; use <main> only with mainId={null} -->
  <PageShell title="Users">…</PageShell>
</AppShellLayout>

Announcements

announce(message, politeness) speaks a message through one shared, lazily created live region (a polite role="status" and an assertive role="alert" pair appended to <body>). Several components talking through separate regions step on each other; one region keeps messages ordered.

import { announce } from "@codeatlasdev/ui";

announce("3 results");                 // polite (default)
announce("Payment failed", "assertive");

In SvelteKit, mount RouteAnnouncer once in the root layout to announce client-side navigations (the new document.title, or the page <h1>):

<script>
  import { RouteAnnouncer } from "@codeatlasdev/ui/kit";
</script>

<RouteAnnouncer />

Forced colors and increased contrast

Windows High Contrast and other forced-color modes replace author colors with system colors. The package removes backdrop-filter, translucent tints and shadows there, draws surfaces with a 1px CanvasText border and uses Highlight for the focus ring, so nothing depends on a glass blur to be visible.

prefers-contrast: more raises the neutral tokens instead: text-secondary and text-hint to at least 7:1 and outlines to at least 3:1 on every surface, with a thicker focus ring. The values are generated per preset (built-in and brand presets from defineTheme), tinted with the preset hue.

Reduced transparency

With prefers-reduced-transparency: reduce, every glass material (glass-regular, glass-chrome, overlays, panels, upload zones and glass buttons) drops backdrop-filter and renders on an opaque tint. Browsers without backdrop-filter support get the same near-opaque fallback, so text never sits on raw page content.

Color contrast

Text tokens are tuned per preset and scheme:

  • --ca-text-primary and --ca-text-secondary for body text.
  • --ca-text-hint is the lowest readable tier and keeps 4.5:1. Use it for captions and helper text.
  • --ca-text-disabled is for the label of disabled controls only, never for information (use --ca-text-hint to de-emphasise). It keeps 3:1 on every surface and on the disabled fill, so a disabled label stays legible. Disabled controls are drawn from tokens (--ca-text-disabled, --ca-action-disabled-bg, --ca-outline-default), never from opacity.
  • --ca-outline-control is the edge of fields and controls (text field, select, checkbox box) and keeps 3:1 on every surface and on the 5% field tint (WCAG 1.4.11). --ca-outline-subtle is for dividers only.
  • --ca-focus-ring and --ca-state-indicator (switch track, slider range, segmented thumb edge) keep 3:1 against every surface and the hover/selected fills.
  • --ca-semantic-error-contrast is the text colour for --ca-semantic-error-fill, like --ca-brand-contrast for --ca-brand-fill. defineTheme derives it from a custom colors.*.error; if you set the fill yourself, set the contrast beside it.
  • Menus, popovers and pickers (--ca-overlay-bg) and panels (--ca-panel-bg) are near-opaque (94% of --ca-surface-overlay) so their text contrast never depends on the page behind. The old see-through tint stays available as --ca-overlay-bg-glass / --ca-panel-bg-glass for decoration only.
  • --ca-brand-text is brand-colored text that keeps contrast on the surface. Use --ca-brand-fill with --ca-brand-contrast for filled brand elements.

Brand presets generated from a hex color use the same generator, so they keep these relationships. Semantic colors (success, warning, error, info) are separated from the brand by luminance, not only hue, and should always be paired with an icon or text: color alone is never the only signal. Alert and Toast already include an icon.

Writing accessible pages

  • Give every page one <h1> and keep heading levels in order.
  • Provide a skip link and a single <main id="main" tabindex="-1"> landmark. AppShellLayout, DocsShell and AuthLayout already do (see Skip link and landmarks); use SkipLink in shells you build.
  • Label icon-only controls (IconButton enforces it) and give Dialog, Sheet and Drawer a title. Use chromeless dialogs with a title so the accessible name remains.
  • Give Select, Combobox and the other fields a label prop. Without one, the control has no visible or programmatic name. For a Combobox with no visible label, pass ariaLabel.
  • Do not put will-change: transform on glass surfaces. It breaks blur rendering, which also removes the contrast the tint was tuned for.
  • Make clickable non-button elements real <button> or <a> elements.

Testing

@codeatlasdev/ui/testing is the helper the library uses on its own components: axe-core run after animations settle, with the library’s known exceptions. It takes any Element or Document, so it works in Vitest browser mode, Playwright component tests or any DOM test environment. axe-core is an optional peer dependency: install it where you import the helper.

bun add -d axe-core
import { render } from "vitest-browser-svelte";
import { expectNoA11yViolations } from "@codeatlasdev/ui/testing";

it("has no axe violations", async () => {
  const screen = await render(MyComponent);
  await expectNoA11yViolations(screen.container);
});

A violation rejects with the rule id, impact, help URL and every offending node. Run it in each state worth testing: open, error, empty, light and dark.

Export Returns Description
expectNoA11yViolations(target?, options?) Promise<void> Rejects with a formatted report when axe finds violations. target defaults to document.
checkA11y(target?, options?) Promise<AxeResults> Runs axe and returns the raw results (violations only), for custom assertions.
formatViolations(violations) string The readable report used by expectNoA11yViolations.
settled(timeout?) Promise<void> Resolves once no finite CSS animation or transition is running.
A11Y_EXCEPTIONS RunOptions["rules"] The exceptions applied to every run: region is off, because a component rendered into an empty body is not a page.

options is the axe RunOptions; its rules are merged over the exceptions, so { rules: { region: { enabled: true } } } re-enables one. axe.run is not reentrant: never run two checks concurrently. Contrast needs real layout and styles, so run in a browser rather than jsdom.

To test whole pages (landmarks, heading order, region), run axe on your routes in light, dark and forcedColors: "active" with Playwright and @axe-core/playwright. Also test with a keyboard only and with a screen reader on at least one platform.