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.
Tại sao Accessibility là kỹ thuật, không phải checkbox
Accessibility không phải lớp hoàn thiện gắn thêm trước khi ship. Đó 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.
Ba lực hội tụ với team senior:
| Force | Why it matters |
|---|---|
| Legal | 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 | Đạ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 | 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. |
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 và mức tuân thủ
WCAG 2.2 tổ chức yêu cầu quanh bốn nguyên tắc:
| Principle | Meaning | Example success criterion |
|---|---|---|
| Perceivable | 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 dùng được bằng bàn phím và input hỗ trợ | 2.1.1 Keyboard — all functionality available from keyboard |
| Understandable | Nội dung và hành vi dự đoán được | 3.3.1 Error Identification — errors described in text |
| Robust | Hoạt động trên nhiều browser và AT | 4.1.2 Name, Role, Value — programmatically determinable |
Mức tuân thủ xếp chồng:
| Level | Scope | Typical target |
|---|---|---|
| A | Mức tối thiểu; lỗi chặn truy cập cơ bản | Rarely sufficient alone for enterprise products |
| AA | Chuẩn ngành cho hầu hết quy định và mua sắm | Default target for design systems and SaaS |
| AAA | 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) |
Tiêu chí WCAG cần nhưng chưa đủ. 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.
Accessibility Tree — Assistive tech thực sự thấy gì
Browser không expose DOM thô cho screen reader. 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>
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 |
Tính toán Accessible Name
Accessible name là chuỗi screen reader đọc khi focus vào element. Ưu tiên (rút gọn) theo spec Accessible Name and Description Computation:
- tham chiếu một hoặc nhiều element, nối text
- chuỗi do tác giả cung cấp
- liên kết label native hoặc text content của element
- 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>
Tại sao quan trọng với principal: Design system thường ship primitive “headless” mà nguồn name mơ hồ. Ghi rõ prop nào thắng (label, aria-label, children) và enforce trong component API.
Role: Ngầm vs Tường minh
HTML native mang implicit role — <button> → button, <a href> → link, v.v..
Thêm role="button" trên <div> không cho hành vi button — chỉ thông báo. Phải tự implement kích hoạt bàn phím, focusability và disabled.
HTML ngữ nghĩa trước — Năm quy tắc ARIA
Spec WAI-ARIA mở đầu bằng năm quy tắc:
- Đừng dùng ARIA nếu đã có element HTML native.
- Đừng đổi semantics native trừ khi thực sự cần.
- Mọi control ARIA tương tác phải dùng được bằng bàn phím.
- Đừng dùng
role="presentation"hayaria-hidden="true"trên element focusable. - Mọi element tương tác phải có accessible name.
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 |
Lỗi phổ biến: Dùng
role="menu"vàrole="menuitem"cho navigation site chính. Trong ARIA, menu là widget ứng dụng (context menu, menubar), không phải danh sách link. Nav site nên là<nav>với danh sách link.
Accessibility bàn phím — Focus là con trỏ của người dùng AT
Nếu không reach và operate được bằng bàn phím thì không accessible. 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.
Thứ tự focus và tabindex
Thứ tự Tab theo thứ tự DOM giữa element focusable. Chỉ những cái này tham gia sequential focus navigation mặc định: <a href>, <button>, <input>, <select>, <textarea>,
tabindex value | Behavior |
|---|---|
| (absent) | Quy tắc focusability native |
0 | Đưa element vào tab order tự nhiên tại vị trí DOM |
-1 | Focus được bằng code nhưng bỏ qua trong chuỗi tab |
positive (1, 2, …) | Antipattern — tạo tab order tùy chỉnh chống lại DOM order và gây rối cho người dùng |
<!-- ✅ 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 — Hiện focus khi cần
:focus kích hoạt với mọi focus, kể cả click chuột. :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 link
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>
Đặt tabindex="-1" trên <main> cho phép focus programmatic sau skip để screen reader vào main content.
Quản lý focus trong Dialog
Khi modal mở:
- Chuyển focus tới element focusable đầu tiên.
- Giữ focus trong dialog đến khi đóng.
- Khi đóng, khôi phục focus về element mở dialog.
Escapeđóng (trừ hành động phá huỷ cần xác nhận).
Element <dialog> native với .showModal() cung cấp top layer, backdrop inert và hành vi Escape trên browser hỗ trợ. Vẫn cần nối focus restore và verify trap behavior trên nhiều browser.
Widget phức tạp — WAI-ARIA Authoring Practices
ARIA Authoring Practices Guide (APG) mô tả tương tác bàn phím, role và quản lý state cho widget composite. Dưới đây là pattern tối thiểu production-grade.
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
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 reader thường announce dialog và có thể đổi reading mode.
Tabs — Roving tabindex
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
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 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á. Ghi arrow-key behavior trong docs design system.
Combobox / Autocomplete
Combobox kết hợp input text với popup listbox. 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-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>
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. Tránh mất focus trong input.
Bàn phím (APG): Down/Up mở list và di chuyển option; Enter chọn; Escape đóng; gõ lọc.
Ghi chú principal: Combobox là widget lỗi nhiều nhất — chỉ ship khi có đủ keyboard APG hoặc hoãn.
Disclosure / Accordion
Ư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>
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>
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 region — Thông báo cập nhật động
SPA và UI async mutate DOM không reload trang. 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" | Chờ pause trong speech | Status updates, form save confirmation |
aria-live="assertive" | 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');
Đổi route SPA: Chuyển focus tới <main> (hoặc heading riêng) và announce title trang mới. Mô phỏng full navigation.
Pitfall: Nhiều region assertive đồng thời gây thông báo hỗn loạn. Queue hoặc dedupe announcement trong app shell.
Form — Label, lỗi và trường bắt buộc
Label không thể thương lượng
Mọi input cần label hiển thị hoặc programmatic. Placeholder không phải label — 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" />
Thông báo lỗi
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>
Luồng screen reader: Focus input → name “Email” → description gồm hint và lỗi nếu có. Chuyển focus tới lỗi thay vì liên kết buộc user tìm kiếm — Dùng role="alert" trên summary khi submit fail, nhưng giữ liên kết cấp field.
Nhóm với fieldset / legend
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>
Yếu tố thị giác và vận động
Tương phản màu
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 |
Đừng chỉ dựa màu để truyền state (thêm icon, text hoặc pattern). 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
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;
}
}
Rối loạn tiền đình khiến parallax và transition lớn có hại. Cung cấp thông tin tương đương không cần motion.
Zoom, reflow và kích thước target
- Nội dung dùng được ở zoom 200%.
- Không scroll ngang ở viewport 320px (có ngoại lệ).
- Target tương tác tối thiểu 24×24 CSS px (hoặc spacing đủ).
Icon button 16px không padding hit area fail audit touch và motor.
Testing — Tự động, AT và CI
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([]);
Test chỉ bàn phím
Rút chuột và đi hết flow chính bằng phím. Verify focus nhìn thấy, trap/restore overlay, và lỗi liên kết khi submit.
axe không bắt được gì
- Tab order vô nghĩa nhưng DOM hợp lệ
- Text link
"Click here" - Timing và politeness
aria-liveđúng - Keyboard model widget custom có khớp APG
Nguyên tắc: Tự động hoá regression; validate trải nghiệm thủ công.
Checklist Principal — Ship 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 |
Kết — Accessibility như thuộc tính hệ thống
Accessibility nổi lên từ semantics, keyboard, announcement và inclusivity thị giác. 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.
Quy tắc đầu tiên: đừng dùng ARIA khi HTML native đủ. Quy tắc thứ hai: implement đủ APG hoặc đừng ship.
Further reading: APG · WCAG 2.2 Quick Ref · Inclusive Components · Deque University