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.
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 the label of disabled controls only, never for information (use--ca-text-hintto 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 fromopacity.--ca-outline-controlis 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-subtleis for dividers only.--ca-focus-ringand--ca-state-indicator(switch track, slider range, segmented thumb edge) keep 3:1 against every surface and the hover/selected fills.--ca-semantic-error-contrastis the text colour for--ca-semantic-error-fill, like--ca-brand-contrastfor--ca-brand-fill.defineThemederives it from a customcolors.*.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-glassfor decoration only. --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
@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.