jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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}:

ForceWhy it matters
LegalADA, 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 qualitySemantic 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}:

PrincipleMeaningExample success criterion
PerceivableUsers 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
OperableUI 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
UnderstandableContent and behavior are predictable {Nội dung và hành vi dự đoán được}3.3.1 Error Identification — errors described in text
RobustWorks 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}:

LevelScopeTypical target
AMinimum 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
AAIndustry 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
AAAEnhanced; 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}:

PropertyDescription
RoleWhat it is (button, link, heading, dialog, tab) — from implicit HTML semantics or explicit ARIA
NameWhat it is called — computed from visible text, aria-label, aria-labelledby, or alt
StatesDynamic booleans — checked, expanded, selected, disabled, pressed
PropertiesAdditional 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}:

  1. aria-labelledby — references one or more elements whose text is concatenated {tham chiếu một hoặc nhiều element, nối text}
  2. aria-label — author-provided string {chuỗi do tác giả cung cấp}
  3. 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}
  4. title attribute — 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}:

  1. Don’t use ARIA if a native HTML element exists {Đừng dùng ARIA nếu đã có element HTML native}.
  2. 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}.
  3. 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}.
  4. Don’t use role="presentation" or aria-hidden="true" on focusable elements {Đừng dùng role="presentation" hay aria-hidden="true" trên element focusable}.
  5. 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}

NeedPreferAvoid
Toggle<input type="checkbox"><div role="checkbox">
Expand/collapse<details> / <summary>Accordion without keyboard
Modal<dialog><div role="dialog"> without trap
Site nav<nav> + linksrole="menu" for primary nav

Common mistake: Using role="menu" and role="menuitem" for primary site navigation {Lỗi phổ biến: Dùng role="menu"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 valueBehavior
(absent)Native focusability rules apply {Quy tắc focusability native}
0Inserts element into natural tab order at its DOM position {Đưa element vào tab order tự nhiên tại vị trí DOM}
-1Focusable 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; } */

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ở}:

  1. 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}.
  2. Trap focus inside the dialog until it closes {Giữ focus trong dialog đến khi đóng}.
  3. On close, restore focus to the element that opened the dialog {Khi đóng, khôi phục focus về element mở dialog}.
  4. Escape closes (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, rolequả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 with aria-orientation="horizontal" (or vertical)
  • Each tab: role="tab", aria-selected, aria-controls pointing to panel id
  • Each panel: role="tabpanel", tabindex="0" (optional — allows panel to receive focus), aria-labelledby the 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" (or both)
  • Listbox: role="listbox", options as role="option" with aria-selected
  • aria-activedescendant on the input points to the highlighted option id while focus stays in the input {aria-activedescendant trê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-expandedaria-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}.

MechanismPolitenessUse 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) 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}:

ContentMinimum contrast ratio
Normal text (< 18pt / < 14pt bold)4.5:1
Large text (≥ 18pt / ≥ 14pt bold)3:1
UI components and graphical objects3: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}

LayerTool / methodCatches
Staticeslint-plugin-jsx-a11y, TypeScript prop typesMissing alt, invalid ARIA, bad roles
Unit / componentaxe-core via @axe-core/playwright or jest-axe~57% of WCAG issues (Deque claim); zero false sense of completeness
IntegrationPlaywright/Cypress + axe after interactionsState bugs (expanded/collapsed, dialog open)
ManualKeyboard-only pass, 200% zoomFocus order, visual regressions
ATNVDA (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-live timing and politeness {Timing và politeness aria-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}:

AreaCheck before marking stable
Tokens4.5:1 text / 3:1 UI contrast; :focus-visible on all primitives; prefers-reduced-motion
LandmarksSkip link; header, nav, main, footer regions
Component APIDocumented keyboard map; explicit name source; disabled vs aria-disabled semantics
Composite widgetsDialog trap + restore; tabs roving tabindex; combobox aria-activedescendant; single live-region manager
Processaxe 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, announcementinclusivity 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