Getting started
Accessibility
What the components handle for you (focus, keyboard, labels, motion, transparency, contrast) and what stays your responsibility.
Interactive components are built on bits-ui, which provides the ARIA roles, keyboard handling and focus management of each pattern. The package adds tokens, motion and touch rules on top, and every component is tested with axe in a real browser.
What you get
| Area | Behavior |
|---|---|
| Focus ring | A global :focus-visible outline in --ca-focus-ring (the brand color) with a 2px offset. Never remove it; change the token instead. |
| 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);
}
Skip link and landmarks
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-primaryand--ca-text-secondaryfor body text.--ca-text-hintis the lowest readable tier and keeps 4.5:1. Use it for captions and helper text.--ca-text-disabledis for inactive controls only, never for information.--ca-brand-textis brand-colored text that keeps contrast on the surface. Use--ca-brand-fillwith--ca-brand-contrastfor 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.
Caution
Contrast of your own combinations is your responsibility. Check custom overrides, setTokens values and text placed over images or gradients.
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,DocsShellandAuthLayoutalready do (see Skip link and landmarks); useSkipLinkin shells you build. - Label icon-only controls (
IconButtonenforces it) and giveDialog,SheetandDraweratitle. Usechromelessdialogs with atitleso the accessible name remains. - Give
Select,Comboboxand the other fields alabelprop. Without one, the control has no visible or programmatic name. For aComboboxwith no visible label, passariaLabel. - Do not put
will-change: transformon 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
Components are tested with vitest-browser-svelte and axe-core. The helper in packages/ui/tests/a11y.ts provides checkA11y and a toHaveNoViolations matcher (this is the pattern used inside the repo):
import { render } from "vitest-browser-svelte";
import { checkA11y } from "../../../tests/a11y";
it("has no axe violations", async () => {
const screen = await render(MyComponent);
expect(await checkA11y(screen.container)).toHaveNoViolations();
});
In your app, run axe on your routes in light and dark, and with an overlay open, using Playwright with @axe-core/playwright. Also test with a keyboard only and with a screen reader on at least one platform.