# Accessibility

> The accessibility commitments every Apogee component ships with, and how to verify them.

URL: https://apogee.avaui.dev/docs/accessibility
Last updated: 2026-10-15

The accessibility commitments every Apogee component ships with, and how to verify them.

Last updated October 15, 2026

Accessible is a non-negotiable, not a checklist run at the end. A component isn’t done until it passes: keyboard-operable, correctly announced, visibly focused, motion-safe, and contrast-checked against every shipped theme.

## Semantics first

Real elements over div soup — `<button>`, `<a>`, `<nav>`, `<dialog>`, `<details>`. ARIA fills genuine gaps; it never replaces a native element.

-   `Button` renders a real `<button>`, or a real `<a>` when `href` is set — never a styled `<div>` with a click handler.
-   `Breadcrumbs` is a `<nav aria-label="Breadcrumb">` around an `<ol>`; the current page is a `<span aria-current="page">`, not a link.
-   `SkipLink` is a plain `<a href="#main">`, visually hidden until focused.

Because native elements carry their own keyboard support, focus behavior, and accessibility tree entry for free, most Apogee components need **little to no extra ARIA** — a role, a live region, or a state attribute (`aria-expanded`, `aria-current`, `aria-busy`) to cover what HTML alone can’t say.

## Keyboard

Every interaction is reachable and operable by keyboard, with a logical tab order. Overlays close on Escape; menus and tabs support arrow-key navigation where users expect it. Interactive targets are at least 24×24px, ideally 44×44px.

## Focus

Every interactive element gets an always-visible `:focus-visible` ring, driven by the `--border-primary` token — never suppressed:

```astro
class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
```

Tab into the button below to see it:

```astro
<Button>Save changes</Button>
```

Modal dialogs trap focus while open and return it to the trigger on close.

## Motion

Every animated component reads the motion tokens (`--duration-*`, `ease-standard`, `ease-decelerate`) and respects `prefers-reduced-motion` — for example, `Button`’s press animation:

```plaintext
active:scale-95 motion-reduce:active:scale-100
```

No component ships an animation that can’t be turned off this way.

## Contrast

Text and UI meet **WCAG AA** against every themed background: 4.5:1 for text, 3:1 for the focus ring. This is enforced, not assumed — see [Color](https://apogee.avaui.dev/docs/color) for the full token-pair table and the `npm run check:contrast` gate that fails CI on a miss.

## Names, roles, state

Accessible name, role, and state stay in sync with the UI: `aria-expanded` on disclosure triggers, `aria-current="page"` on the active breadcrumb/nav item, `aria-selected` on active tabs, `aria-busy` while `Button` is `loading`. Meaningful images and icon-only controls (`Button iconOnly`) get a label; decorative icons are hidden from assistive tech (`aria-hidden="true"`).

## How we verify

-   **Keyboard pass.** Tab through the component; every action reachable, every focus state visible, `Escape` closes overlays.
-   **Screen reader smoke test.** Confirm the announced name, role, and state match what’s on screen.
-   **Contrast script.** `npm run check:contrast` after any color token change — it computes real ratios from `theme.css` and fails on a miss.
-   Automated `axe` checks in CI are planned, not yet wired in.
