Web Accessibility Deep Dive — ARIA, the Accessibility Tree, and Complex Widgets
A senior guide to the accessibility tree, ARIA authoring patterns, keyboard semantics, live regions, and pragmatic a11y testing for design systems.
Why Accessibility Is Engineering, Not a Checkbox {Tại sao Accessibility là kỹ thuật, không phải checkbox}
Accessibility is not a polish layer you bolt on before launch {Accessibility không phải lớp hoàn thiện gắn thêm trước khi ship}. It is a contract between your UI and assistive technologies (screen readers, switch devices, voice control, high-contrast modes) that expose your app through platform accessibility APIs {Đó là hợp đồng giữa UI và assistive technologies (screen reader, thiết bị switch, điều khiển giọng nói, chế độ tương phản cao) thông qua platform accessibility APIs}.
Three forces converge for senior teams {Ba lực hội tụ với team senior}:
| Force | Why it matters |
|---|---|
| Legal | ADA, Section 508, EN 301 549, and EU accessibility directives create real liability for public-facing products {Pháp lý: ADA, Section 508, EN 301 549 và chỉ thị EU tạo rủi ro pháp lý thật cho sản phẩm công khai}. |
| Ethical | ~15% of the global population lives with a disability; excluding them is a product failure, not a niche edge case {Đạo đức: ~15% dân số thế giới có khuyết tật; loại trừ họ là lỗi sản phẩm, không phải edge case nhỏ}. |
| Engineering quality | Semantic HTML, predictable keyboard flows, and visible focus states improve everyone’s experience — power users, mobile, slow networks, automated testing {Chất lượng kỹ thuật: HTML ngữ nghĩa, luồng bàn phím dự đoán được và focus rõ ràng cải thiện trải nghiệm mọi người — power user, mobile, mạng chậm, automated testing}. |
Principal takeaway: Treat a11y like performance or security — define budgets, automate regression checks, and design components with accessibility semantics baked in from day one {Bài học principal: Coi a11y như performance hay security — đặt ngân sách, tự động hoá regression check, và thiết kế component với semantics accessibility từ ngày đầu}.
WCAG 2.2 — POUR and Conformance Levels {WCAG 2.2 — POUR và mức tuân thủ}
WCAG 2.2 organizes requirements around four principles {WCAG 2.2 tổ chức yêu cầu quanh bốn nguyên tắc}:
| Principle | Meaning | Example success criterion |
|---|---|---|
| Perceivable | Users can perceive content regardless of sense {Người dùng nhận thức được nội dung bất kể giác quan} | 1.4.3 Contrast (Minimum) — 4.5:1 for normal text |
| Operable | UI is usable via keyboard and assistive input {UI dùng được bằng bàn phím và input hỗ trợ} | 2.1.1 Keyboard — all functionality available from keyboard |
| Understandable | Content and behavior are predictable {Nội dung và hành vi dự đoán được} | 3.3.1 Error Identification — errors described in text |
| Robust | Works across browsers and AT {Hoạt động trên nhiều browser và AT} | 4.1.2 Name, Role, Value — programmatically determinable |
Conformance levels stack {Mức tuân thủ xếp chồng}:
| Level | Scope | Typical target |
|---|---|---|
| A | Minimum bar; failures block basic access {Mức tối thiểu; lỗi chặn truy cập cơ bản} | Rarely sufficient alone for enterprise products |
| AA | Industry standard for most regulations and procurement {Chuẩn ngành cho hầu hết quy định và mua sắm} | Default target for design systems and SaaS |
| AAA | Enhanced; often impractical for entire products {Nâng cao; thường không khả thi cho toàn bộ sản phẩm} | Cherry-pick specific criteria (e.g. 1.4.6 enhanced contrast) |
WCAG success criteria are necessary but not sufficient {Tiêu chí WCAG cần nhưng chưa đủ}. Passing automated checks does not guarantee a good screen reader experience — you still need manual keyboard and AT testing {Pass automated check không đảm bảo trải nghiệm screen reader tốt — vẫn cần test bàn phím và AT thủ công}.
The Accessibility Tree — What Assistive Tech Actually Sees {Accessibility Tree — Assistive tech thực sự thấy gì}
Browsers do not expose the raw DOM to screen readers {Browser không expose DOM thô cho screen reader}. They build a parallel structure: the accessibility tree (also called the AX tree or platform accessibility tree) {Chúng dựng cấu trúc song song: accessibility tree (còn gọi AX tree hoặc platform accessibility tree)}.
DOM Accessibility tree (simplified)
─────────────────────────────────────────────────────────────
<div class="card"> ignored (no semantic role)
<h2>Settings</h2> → heading, level=2, name="Settings"
<button>Save</button> → button, name="Save", focusable
<span aria-hidden> → pruned from tree
decorative icon
</span>
</div>
Each accessible node carries {Mỗi node accessible mang}:
| Property | Description |
|---|---|
| Role | What it is (button, link, heading, dialog, tab) — from implicit HTML semantics or explicit ARIA |
| Name | What it is called — computed from visible text, aria-label, aria-labelledby, or alt |
| States | Dynamic booleans — checked, expanded, selected, disabled, pressed |
| Properties | Additional metadata — aria-controls, aria-describedby, aria-haspopup |
Accessible Name Computation {Tính toán Accessible Name}
The accessible name is the string a screen reader announces when focus lands on an element {Accessible name là chuỗi screen reader đọc khi focus vào element}. Priority (simplified) follows the Accessible Name and Description Computation spec {Ưu tiên (rút gọn) theo spec Accessible Name and Description Computation}:
aria-labelledby— references one or more elements whose text is concatenated {tham chiếu một hoặc nhiều element, nối text}aria-label— author-provided string {chuỗi do tác giả cung cấp}- Native label association (
<label for>→<input>) or element’s own text content {liên kết label native hoặc text content của element} titleattribute — fallback only, not a substitute for visible labels {chỉ fallback, không thay label hiển thị}
<!-- ✅ Preferred: visible label + programmatic association -->
<label for="email">Email address</label>
<input id="email" type="email" autocomplete="email" />
<!-- ✅ When visual design hides the label, still expose a name -->
<label for="search" class="sr-only">Search products</label>
<input id="search" type="search" placeholder="Search…" />
<!-- ⚠️ aria-label overrides visible text — use when intentional -->
<button aria-label="Close dialog">×</button>
<!-- ❌ Icon-only button with no accessible name -->
<button><svg aria-hidden="true">…</svg></button>
Why this matters for principals: Design systems often ship “headless” primitives where the name source is ambiguous {Tại sao quan trọng với principal: Design system thường ship primitive “headless” mà nguồn name mơ hồ}. Document which prop wins (label, aria-label, children) and enforce it in component APIs {Ghi rõ prop nào thắng (label, aria-label, children) và enforce trong component API}.
Roles: Implicit vs Explicit {Role: Ngầm vs Tường minh}
Native HTML carries implicit roles — <button> → button, <a href> → link, <input type="checkbox"> → checkbox, <nav> → navigation, <main> → main {HTML native mang implicit role — <button> → button, <a href> → link, v.v.}.
Adding role="button" on a <div> does not give you button behavior — only the announcement {Thêm role="button" trên <div> không cho hành vi button — chỉ thông báo}. You must implement keyboard activation (Enter/Space), focusability, and disabled state yourself {Phải tự implement kích hoạt bàn phím, focusability và disabled}.
Semantic HTML First — The Five Rules of ARIA {HTML ngữ nghĩa trước — Năm quy tắc ARIA}
The WAI-ARIA specification opens with five rules {Spec WAI-ARIA mở đầu bằng năm quy tắc}:
- Don’t use ARIA if a native HTML element exists {Đừng dùng ARIA nếu đã có element HTML native}.
- Don’t change native semantics unless you really mean it (e.g.
<h2 role="button">is almost always wrong) {Đừng đổi semantics native trừ khi thực sự cần}. - All interactive ARIA controls must be keyboard operable {Mọi control ARIA tương tác phải dùng được bằng bàn phím}.
- Don’t use
role="presentation"oraria-hidden="true"on focusable elements {Đừng dùngrole="presentation"hayaria-hidden="true"trên element focusable}. - All interactive elements must have an accessible name {Mọi element tương tác phải có accessible name}.
When Native Elements Win {Khi nào element native thắng}
| Need | Prefer | Avoid |
|---|---|---|
| Toggle | <input type="checkbox"> | <div role="checkbox"> |
| Expand/collapse | <details> / <summary> | Accordion without keyboard |
| Modal | <dialog> | <div role="dialog"> without trap |
| Site nav | <nav> + links | role="menu" for primary nav |
Common mistake: Using
role="menu"androle="menuitem"for primary site navigation {Lỗi phổ biến: Dùngrole="menu"vàrole="menuitem"cho navigation site chính}. In ARIA, a menu is an application widget (like a context menu or menubar), not a list of links {Trong ARIA, menu là widget ứng dụng (context menu, menubar), không phải danh sách link}. Site nav should be a<nav>with a list of links {Nav site nên là<nav>với danh sách link}.
Keyboard Accessibility — Focus Is the Cursor of AT Users {Accessibility bàn phím — Focus là con trỏ của người dùng AT}
If it is not reachable and operable by keyboard, it is not accessible {Nếu không reach và operate được bằng bàn phím thì không accessible}. Keyboard users include people with motor disabilities, power users, and anyone whose pointer device failed {Người dùng bàn phím gồm người khuyết tật vận động, power user, và ai đó thiết bị trỏ hỏng}.
Focus Order and tabindex {Thứ tự focus và tabindex}
Tab order follows DOM order among focusable elements {Thứ tự Tab theo thứ tự DOM giữa element focusable}. Only these participate in sequential focus navigation by default {Chỉ những cái này tham gia sequential focus navigation mặc định}: <a href>, <button>, <input>, <select>, <textarea>, and elements with tabindex="0" or positive tabindex.
tabindex value | Behavior |
|---|---|
| (absent) | Native focusability rules apply {Quy tắc focusability native} |
0 | Inserts element into natural tab order at its DOM position {Đưa element vào tab order tự nhiên tại vị trí DOM} |
-1 | Focusable programmatically (element.focus()) but skipped in tab sequence {Focus được bằng code nhưng bỏ qua trong chuỗi tab} |
positive (1, 2, …`) | Antipattern — creates a custom tab order that fights DOM order and confuses users {Antipattern — tạo tab order tùy chỉnh chống lại DOM và gây rối} |
<!-- ✅ Roving tabindex pattern: one tab stop, arrow keys inside -->
<div role="tablist">
<button role="tab" tabindex="0" aria-selected="true">General</button>
<button role="tab" tabindex="-1" aria-selected="false">Security</button>
</div>
:focus-visible — Show Focus When It Matters {:focus-visible — Hiện focus khi cần}
:focus fires for any focus, including mouse clicks {:focus kích hoạt với mọi focus, kể cả click chuột}. :focus-visible matches when the browser determines focus should be ** visibly indicated** (typically keyboard) {:focus-visible khớp khi browser quyết focus nên hiển thị rõ (thường là bàn phím)}.
/* Remove default outline only if you replace it */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* ❌ Never do this globally */
/* *:focus { outline: none; } */
Skip Links {Skip link}
A skip link is the first focusable element on the page, jumping past repetitive chrome to #main {Skip link là element focusable đầu tiên trên trang, nhảy qua chrome lặp tới #main}.
<a href="#main" class="skip-link">Skip to main content</a>
<!-- … header, nav … -->
<main id="main" tabindex="-1">…</main>
Setting tabindex="-1" on <main> allows programmatic focus after skip navigation so screen readers land inside main content {Đặt tabindex="-1" trên <main> cho phép focus programmatic sau skip để screen reader vào main content}.
Focus Management in Dialogs {Quản lý focus trong Dialog}
When a modal opens {Khi modal mở}:
- Move focus to the first focusable element (often the dialog itself or the primary control) {Chuyển focus tới element focusable đầu tiên}.
- Trap focus inside the dialog until it closes {Giữ focus trong dialog đến khi đóng}.
- On close, restore focus to the element that opened the dialog {Khi đóng, khôi phục focus về element mở dialog}.
Escapecloses (unless destructive action requires confirmation) {Escapeđóng (trừ hành động phá huỷ cần xác nhận)}.
The native <dialog> element with .showModal() provides top layer, inert backdrop, and Escape behavior in supporting browsers {Element <dialog> native với .showModal() cung cấp top layer, backdrop inert và hành vi Escape trên browser hỗ trợ}. You still need to wire focus restore and verify trap behavior across browsers {Vẫn cần nối focus restore và verify trap behavior trên nhiều browser}.
Complex Widgets — WAI-ARIA Authoring Practices {Widget phức tạp — WAI-ARIA Authoring Practices}
The ARIA Authoring Practices Guide (APG) documents keyboard interaction, roles, and state management for composite widgets {ARIA Authoring Practices Guide (APG) mô tả tương tác bàn phím, role và quản lý state cho widget composite}. Below are production-grade minimal patterns {Dưới đây là pattern tối thiểu production-grade}.
Accessible Modal with <dialog> {Modal accessible với <dialog>}
<button type="button" id="open-settings">Settings</button>
<dialog id="settings-dialog" aria-labelledby="settings-title">
<header>
<h2 id="settings-title">Settings</h2>
<button type="button" aria-label="Close" data-close>×</button>
</header>
<form method="dialog">
<label for="theme">Theme</label>
<select id="theme" name="theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
<menu>
<button type="submit" value="save">Save</button>
<button type="button" data-close>Cancel</button>
</menu>
</form>
</dialog>
const dialog = document.getElementById('settings-dialog');
const trigger = document.getElementById('open-settings');
let previousFocus = null;
trigger.addEventListener('click', () => {
previousFocus = document.activeElement;
dialog.showModal();
});
dialog.addEventListener('close', () => previousFocus?.focus());
dialog.querySelectorAll('[data-close]').forEach((btn) => {
btn.addEventListener('click', () => dialog.close());
});
// Add Tab-wrap focus trap if target browsers lack native dialog focus management
Why <dialog>: It maps to role="dialog", participates in the top layer, and blocks interaction with the page behind it {Tại sao <dialog>: Map tới role="dialog", tham gia top layer, chặn tương tác với trang phía sau}. Screen readers typically announce it as a dialog and may switch reading mode {Screen reader thường announce dialog và có thể đổi reading mode}.
Tabs — Roving tabindex {Tabs — Roving tabindex}
The APG tabs pattern uses {Pattern tabs APG dùng}:
role="tablist"container witharia-orientation="horizontal"(orvertical)- Each tab:
role="tab",aria-selected,aria-controlspointing to panelid - Each panel:
role="tabpanel",tabindex="0"(optional — allows panel to receive focus),aria-labelledbythe tab
Keyboard: Arrow keys move between tabs; Home/End jump to first/last; Tab exits the tablist into the active panel {Bàn phím: Arrow di chuyển giữa tab; Home/End nhảy đầu/cuối; Tab thoát tablist vào panel active}.
<div>
<div role="tablist" aria-label="Account settings">
<button role="tab" id="tab-general" aria-selected="true" aria-controls="panel-general" tabindex="0">
General
</button>
<button role="tab" id="tab-security" aria-selected="false" aria-controls="panel-security" tabindex="-1">
Security
</button>
</div>
<div role="tabpanel" id="panel-general" aria-labelledby="tab-general" tabindex="0">
General settings content
</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden tabindex="0">
Security settings content
</div>
</div>
function activateTab(tab, tabs) {
tabs.forEach((t) => {
const selected = t === tab;
t.setAttribute('aria-selected', String(selected));
t.tabIndex = selected ? 0 : -1;
document.getElementById(t.getAttribute('aria-controls')).hidden = !selected;
});
tab.focus();
}
// Wire ArrowLeft/ArrowRight/Home/End on tablist; click handlers call activateTab
Trade-off: Roving tabindex means only one tab is in the tab sequence — this reduces tab stops for keyboard users but requires arrow-key discoverability {Trade-off: Roving tabindex nghĩa chỉ một tab trong tab sequence — giảm tab stop cho user bàn phím nhưng cần arrow key dễ khám phá}. Document arrow-key behavior in your design system docs {Ghi arrow-key behavior trong docs design system}.
Combobox / Autocomplete {Combobox / Autocomplete}
A combobox combines a text input with a popup listbox {Combobox kết hợp input text với popup listbox}. Critical semantics {Semantics quan trọng}:
- Input:
role="combobox",aria-expanded,aria-controls(listbox id),aria-autocomplete="list"(orboth) - Listbox:
role="listbox", options asrole="option"witharia-selected aria-activedescendanton the input points to the highlighted option id while focus stays in the input {aria-activedescendanttrên input trỏ tới id option highlighted trong khi focus ở input}
<label for="country-input">Country</label>
<div class="combobox">
<input
id="country-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="country-listbox"
aria-autocomplete="list"
aria-activedescendant=""
autocomplete="off"
/>
<ul id="country-listbox" role="listbox" hidden>
<li id="opt-vn" role="option" aria-selected="false">Vietnam</li>
<li id="opt-us" role="option" aria-selected="false">United States</li>
</ul>
</div>
Screen reader behavior: With aria-activedescendant, NVDA and VoiceOver announce the active option as you arrow through the list without moving DOM focus {Hành vi screen reader: Với aria-activedescendant, NVDA và VoiceOver announce option active khi arrow list mà không di chuyển DOM focus}. This prevents focus loss in the input field {Tránh mất focus trong input}.
Keyboard (APG): Down/Up open list and move active option; Enter selects; Escape closes; typing filters {Bàn phím (APG): Down/Up mở list và di chuyển option; Enter chọn; Escape đóng; gõ lọc}.
Principal note: Combobox is among the highest-bug widgets in design systems — ship only with full APG keyboard coverage or defer {Ghi chú principal: Combobox là widget lỗi nhiều nhất — chỉ ship khi có đủ keyboard APG hoặc hoãn}.
Disclosure / Accordion {Disclosure / Accordion}
Prefer native <details> when one section toggles independently {Ưu tiên <details> native khi một section toggle độc lập}:
<details>
<summary>Shipping policy</summary>
<p>Free shipping on orders over $50.</p>
</details>
For accordion (one panel open at a time, shared header list), use role="button" on headers or native <button> inside headings, with aria-expanded and aria-controls {Cho accordion (một panel mở, danh sách header chung), dùng <button> trong heading với aria-expanded và aria-controls}:
<h3>
<button type="button" aria-expanded="false" aria-controls="faq-1" id="faq-1-btn">
What is your return policy?
</button>
</h3>
<div id="faq-1" role="region" aria-labelledby="faq-1-btn" hidden>
Returns accepted within 30 days.
</div>
Why <button> in <h3>: Preserves heading level for document outline while exposing button semantics and keyboard activation {Tại sao <button> trong <h3>: Giữ heading level cho outline document đồng thời expose button semantics và kích hoạt bàn phím}.
Live Regions — Announcing Dynamic Updates {Live region — Thông báo cập nhật động}
SPAs and async UI mutate the DOM without full page loads {SPA và UI async mutate DOM không reload trang}. Sighted users see spinners and toasts; screen reader users hear nothing unless you use live regions {User nhìn thấy spinner và toast; user screen reader không nghe gì nếu không dùng live region}.
| Mechanism | Politeness | Use case |
|---|---|---|
aria-live="polite" | Waits for pause in speech {Chờ pause trong speech} | Status updates, form save confirmation |
aria-live="assertive" | Interrupts immediately {Ngắt ngay} | Critical errors (use sparingly) |
role="status" | Implicit aria-live="polite" | Loading complete, item added to cart |
role="alert" | Implicit aria-live="assertive" | Validation errors affecting submission |
<div role="status" aria-live="polite" aria-atomic="true" class="sr-only" id="route-announcer"></div>
function announce(message) {
const region = document.getElementById('route-announcer');
region.textContent = '';
// Clearing then setting forces re-announcement in most AT
requestAnimationFrame(() => {
region.textContent = message;
});
}
// After client-side route change
announce('Navigated to Dashboard');
SPA route changes: Move focus to <main> (or a dedicated heading) and announce the new page title {Đổi route SPA: Chuyển focus tới <main> (hoặc heading riêng) và announce title trang mới}. This mirrors what happens on a full navigation {Mô phỏng full navigation}.
Pitfall: Multiple simultaneous assertive regions create chaotic announcements {Pitfall: Nhiều region assertive đồng thời gây thông báo hỗn loạn}. Queue or dedupe announcements in your app shell {Queue hoặc dedupe announcement trong app shell}.
Forms — Labels, Errors, and Required Fields {Form — Label, lỗi và trường bắt buộc}
Labels Are Non-Negotiable {Label không thể thương lượng}
Every input needs a visible or programmatic label {Mọi input cần label hiển thị hoặc programmatic}. Placeholder is not a label {Placeholder không phải label} — it disappears on input and often fails contrast requirements {Biến mất khi nhập và thường fail contrast}.
<label for="password">Password</label>
<input id="password" type="password" autocomplete="current-password" required aria-required="true" />
Error Messaging {Thông báo lỗi}
Associate errors with inputs using aria-describedby (can reference multiple ids) and mark invalid state with aria-invalid="true" {Liên kết lỗi với input bằng aria-describedby (nhiều id) và đánh dấu invalid bằng aria-invalid="true"}:
<label for="email">Email</label>
<input
id="email"
type="email"
aria-describedby="email-hint email-error"
aria-invalid="true"
/>
<p id="email-hint">We will never share your email.</p>
<p id="email-error" role="alert">Enter a valid email address.</p>
Screen reader flow: Focus on input → name “Email” → description includes hint and error text when present {Luồng screen reader: Focus input → name “Email” → description gồm hint và lỗi nếu có}. Moving focus to the error instead of associating it forces users to hunt {Chuyển focus tới lỗi thay vì liên kết buộc user tìm kiếm} — use role="alert" on summary for submit failures, but keep field-level association {Dùng role="alert" trên summary khi submit fail, nhưng giữ liên kết cấp field}.
Grouping with fieldset / legend {Nhóm với fieldset / legend}
Radio and checkbox groups need a legend for the group name {Nhóm radio và checkbox cần legend cho tên nhóm}:
<fieldset>
<legend>Notification preferences</legend>
<label><input type="checkbox" name="notify" value="email" /> Email</label>
<label><input type="checkbox" name="notify" value="sms" /> SMS</label>
</fieldset>
Visual and Motor Considerations {Yếu tố thị giác và vận động}
Color Contrast {Tương phản màu}
WCAG 2.2 level AA requires {WCAG 2.2 level AA yêu cầu}:
| Content | Minimum contrast ratio |
|---|---|
| Normal text (< 18pt / < 14pt bold) | 4.5:1 |
| Large text (≥ 18pt / ≥ 14pt bold) | 3:1 |
| UI components and graphical objects | 3:1 against adjacent colors |
Do not rely on color alone to convey state (add icon, text, or pattern) {Đừng chỉ dựa màu để truyền state (thêm icon, text hoặc pattern)}. Your terminal aesthetic with lime accent on dark bg — verify tokens with WebAIM Contrast Checker or axe {Aesthetic terminal với accent lime trên nền tối — verify token bằng công cụ contrast hoặc axe}.
prefers-reduced-motion {prefers-reduced-motion}
Respect the user’s OS setting {Tôn trọng cài OS của user}:
@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;
}
}
Vestibular disorders make parallax and large transitions harmful {Rối loạn tiền đình khiến parallax và transition lớn có hại}. Provide equivalent information without motion {Cung cấp thông tin tương đương không cần motion}.
Zoom, Reflow, and Target Size {Zoom, reflow và kích thước target}
- 1.4.4 Resize text: Content must remain usable at 200% zoom {Nội dung dùng được ở zoom 200%}.
- 1.4.10 Reflow: No horizontal scroll at 320px viewport width (with exceptions) {Không scroll ngang ở viewport 320px (có ngoại lệ)}.
- 2.5.8 Target size (AA, new in 2.2): Interactive targets at least 24×24 CSS px (or sufficient spacing) {Target tương tác tối thiểu 24×24 CSS px (hoặc spacing đủ)}.
Icon buttons at 16px with no hit area padding fail both touch and motor accessibility audits {Icon button 16px không padding hit area fail audit touch và motor}.
Testing — Automation, AT, and CI {Testing — Tự động, AT và CI}
Layered Testing Strategy {Chiến lược test phân lớp}
| Layer | Tool / method | Catches |
|---|---|---|
| Static | eslint-plugin-jsx-a11y, TypeScript prop types | Missing alt, invalid ARIA, bad roles |
| Unit / component | axe-core via @axe-core/playwright or jest-axe | ~57% of WCAG issues (Deque claim); zero false sense of completeness |
| Integration | Playwright/Cypress + axe after interactions | State bugs (expanded/collapsed, dialog open) |
| Manual | Keyboard-only pass, 200% zoom | Focus order, visual regressions |
| AT | NVDA (Windows), VoiceOver (macOS/iOS), JAWS (enterprise) | Announcement order, verbosity, browse vs focus mode |
// axe in Playwright — run after interactions, not only on static HTML
const results = await new AxeBuilder({ page }).include('#settings-dialog').analyze();
expect(results.violations.filter((v) => v.impact === 'critical')).toEqual([]);
Keyboard-Only Pass {Test chỉ bàn phím}
Unplug the mouse and traverse every major flow with Tab, arrows, Enter, Space, and Escape {Rút chuột và đi hết flow chính bằng phím}. Verify visible focus on every stop, trap/restore in overlays, and associated error announcements on submit {Verify focus nhìn thấy, trap/restore overlay, và lỗi liên kết khi submit}.
What axe Cannot Catch {axe không bắt được gì}
- Nonsensical tab order that is technically valid DOM order {Tab order vô nghĩa nhưng DOM hợp lệ}
"Click here"link text {Text link"Click here"}- Correct
aria-livetiming and politeness {Timing và politenessaria-liveđúng} - Whether custom widget keyboard model matches APG {Keyboard model widget custom có khớp APG}
Rule of thumb: Automate regression; manually validate experience {Nguyên tắc: Tự động hoá regression; validate trải nghiệm thủ công}.
Principal Engineer Checklist — Shipping a Design System {Checklist Principal — Ship design system}
Use this before marking a component stable in your design system {Dùng trước khi đánh dấu component stable trong design system}:
| Area | Check before marking stable |
|---|---|
| Tokens | 4.5:1 text / 3:1 UI contrast; :focus-visible on all primitives; prefers-reduced-motion |
| Landmarks | Skip link; header, nav, main, footer regions |
| Component API | Documented keyboard map; explicit name source; disabled vs aria-disabled semantics |
| Composite widgets | Dialog trap + restore; tabs roving tabindex; combobox aria-activedescendant; single live-region manager |
| Process | axe in CI; keyboard + AT smoke test per release; limitations and VPAT for regulated customers |
Closing — Accessibility as a System Property {Kết — Accessibility như thuộc tính hệ thống}
Accessibility emerges from semantics, keyboard models, announcement strategy, and visual inclusivity working together {Accessibility nổi lên từ semantics, keyboard, announcement và inclusivity thị giác}. Know where to look (APG, accname, WCAG), how AT traverses the tree, and which headless abstractions leak without keyboard contracts {Biết tra ở đâu, AT duyệt tree thế nào, và abstraction nào rò rỉ khi thiếu hợp đồng bàn phím}.
The first rule of ARIA: don’t use ARIA when native HTML suffices {Quy tắc đầu tiên: đừng dùng ARIA khi HTML native đủ}. The second: when you build complex widgets, implement the full APG contract or don’t ship {Quy tắc thứ hai: implement đủ APG hoặc đừng ship}.
Further reading: APG · WCAG 2.2 Quick Ref · Inclusive Components · Deque University