Accessibility (a11y) — The Verdict
Accessibility is not a component or a final QA pass — it is a contract every token, component, and pattern must honor. WCAG 2.2 AA is the legal floor (what auditors, scanners, and courts test); APCA is the readability ceiling you tune toward. Build on native HTML semantics, add ARIA only to fill gaps, ship a visible focus ring, full keyboard operability, and respect user preferences (reduced motion, contrast, color scheme). Automated tools catch only ~30-40% of WCAG success criteria — manual + assistive-technology testing is non-negotiable.
TL;DR decision table
| Concern | Do this | WCAG SC |
|---|---|---|
| Text contrast | 4.5:1 body, 3:1 large (≥24px or ≥18.66px bold) | 1.4.3 |
| UI / icon / focus-ring contrast | 3:1 against adjacent colors | 1.4.11 |
| Visible focus | :focus-visible + --color-ring, 2px outline + 2px offset, never outline:none alone |
2.4.7, 2.4.13 |
| Pointer target size | ≥24×24px (AA), aim ≥44×44px (AAA / Apple HIG) | 2.5.8, 2.5.5 |
| Keyboard | Everything operable, no traps, logical order | 2.1.1, 2.1.2 |
| Semantics | Native element first (<button>, <a>, <label>); ARIA only when no native exists |
4.1.2 |
| Forms | Programmatic label + error association; never color-alone | 1.3.1, 3.3.1, 1.4.1 |
| Motion | Honor prefers-reduced-motion; no >3 flashes/sec |
2.3.3, 2.3.1 |
| Don't rely on color | Pair color with text/icon/shape | 1.4.1 |
1. WCAG 2.2 AA — the essentials you actually ship
WCAG organizes around POUR: Perceivable, Operable, Understandable, Robust. AA is the target level for virtually all legislation worldwide (EN 301 549 / European Accessibility Act, ADA case law, Section 508). WCAG 2.2 (W3C Recommendation, Oct 2023) is the current stable version and adds 9 success criteria over 2.1 — the ones you will touch most:
| New in 2.2 | Level | What it forces |
|---|---|---|
| 2.4.11 Focus Not Obscured (Min) | AA | The focused element must not be entirely hidden by sticky headers/footers or overlays |
| 2.4.13 Focus Appearance | AAA | Focus indicator min area + 3:1 contrast against both states (treat as the bar to aim for) |
| 2.5.8 Target Size (Min) | AA | Pointer targets ≥ 24×24 CSS px (with spacing exceptions) |
| 3.2.6 Consistent Help | A | Help mechanisms appear in the same relative order across pages |
| 3.3.7 Redundant Entry | A | Don't make users re-enter info already given in the same process |
| 3.3.8 Accessible Authentication (Min) | AA | No cognitive-function test (e.g. transcribe/puzzle) without an alternative; allow paste & password managers |
3.3.8 in practice: never disable paste on password / OTP fields and never block password managers. That single rule fixes a huge class of real-world auth failures.
2. Color contrast — WCAG floor + APCA direction
WCAG 2.x ratios (the legal requirement)
A luminance-ratio model from 1:1 (no contrast) to 21:1 (black on white).
| Content | AA | AAA |
|---|---|---|
| Body text (< 24px, or < 18.66px bold) | 4.5:1 | 7:1 |
| Large text (≥ 24px, or ≥ 18.66px bold) | 3:1 | 4.5:1 |
| UI components, icons, graphical objects, focus indicators | 3:1 | — |
| Disabled controls, pure decoration | exempt | exempt |
Known weakness: WCAG 2 over-rates dark pairs, so it is a poor guide for dark mode — verify dark themes with APCA and real eyes. There is no background where both pure black and pure white fail AA; one always passes.
APCA — the readability ceiling (candidate, not law)
APCA (Advanced Perceptual Contrast Algorithm) is the candidate method for WCAG 3. It outputs a polarity- and size/weight-aware Lc value (practical range ≈ ±106). Status June 2026: APCA was moved out of the WCAG 3 working draft (July 2023); WCAG 3 is unlikely to finalize before ~2028-2030. Do not drop WCAG 2 conformance over a draft — even APCA's author says so.
Lc |
Use | Rough WCAG map |
|---|---|---|
| 90 | Preferred body text | — |
| 75 | Minimum for body-text columns | preferred |
| 60 | Min for non-body text | ≈ 4.5:1 |
| 45 | Large/heavy text, headlines, fine icons | ≈ 3:1 |
| 30 | Absolute min any text; placeholder/disabled | — |
| 15 | Min for non-text discernibility | — |
Two-layer strategy: ship WCAG 2.2 AA as the floor, tune with APCA on top, and pick colors that pass both.
How to check contrast
- In the build: axe-core / Lighthouse / Pa11y in CI flag text-contrast failures automatically (≈ the only WCAG area machines test well).
- By hand: browser DevTools color picker shows the live ratio + AA/AAA pass; Chrome DevTools also exposes an experimental APCA readout (treat as informative).
- Standalone: the OKLCH picker visualizes gamut; APCA's own calculators for
Lc. - Token-time: CSS Color 5
contrast-color()(Baseline newly available, April 2026) returns black/white auto for any flat background — guarantees AA-ish, not AAA.
/* Let the browser pick legible foreground for any token-driven surface */
.chip {
background: var(--color-accent);
color: contrast-color(var(--color-accent)); /* auto black or white */
}
/* Progressive enhancement: ship a manual fallback for older engines */
.chip { color: var(--color-accent-fg); }
@supports (color: contrast-color(red)) {
.chip { color: contrast-color(var(--color-accent)); }
}
Never communicate state by color alone (1.4.1). A required field, an error, a "selected" tab, a chart series — each needs a second cue: text, icon, underline, shape, or pattern.
3. Visible focus — the :focus-visible + --color-ring recipe
A visible focus indicator is the single most-impactful keyboard-a11y feature, and the most commonly destroyed one (outline: none). The rule: never remove focus without replacing it, and scope the ring to :focus-visible so mouse users don't see rings on click while keyboard users always do.
/* GLOBAL FOCUS RING — apply once, inherit everywhere */
/* 1. Kill the default ONLY for pointer focus, keep it for keyboard */
:focus:not(:focus-visible) {
outline: none;
}
/* 2. The real, token-driven ring (3:1 against bg per 1.4.11) */
:focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 2px; /* gap so the ring reads against the control */
border-radius: inherit; /* match the element's corners */
}
/* 3. Inputs/cards on busy backgrounds: double-ring for guaranteed contrast
on ANY surface (light halo + dark line, or vice versa) */
.input:focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 2px;
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-ring) 25%, transparent);
}
/* 4. High-contrast / forced-colors: opt into the system focus color */
@media (forced-colors: active) {
:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
}
/* The ring token itself — defined in the color layer, recalled here.
Must clear 3:1 against EVERY surface it can land on. */
:root { --color-ring: oklch(0.62 0.20 264); }
.dark,
[data-theme="dark"] { --color-ring: oklch(0.72 0.21 264); } /* +L +C for dark */
Why outline over box-shadow as the primary ring: outline is not clipped by overflow: hidden, ignores border-radius clipping issues, and survives forced-colors mode. Use box-shadow only as an additive halo, never the sole indicator.
2.4.11 Focus Not Obscured: when you have a sticky header, give scrollable focus targets scroll-margin so they never hide under it.
:where(a, button, input, [tabindex]) { scroll-margin-block: var(--space-20); }
4. Keyboard navigation + focus management
Everything operable by mouse must be operable by keyboard, with no traps and a logical (DOM) order. Lean on native elements to get this for free.
Expected keyboard contract
| Pattern | Keys |
|---|---|
| Button / link | Enter (and Space for <button>) activates |
| Tabs | Tab to tablist, ←/→ move between tabs, Home/End jump |
| Menu / listbox | ↑/↓ move, Enter/Space select, Esc close, type-ahead |
| Dialog (modal) | Tab/Shift+Tab cycle inside dialog, Esc closes |
| Disclosure / accordion | Enter/Space toggles |
| Combobox | ↓ opens list, arrows navigate, Enter commits, Esc reverts |
tabindexrules: use0to make a custom widget focusable in DOM order,-1for programmatic-only focus (element.focus()), and never positive values — they break the natural order and are an anti-pattern.
Skip link (bypass blocks — 2.4.1)
<a class="skip-link" href="#main">Skip to main content</a>
<!-- ...nav... -->
<main id="main" tabindex="-1"> ... </main>
.skip-link {
position: absolute;
left: var(--space-2);
top: var(--space-2);
z-index: 999;
padding: var(--space-2) var(--space-4);
background: var(--color-surface);
color: var(--color-fg);
border-radius: var(--radius-md);
/* Visually hidden until focused, then snaps into view */
transform: translateY(-150%);
transition: transform var(--dur-fast) var(--ease-out);
}
.skip-link:focus-visible { transform: translateY(0); }
@media (prefers-reduced-motion: reduce) { .skip-link { transition: none; } }
Focus management for dialogs (trap + restore)
A modal dialog must: move focus in on open, trap focus inside, close on Esc and backdrop click, and restore focus to the trigger on close. The native <dialog showModal()> does most of this — prefer it.
"use client";
import { useEffect, useRef } from "react";
type DialogProps = {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
};
export function Dialog({ open, onClose, title, children }: DialogProps) {
const ref = useRef<HTMLDialogElement>(null);
const lastFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (open) {
lastFocused.current = document.activeElement as HTMLElement;
el.showModal(); // native: focus trap + inert background + Esc
} else if (el.open) {
el.close();
lastFocused.current?.focus(); // restore focus to the trigger (2.4.3)
}
}, [open]);
return (
<dialog
ref={ref}
aria-labelledby="dlg-title"
onClose={onClose} // fires on Esc / close()
onClick={(e) => { if (e.target === ref.current) onClose(); }} // backdrop
className="dialog"
>
<h2 id="dlg-title">{title}</h2>
{children}
<button type="button" onClick={onClose}>Close</button>
</dialog>
);
}
.dialog { border: 1px solid var(--color-border); border-radius: var(--radius-xl);
padding: var(--space-6); background: var(--color-surface);
color: var(--color-fg); box-shadow: var(--shadow-xl); }
.dialog::backdrop { background: oklch(0 0 0 / 0.5); }
If you must build a custom (non-
<dialog>) overlay, setaria-modal="true"+role="dialog", mark the rest of the pageinert, and implement the trap + restore yourself. Native<dialog>exists precisely so you don't have to.
5. Semantic HTML first, ARIA only when needed
The first rule of ARIA: don't use ARIA. A native element with built-in semantics and behavior is always better than re-creating it with a
<div>+ roles.<button>gives you focusability,Enter/Spaceactivation, and the correct role for free; a<div role="button">makes you re-implement all of it (and most implementations are buggy).
| Need | Use this | Not this |
|---|---|---|
| Click action | <button type="button"> |
<div onClick> |
| Navigation | <a href> |
<span onClick> |
| Section landmarks | <header> <nav> <main> <aside> <footer> |
<div class="nav"> |
| Heading hierarchy | one <h1> per page, no skipped levels |
styling a <p> to look big |
| Toggle | <input type="checkbox"> / <button aria-pressed> |
<div class="toggle"> |
| List | <ul>/<ol>/<li> |
stacked <div>s |
The five ARIA rules (W3C ARIA Authoring Practices): (1) prefer native HTML; (2) don't change native semantics (<h2 role="button"> is wrong — wrap a button instead); (3) all interactive ARIA controls must be keyboard-operable; (4) never put role="presentation" / aria-hidden="true" on a focusable element; (5) every interactive element needs an accessible name.
<!-- Accessible names, in priority order -->
<button aria-label="Close dialog">✕</button> <!-- icon-only: aria-label -->
<button aria-labelledby="t1">…</button><span id="t1">Save</span>
<img src="chart.png" alt="Q2 revenue up 18% over Q1" /> <!-- meaningful alt -->
<img src="divider.svg" alt="" /> <!-- decorative: empty alt -->
<svg aria-hidden="true" focusable="false">…</svg> <!-- decorative icon -->
Live regions announce async changes to screen readers without moving focus:
<!-- Polite: waits for a pause (toasts, autosave, search counts) -->
<div role="status" aria-live="polite" class="sr-only" id="toast-region"></div>
<!-- Assertive: interrupts immediately (errors, time-critical) -->
<div role="alert" aria-live="assertive"></div>
The screen-reader-only utility (visually hidden, still announced):
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* Make it visible again when a sr-only control receives focus (skip links) */
.sr-only-focusable:focus-visible {
position: static; width: auto; height: auto;
clip: auto; clip-path: none; white-space: normal;
}
6. Accessible forms — label + error association
Forms are where a11y most often breaks. Three hard requirements: every field has a programmatically associated label, errors are associated and announced, and validity is never signaled by color alone.
<form novalidate>
<div class="field">
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
aria-required="true"
aria-invalid="true"
aria-describedby="email-hint email-error"
/>
<p id="email-hint" class="field__hint">We'll never share it.</p>
<!-- role="alert" announces the error the moment it appears -->
<p id="email-error" role="alert" class="field__error">
<svg aria-hidden="true" class="icon">…</svg>
Enter a valid email, e.g. name@example.com
</p>
</div>
<button type="submit">Create account</button>
</form>
Rules baked into the markup above:
- Label association:
<label for>↔id. A wrapping<label>also works. Placeholder text is not a label (it disappears and fails contrast). autocompletetokens (1.3.5 Identify Input Purpose) let browsers/password managers fill fields — required for AA and they help everyone.- Error association:
aria-describedbylinks the field to both hint and error;aria-invalid="true"marks the field;role="alert"makes the message announce on appearance. - Required: mark with
required+ visible text/*(with a legend explaining*), not color. - Error summary: for multi-error forms, render a focusable summary at the top (
<h2 tabindex="-1">+ a list of in-page links to each field) and move focus to it on failed submit.
// Move focus to the error summary on a failed submit (focus management 3.3.1)
function onSubmitInvalid(summaryRef: React.RefObject<HTMLHeadingElement>) {
summaryRef.current?.focus();
summaryRef.current?.scrollIntoView({ block: "start" });
}
.field__error {
display: flex; gap: var(--space-1); align-items: center;
color: var(--color-danger-fg);
font: var(--text-sm)/var(--leading-sm) var(--font-body);
}
/* Pair the danger color with a border AND an icon — never color alone */
.input[aria-invalid="true"] { border-color: var(--color-danger); }
7. Target size — 24px floor, 44px goal
WCAG 2.2 2.5.8 (AA) requires pointer targets ≥ 24×24 CSS px (or ≥24px of spacing between smaller targets). The AAA criterion 2.5.5 and Apple HIG both want 44×44 — that is the real comfort target for touch. Bake the floor into interactive tokens.
:where(button, [role="button"], a, input, select, summary) {
min-block-size: var(--space-11); /* 44px when --space-11 = 2.75rem */
min-inline-size: var(--space-11);
}
/* Small inline controls (e.g. icon in dense table): keep a 24px hit area
even when the glyph is smaller, via padding or a ::before overlay */
.icon-btn--dense {
min-block-size: var(--space-6); /* 24px visual */
position: relative;
}
.icon-btn--dense::before { /* expand the hit area to 44px */
content: ""; position: absolute; inset: 50% 50%;
inline-size: 44px; block-size: 44px; translate: -50% -50%;
}
Spacing exception: a 24px target is allowed if there's ≥24px clear space to neighboring targets. Don't rely on it — generous targets reduce mis-taps for everyone (motor impairments, small screens, fat fingers).
8. User preferences — reduced motion, contrast, color scheme
prefers-reduced-motion (2.3.3)
Reduce, don't always remove — swap large transforms/parallax for a quick opacity fade so the UI still feels intentional. Ship a global safety net, then refine per-component.
/* Global net: neutralizes runaway animation but keeps tiny crossfades */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Kill morphs in View Transitions too */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation: none !important; }
}
// React: site-wide via MotionConfig; "user" keeps opacity/color, drops transforms
import { MotionConfig } from "motion/react";
<MotionConfig reducedMotion="user">{children}</MotionConfig>
// Per-component: swap transform for fade
import { useReducedMotion } from "motion/react";
function Drawer({ open }: { open: boolean }) {
const reduce = useReducedMotion();
const animate = open
? reduce ? { opacity: 1 } : { x: 0 }
: reduce ? { opacity: 0 } : { x: "-100%" };
return <motion.aside animate={animate} />;
}
Also disable smooth-scroll libraries (Lenis), autoplaying video, and infinite loops under reduced motion. 2.3.1 Three Flashes: never flash content more than 3×/second.
prefers-contrast + forced-colors
/* User asked for more contrast: strengthen borders and text */
@media (prefers-contrast: more) {
:root {
--color-border: var(--color-fg);
--color-fg-muted: var(--color-fg);
}
.btn { border: 2px solid currentColor; }
}
/* Windows High Contrast / forced-colors: don't fight the system palette */
@media (forced-colors: active) {
.btn { border: 1px solid ButtonText; }
/* Restore meaning lost when bg images/box-shadows are stripped */
.card { border: 1px solid CanvasText; }
/* forced-color-adjust: none ONLY where you must preserve meaning (e.g. a
status swatch) — pair with a text label so it survives anyway */
.swatch { forced-color-adjust: none; }
}
color-scheme (correct native form controls in dark mode)
/* Tells the UA to render scrollbars, inputs, and built-in controls per theme */
:root { color-scheme: light dark; }
[data-theme="light"] { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }
9. Japanese-text accessibility (日本語の a11y)
Latin-centric defaults degrade Japanese readability. Key adjustments:
- Line height: CJK glyphs are dense and full-height — use
line-height: 1.7-1.8for Japanese body text (vs ~1.5 for Latin), so lines breathe. - No mid-word breaking by character class: use
line-break: strictandoverflow-wrap: anywherecarefully; rely onword-break: normal(do not usebreak-allfor prose — it breaks kinsoku rules). Enable proper line-break rules withline-break: strict;. - Avoid justified text (
text-align: justify) for Japanese unless you also settext-justifyappropriately — uneven spacing harms low-vision readers. - Font weight & contrast: thin weights on dense kanji reduce legibility; keep body weight ≥ 400 and meet the same 4.5:1. Many kanji have fine strokes, so favor the higher end of contrast.
langattribute: set<html lang="ja">(or per-blocklang) so screen readers pick the correct voice and so:lang()typography rules apply. Mixed JA/EN content should mark English spanslang="en".- Ruby for readings: use semantic
<ruby>/<rt>for furigana rather than parenthetical hacks, so AT can expose or skip readings. text-spacing-trim/font-feature-settings: modern browsers can trim full-width punctuation spacing (text-spacing-trim: trim-start) for tighter, more legible kana/kanji runs.
:lang(ja) {
line-height: 1.75;
line-break: strict;
word-break: normal;
overflow-wrap: anywhere;
font-feature-settings: "palt" 0; /* keep full-width metrics for body */
text-spacing-trim: trim-start; /* trim leading bracket/punct space */
}
:lang(ja) :is(h1, h2, h3) { line-height: 1.4; } /* headings can be tighter */
<p lang="ja">本日<ruby>晴天<rt>せいてん</rt></ruby>なり。<span lang="en">OK</span></p>
10. Accessible loading / empty / error states
Every async surface needs three states, each announced correctly — not just a spinner.
function DataPanel({ status, items, error }: PanelState) {
if (status === "loading")
// Announce politely; busy state for AT; skeleton, not just a spinner
return (
<div role="status" aria-live="polite" aria-busy="true">
<span className="sr-only">Loading results…</span>
<Skeleton rows={5} aria-hidden="true" />
</div>
);
if (status === "error")
// role="alert" interrupts; offer a keyboard-reachable recovery action
return (
<div role="alert" className="state state--error">
<h3>Couldn't load results</h3>
<p>{error.userMessage /* plain language, no codes */}</p>
<button type="button" onClick={retry}>Try again</button>
</div>
);
if (items.length === 0)
// Empty ≠ error: explain + give the next action. Not announced as alert.
return (
<div className="state state--empty">
<h3>No results yet</h3>
<p>Try a different search or create your first item.</p>
<button type="button" onClick={onCreate}>Create item</button>
</div>
);
return <List items={items} />;
}
- Loading:
aria-busy="true"+ a politerole="status"label. Decorative skeletons getaria-hidden. - Error:
role="alert", plain-language message (what happened + how to fix), and a focusable retry. - Empty: distinct from error — explain and offer a clear next step. Don't announce as an alert.
11. Tailwind v4 — themed a11y utilities
shadcn / Tailwind defaults must be themed, not shipped raw. Wire the focus ring and target tokens into @theme so utilities inherit them.
@import "tailwindcss";
@theme {
--color-ring: oklch(0.62 0.20 264);
--color-danger: oklch(0.64 0.237 25);
--color-danger-fg: oklch(0.98 0.02 25);
--radius-md: 0.5rem;
}
/* Project-wide focus ring as a Tailwind base layer */
@layer base {
:focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 2px;
}
:focus:not(:focus-visible) { outline: none; }
}
// Tailwind utility form of the ring (matches the CSS above)
<button
className="min-h-11 min-w-11 rounded-md
focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-[var(--color-ring)]
motion-reduce:transition-none"
>
Save
</button>
motion-reduce:andcontrast-more:variants ship in Tailwind v4 — use them to honorprefers-reduced-motion/prefers-contrastinline.forced-colors:variant handles Windows High Contrast.
✅ Pre-ship a11y checklist
Perceivable
- Text contrast ≥ 4.5:1 (body) / 3:1 (large); UI + icons + focus ring ≥ 3:1
- No information conveyed by color alone (text/icon/shape backup)
- All meaningful images have
alt; decorative images havealt=""/aria-hidden - Content reflows / readable at 200% zoom and 320px width
Operable
- Every interactive element reachable + operable by keyboard, no traps
- Visible
:focus-visiblering on every focusable element;outline:nonenever alone - Focus not obscured by sticky headers/overlays (2.4.11);
scroll-marginset - Logical DOM/tab order; no positive
tabindex - Skip link to
<main>; one<h1>, no skipped heading levels - Pointer targets ≥ 24px (≥44px preferred)
- Dialogs trap focus, close on
Esc, restore focus to trigger -
prefers-reduced-motionhonored; nothing flashes >3×/sec
Understandable
- Every form field has a programmatic label (not placeholder-only)
- Errors associated (
aria-describedby+aria-invalid) and announced (role="alert") -
autocompletetokens on identity fields; paste allowed on auth fields - Plain-language errors (what happened + how to fix); loading/empty/error states present
-
<html lang>set (lang="ja"for Japanese; English spans marked)
Robust
- Native HTML semantics first; ARIA only to fill gaps; valid roles + names
- Live regions for async updates (
role="status"/role="alert") -
color-schemeset;forced-colorsmode survives (meaning not lost) - Tested with a screen reader (VoiceOver / NVDA) + keyboard-only, not just axe
- axe-core / Lighthouse in CI (catches ~30-40% of SC — necessary, not sufficient)
Sources
- WCAG 2.2 (W3C Recommendation) — https://www.w3.org/TR/WCAG22/
- WCAG 2.2 — What's New / new success criteria — https://www.w3.org/WAI/standards-guidelines/wcag/new-in-22/
- MDN —
:focus-visible— https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible - MDN —
prefers-reduced-motion— https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion - MDN —
forced-colors/ Windows High Contrast — https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors - MDN —
<dialog>element — https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog - MDN —
contrast-color()— https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/contrast-color - W3C — ARIA Authoring Practices Guide (APG) — https://www.w3.org/WAI/ARIA/apg/
- W3C — Using ARIA (the 5 rules) — https://www.w3.org/TR/using-aria/
- WebAIM — Contrast & Color Accessibility — https://webaim.org/articles/contrast/
- APCA — APCA in a Nutshell (Lc levels) — https://git.apcacontrast.com/documentation/APCA_in_a_Nutshell.html
- Adrian Roselli — WCAG3 Contrast as of April 2026 — https://adrianroselli.com/2026/04/wcag3-contrast-as-of-april-2026.html
- Adrian Roselli — Avoid Default Browser Focus Styles / focus-visible — https://adrianroselli.com/2019/07/focus-visible-and-backwards-compatibility.html
- Apple — Human Interface Guidelines (target size, accessibility) — https://developer.apple.com/design/human-interface-guidelines/accessibility
- Smashing Magazine — Building Self-Correcting Color Systems With contrast-color() — https://www.smashingmagazine.com/2026/05/building-self-correcting-color-systems-contrast-color/
- Motion for React — Accessibility / useReducedMotion — https://motion.dev/docs/react-accessibility
- MDN —
line-break(CJK line breaking) — https://developer.mozilla.org/en-US/docs/Web/CSS/line-break - MDN —
text-spacing-trim— https://developer.mozilla.org/en-US/docs/Web/CSS/text-spacing-trim - A11Y Pros — Accessibility in design systems — https://a11ypros.com/blog/accessibility-in-design-systems