Accessibility
Claude skill atlas-accessibility · for Designer, Frontend, Everyone
WCAG 2.2 AA for Atlas of Today's World - semantics, keyboard and focus (panels, Esc, dialogs, menus, tabs, the Rail carousel), skip link, touch targets (--touch-min), reduced motion, contrast, the globe and maps with HTML alternatives, accessible forms and errors, screen-reader announcements, and the repo's axe and overflow tests. Use when building or reviewing any UI, adding an interactive control, a dialog, a form, an animation, an image or a map, or when a11y tests fail.
The bar is WCAG 2.2 level AA on every public page and in the admin. Prefer native HTML, reuse the components that already behave correctly, and never ship something a keyboard, a screen reader or a 320 px phone cannot use.
Before you start
- Run the checks:
npm run test:e2e -- tests/a11y.spec.ts tests/overflow.spec.ts(needs a production build:npm run build, Playwright startsnpm run start). Both run nightly in Full tests, together with Lighthouse (accessibility score at least 0.9,lighthouserc.json). - Related skills:
atlas-visual-design(token contrast table),atlas-ux(flows and states),atlas-frontend(component rules),atlas-maps-data(chart and map content),atlas-content(alt text wording, plain language).
Rules
Structure and semantics
- One
<h1>per page; headings go h1 → h2 → h3 without skipping. Panel and full pages keep their ownh1; the home page has a visually hidden one. - Landmarks:
header,nav(each labelled when there is more than one),main id="content" tabIndex={-1},footer. The panel is anasidethat takes focus. - Native elements first:
button,a,label,details,dialog,table. A clickabledivis a bug. - Icon-only controls carry
aria-label(andtitlewhere a tooltip helps); every lucide icon next to text isaria-hidden. <html lang="en">is set insrc/app/layout.tsx; content in another language marks its ownlang(as<article lang={item.locale}>does).
Keyboard and focus
- Everything works with the keyboard alone. Focus is always visible (
focus-visible:ring-2onbuttonVariants, a 3 px outline on the globe canvas). Never remove an outline without a replacement. - The skip link (
SKIP_LINKinsrc/config/layout.ts) is the first Tab stop and jumps to#content. On the home page a second hidden link leads to the plain list of places. - Esc closes the innermost thing: an open dialog or menu first, then the panel (
ContentRail). Page-wide shortcuts must ignore keys that belong to a field, dialog or menu (belongsToFieldinsrc/lib/keyboard.ts). - When content appears, focus moves to it (
ContentRailfocuses the panel on open; a subtopic heading istabIndex={-1}so Previous / Next can land on it). When it closes, focus returns to the trigger. - Use the existing widgets; they implement the WAI-ARIA patterns:
| Need | Component | Keyboard |
|---|---|---|
| Modal | Dialog (components/ui/dialog.tsx, native <dialog>) | Focus inside, Esc closes, focus returns |
| Full-screen menu | useFocusTrap (src/lib/use-focus-trap.ts) + Esc in the caller | Tab cycles inside |
| Menu / popover | Popover (components/ui/popover.tsx) | Arrows, Home, End, Esc |
| Tabs | Tabs, TabPanel (components/ui/tabs.tsx) | One tab stop, arrows, Home, End |
| Carousel | Rail (components/atlas/Rail.tsx) | ←/→, Home, End, labelled arrows, page announced |
| Disclosure | Accordion / <details> | Enter, Space |
| Confirmation | ConfirmButton (components/admin/ConfirmButton.tsx) | Dialog, Cancel before the action |
- Sticky bars and banners never cover the focused element (WCAG 2.4.11). Anchored headings under the sticky header need a scroll margin.
- Never trap Tab in the globe; Tab must pass through it.
Pointer and touch
- Every target is at least 24 × 24 px with a mouse and 44 × 44 px on touch:
min-h-(--touch-min),size-(--touch-min),pointer-coarse:min-h-(--touch-min)for dense controls, or thetouch-hitutility for an invisible 44 px area around a small visual. - Every drag has a single-pointer alternative: the globe has + / − buttons and a list of places,
Railhas arrows, ordered lists have up / down buttons (moveActions). - Never disable zoom.
Motion
prefers-reduced-motion: reducestops all CSS animation and transition (kill-switch inglobals.css). Scripts askscrollBehavior()(src/lib/motion.ts) before smooth scrolling.- Anything that moves for more than 5 s has a pause: the globe's idle spin has a Stop button and
never starts with reduced motion (
useIdleSpin). The owner's coin, spark and pulse animations stay; they stop under reduced motion. - Nothing flashes more than 3 times a second.
Colour and text
- Text contrast at least 4.5:1 (3:1 for text of 24 px, or 18.7 px bold, and larger); UI parts, icons,
chart marks and map strokes at least 3:1 against their neighbours. Token pairs:
atlas-visual-designreference. - Never convey meaning by colour alone: a zero carries the number "0", statuses carry a label, form errors carry text.
- Pages reflow at 320 px with no sideways scroll (
tests/overflow.spec.tschecks 320, 375, 1280); tables, carousels and maps may scroll in their own box. - Text survives user spacing overrides (line height 1.5, letter spacing 0.12 em): no fixed-height text boxes.
Images, the globe and maps
- Informative images have a non-empty alt (what to write:
atlas-content); decorative ones getalt=""andaria-hidden. The hero photo's alt is the page title (owner decision). Photos set as CSS backgrounds that carry meaning userole="img"+aria-label(the author photo). - The globe canvas is keyboard-operable (arrows pan, + / − zoom), focusable and outlined, and
axe cannot test it (
tests/a11y.spec.tsexcludes.maplibregl-canvas-container). So every globe feature needs an HTML twin:/countriesfor places, the ranked list on/view/[indicator]for a data layer, search for jumping to a place, labelled zoom buttons (GlobeZoomControls). - Selecting a place opens its panel, which takes focus; that is the announcement. Do not add a second live announcement for the same change.
- The globe window on full pages (
MiniGlobeLink) is one labelled link, not an interactive map. - Every chart and custom map has a title, a text summary of its takeaway and its data available
as text or a table. Details:
atlas-maps-data. - Audio has a text alternative next to it and never autoplays (
EntryAudio).
Forms and errors
- Every control has a visible
<label>(FormFieldwithid= the control'sid). Hints and errors are linked withdescribedBy(id, { hint, errors }), which also setsaria-invalid. - Required fields:
requiredon the control;FormField requiredshows the*and setsaria-required; the page explains*withRequiredNote. - After a rejected submit, focus moves to the first invalid field:
ActionStatus(admin) anduseFormErrors(public forms) do this. Errors are text next to the field, never only colour or a tooltip. - Inputs have the right
type,name,autocompleteandinputMode(the 2FA code field usesinputMode="numeric"andautoComplete="one-time-code"); paste is never blocked. - Sign-in (email code, Google) allows paste and password managers and never adds a memory or puzzle test (WCAG 3.3.8).
Announcements
- Status changes the reader did not navigate to are announced in a polite live region:
ShareButton(role="status"),ActionStatus(status/alert),Railpage changes, DataTable result counts,TopicsBrowserresults. - Use
role="alert"only for errors that happen while the person is on the page (ErrorState livein error boundaries). A 404 page is just a page, not an alert. - One live region per change; keep the message short ("Link copied", "3 results").
How we do it in Atlas
<FormField id="redirect-from" label="Old path" hint="E.g. /news/old-title" required errors={errors.from_path}>
<Input id="redirect-from" name="from_path" required
{...describedBy("redirect-from", { hint: "E.g. /news/old-title", errors: errors.from_path })} />
</FormField>
<Button variant="quiet" size="denseIcon" aria-label="Close" onClick={close}>
<X aria-hidden className="size-4" />
</Button>Checklist
- [ ] Keyboard only: reach, operate and leave every control; focus visible; Esc closes the right thing.
- [ ] Screen reader (NVDA or VoiceOver): headings, labels, names of icon buttons, announcements.
- [ ] 320 px width and 200 % zoom: nothing cut off, no sideways page scroll.
- [ ] Touch: targets 44 px; no drag-only action.
- [ ] Reduced motion on: nothing moves.
- [ ] Contrast of new colour pairs checked; meaning not by colour alone.
- [ ] New public page added to
PAGESintests/a11y.spec.tsandtests/overflow.spec.ts. - [ ] Images have meaningful or empty alt; maps and charts have a text or table alternative.
Anti-patterns
<div onClick>or a link withouthref: invisible to keyboard and screen readers.- A hand-made modal without focus handling: use
Dialog. outline: nonewith no replacement.- A new animation that ignores reduced motion or cannot be paused.
- Information only on the globe or only in a tooltip.
role="alert"on static content: screen readers interrupt and read it out of turn.- Placeholder as the only label.
Sources
Full checklist and sources: reference.
- WCAG 2.2 quick reference: https://www.w3.org/WAI/WCAG22/quickref/
- What is new in WCAG 2.2: https://www.w3.org/WAI/standards-guidelines/wcag/new-in-22/
- WAI-ARIA Authoring Practices, patterns: https://www.w3.org/WAI/ARIA/apg/patterns/
- W3C complex images tutorial: https://www.w3.org/WAI/tutorials/images/complex/
- Chartability: https://chartability.github.io/POUR-CAF/
- Vercel Web Interface Guidelines: https://vercel.com/design/guidelines
Files of this skill
reference.md
atlas-accessibility: reference
Manual test script, WCAG 2.2 additions and sources for SKILL.md.
What the automated tests cover (and what they do not)
| Check | File | Runs | Covers |
|---|---|---|---|
| axe (serious and critical only) | tests/a11y.spec.ts | Nightly e2e (Full tests), locally with npm run test:e2e | About 20 public pages, the 404 quiz, the open account menu, skip link, unique field ids |
| Overflow | tests/overflow.spec.ts | Same | No sideways scroll and no text sticking out at 320, 375, 1280 px |
| Lighthouse accessibility | lighthouserc.json | Nightly | Score at least 0.9 on 4 URLs |
| Errors per field | tests/unit/public-form-errors.test.ts | Every PR (npm test) | Public forms return a message code for every invalid field, so each error sits under its own field |
axe runs with the tags wcag2a, wcag2aa, wcag21a, wcag21aa and skips the globe canvas.
It does not test the WCAG 2.2 additions, keyboard flows, reading order or the quality of alt text.
Those need the manual script below. Adding wcag22aa to the tag list is welcome once it passes.
WCAG 2.2 AA additions and where they matter in Atlas
| Criterion | What it asks | Atlas places to check |
|---|---|---|
| 2.4.11 Focus Not Obscured (AA) | The focused element is not fully hidden | Sticky header, ReadingProgress, TopicJumpBar, cookie notice, floating buttons, mobile bottom sheet |
| 2.5.7 Dragging Movements | A single-pointer alternative for every drag | Globe (zoom buttons, lists), Rail arrows, admin reordering (moveActions), custom map builder |
| 2.5.8 Target Size (Minimum) | 24 × 24 px or enough spacing | DataTable row icons (24 px mouse, 44 px touch), chips, pagination dots in Rail |
| 3.2.6 Consistent Help | Help in the same place on every page | Contact link and footer links stay in the same place |
| 3.3.7 Redundant Entry | Do not ask again for data given in the same flow | Volunteer form prefill (volunteerHref), checkout |
| 3.3.8 Accessible Authentication (Minimum) | No cognitive test to sign in | Email code and Google sign-in, 2FA code field |
Manual test script (about 15 minutes per new screen)
- Keyboard. Unplug the mouse. Tab from the address bar: the first stop is "Skip to content". Reach every control, open and close every menu, dialog and panel with Enter, Space and Esc. Focus must stay visible and return to the trigger.
- Screen reader. NVDA (Windows) or VoiceOver (macOS, iOS). List the headings: one h1, no skipped levels. List the buttons: every icon button has a name. Trigger Share, a form error, a filter: each is announced once.
- Zoom and reflow. 200 % browser zoom, then a 320 px wide window. Nothing is cut off; only tables, carousels and maps scroll sideways in their own box.
- Touch. On a phone: tap targets are easy to hit, the bottom sheet opens and closes, the globe tap-preview works, nothing needs hover.
- Motion. Turn on "reduce motion" in the OS: the coin, spark, pulse, stars and globe spin stop.
- Colour. Check new colour pairs against the table in
atlas-visual-design. Turn on forced colours (Windows high contrast): controls still have visible borders. - Content. Read the alt texts aloud: do they say what matters? Do maps and charts have the text or table alternative?
Pattern notes
- Dialog.
DialogusesshowModal(), so the browser handles inertness outside it; the Close button is the first focusable element, which keeps the destructive action away from the initial focus. - Panel Esc.
ContentRailignores Esc when anaria-modaldialog is open or the key comes from a field, dialog or menu (belongsToField). New page-wide shortcuts follow the same guard. - Live regions. Render the region empty on mount and fill it on change; a region inserted together with its text is often not announced.
- Forms.
ActionStatusfocuses the first[aria-invalid="true"]field after a failed save, otherwise scrolls the message into view withscrollBehavior().
Accessibility statement
The public statement lives at /accessibility and is edited in
Admin → Site pages → Accessibility.
Update it when a known limitation is added or removed.
Sources
- WCAG 2.2 quick reference: https://www.w3.org/WAI/WCAG22/quickref/
- What is new in WCAG 2.2: https://www.w3.org/WAI/standards-guidelines/wcag/new-in-22/
- WAI-ARIA APG patterns (dialog, tabs, menu button, carousel, disclosure): https://www.w3.org/WAI/ARIA/apg/patterns/
- APG landmarks: https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/
- W3C tables tutorial: https://www.w3.org/WAI/tutorials/tables/
- W3C complex images tutorial: https://www.w3.org/WAI/tutorials/images/complex/
- Understanding use of colour: https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html
- Understanding non-text contrast: https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html
- Chartability critical tests: https://chartability.github.io/POUR-CAF/
- GOV.UK error summary: https://design-system.service.gov.uk/components/error-summary/
- Maps accessibility guidance (Hedron): https://hedron.readthedocs.io/en/latest/guides/maps-accessibility/
- NPS web map accessibility: https://www.nps.gov/maps/web/accessibility
- Vercel Web Interface Guidelines: https://vercel.com/design/guidelines
- Next.js production checklist (local copy
node_modules/next/dist/docs/01-app/02-guides/production-checklist.md): https://nextjs.org/docs/app/guides/production-checklist