Accessibility & Thiết kế bao trùmAccessibility & Inclusive Design
Thuộc bộ kiến thức Design System Roadmap.
Tổng quan
Accessibility là thực hành thiết kế và xây dựng sản phẩm sao cho người dùng có khiếm khuyết — về thị giác, thính giác, vận động, nhận thức, hoặc gặp hạn chế tình huống (situational) — vẫn có thể sử dụng được. Ở phần lớn tổ chức, accessibility bị coi như một checklist áp dụng ở cuối quy trình: ship UI trước, chạy audit sau, rồi vá những gì audit tìm ra. Design system tồn tại chính là để phá vỡ vòng lặp đó, và accessibility là minh chứng rõ ràng nhất cho lý do vì sao cả bộ môn này đáng để đầu tư.
Hãy xét một component Button được hai trăm màn hình trong sản phẩm sử dụng. Nếu button này được xây bằng <div onclick="..."> thay vì một <button> thật, mỗi màn hình trong hai trăm màn hình đó sẽ ship ra một phần tử không thể tiếp cận bằng bàn phím, không có accessible name được screen reader đọc lên, và không phản hồi với phím Enter hay Space như người dùng kỳ vọng. Sửa lỗi này sau khi đã ship nghĩa là phải tìm và test lại hai trăm nơi sử dụng. Nhưng nếu việc sửa diễn ra một lần duy nhất, ngay trong component Button dùng chung — đổi <div> thành <button>, kiểm tra lại focus và hành vi bàn phím, ship phiên bản mới — thì cả hai trăm màn hình đó được sửa đồng thời, mà các team tiêu thụ component thậm chí không cần biết có một bản fix vừa diễn ra. Đây chính là leverage (đòn bẩy), cùng loại leverage khiến design system có giá trị về mặt nhất quán thị giác, chỉ khác là áp dụng cho accessibility. Đây là lập luận mạnh nhất để xây accessibility vào bên trong component của hệ thống thay vì audit nó lên trên các màn hình đã hoàn thiện: một lần implement đúng, được thừa hưởng ở mọi nơi, thay vì cùng một lỗi bị lặp lại và sửa lại hàng trăm lần.
Hiệu ứng leverage này thay đổi việc ai chịu trách nhiệm cho cái gì. Không có design system, mỗi team sản phẩm phải tự biết luật accessibility cho từng component họ xây — một kỳ vọng không thực tế khi quy mô lớn. Có design system, gánh nặng được chuyển dịch: một nhóm nhỏ (thường có chuyên môn accessibility riêng) làm đúng các component Button, Modal, Dropdown, Tabs một lần, và các team tiêu thụ mặc định thừa hưởng sự đúng đắn đó chỉ bằng cách dùng hệ thống thay vì tự viết lại. Accessibility không còn là “một thứ nữa mà mọi engineer phải nhớ” mà trở thành “một thuộc tính của những component mà bạn vốn đã phải dùng.” Sự chuyển dịch đó — từ sự cẩn trọng cá nhân sang một đảm bảo mang tính hệ thống — là lý do accessibility nên nằm trong điều lệ của design system ngay từ ngày đầu, chứ không phải một sáng kiến giai đoạn hai gắn thêm sau khi component library đã tồn tại.
Cũng có một khía cạnh pháp lý và kinh doanh đáng nhắc ngắn gọn: các lỗi accessibility được ghi nhận, bị kiện tụng, và tốn kém (ở các khu vực pháp lý chịu điều chỉnh bởi ADA, EN 301 549, hoặc quy định tương tự), và sản phẩm không accessible loại trừ một phần đáng kể người dùng, bao gồm người dùng assistive technology và người gặp khiếm khuyết tạm thời hoặc tình huống (gãy tay, ánh nắng gắt trên màn hình điện thoại, môi trường ồn khiến audio không dùng được). Nhưng lập luận leverage ở trên mới là điều quan trọng nhất đối với riêng design system: đó là lý do “làm cho nó accessible” nên được giải quyết như một bài toán thiết kế hệ thống, chứ không phải bài toán từng màn hình riêng lẻ.
Kiến thức nền tảng
Bốn nguyên tắc POUR
Web Content Accessibility Guidelines (WCAG), do Web Accessibility Initiative của W3C duy trì, tổ chức mọi yêu cầu accessibility dưới bốn nguyên tắc, viết tắt là POUR. Mọi success criterion trong WCAG — từ color contrast đến hỗ trợ bàn phím đến tiêu đề trang — đều thuộc đúng một trong bốn nhóm này, khiến POUR trở thành một checklist tư duy hữu ích khi đánh giá xem một component mới có accessible hay không.
| Nguyên tắc | Ý nghĩa | Ý nghĩa với design system |
|---|---|---|
| Perceivable (Có thể cảm nhận) | Thông tin và các thành phần UI phải được trình bày sao cho người dùng có thể cảm nhận được — không phụ thuộc vào duy nhất một giác quan | Color token phải đạt contrast tối thiểu; icon cần có text alternative; video cần caption; nội dung không được truyền tải chỉ bằng màu sắc |
| Operable (Có thể vận hành) | Các thành phần UI và điều hướng phải vận hành được — dùng được mà không cần chuột, không giới hạn thời gian cứng, không gây co giật (seizure) | Mọi component tương tác phải thao tác được bằng bàn phím; focus phải hiển thị rõ; không có keyboard trap; đủ thời gian để hoàn thành luồng thao tác |
| Understandable (Có thể hiểu) | Thông tin và cách vận hành của UI phải dễ hiểu | Điều hướng và hành vi component nhất quán trong toàn hệ thống; thông báo lỗi rõ ràng; trạng thái component có thể dự đoán được |
| Robust (Vững chắc) | Nội dung phải đủ vững chắc để được diễn giải đáng tin cậy bởi nhiều loại user agent khác nhau, kể cả assistive technology | HTML hợp lệ, có ngữ nghĩa (semantic); ARIA đúng để phơi bày name, role, value ra accessibility tree; component được test với screen reader thật |
POUR cố tình được thiết kế độc lập với cách hiện thực cụ thể — nó mô tả kết quả, không phải kỹ thuật — đây chính là lý do nó phù hợp với lớp nguyên tắc (principles) của một design system: nó cho designer và engineer một ngôn ngữ chung để giải thích vì sao một component phải hành xử theo một cách nhất định, độc lập với success criterion cụ thể nào của WCAG hay thuộc tính ARIA nào đang được nói tới.
Các mức conformance: A, AA, AAA
Các success criterion của WCAG được nhóm thành ba mức conformance với độ nghiêm ngặt tăng dần. Hiểu sự khác biệt này quan trọng vì nói “chúng tôi tuân thủ WCAG” mà không nói rõ mức nào là một tuyên bố vô nghĩa, và vì một design system phải chọn một mức mục tiêu và giữ mọi component nhất quán ở mức đó.
| Mức | Ý nghĩa | Ví dụ yêu cầu điển hình | Tính thực tiễn |
|---|---|---|---|
| A (tối thiểu) | Accessibility ở mức nền tảng; nếu thiếu, một số người dùng hoàn toàn không tiếp cận được nội dung | Nội dung phi văn bản có text alternative; toàn bộ chức năng truy cập được bằng bàn phím; không có nội dung nhấp nháy gây seizure | Cần thiết nhưng còn xa mới đủ — chỉ đạt Level A vẫn khiến sản phẩm rất khó dùng với nhiều người |
| AA (trung bình, mục tiêu phổ biến) | Mức mà phần lớn luật pháp, tiêu chuẩn mua sắm, và tổ chức yêu cầu hoặc khuyến nghị | Contrast text 4.5:1 (3:1 cho large text); focus indicator hiển thị rõ; text resize được đến 200% mà không mất chức năng; điều hướng nhất quán xuyên suốt các trang | Mặc định thực dụng — khả thi với hầu hết sản phẩm, được luật pháp phần lớn khu vực tài phán tham chiếu (án lệ ADA, EN 301 549, Section 508), và bao phủ phần lớn tác động thực tế lên người dùng |
| AAA (nghiêm ngặt nhất) | Mức cao nhất, toàn diện nhất, với một số criterion mà chính W3C ghi nhận là không khả thi với mọi loại nội dung | Contrast text 7:1 (4.5:1 cho large text); phiên dịch ngôn ngữ ký hiệu cho audio đã ghi sẵn; không giới hạn thời gian cho bất kỳ tương tác nào | Hiếm khi được nhắm tới toàn bộ; thường chỉ áp dụng chọn lọc cho nội dung mức độ quan trọng cao (ví dụ thông tin khẩn cấp của chính phủ) thay vì áp dụng toàn sản phẩm |
AA là mục tiêu thực tiễn cho tuyệt đại đa số design system vì ba lý do hội tụ: đây là mức mà quy định pháp luật và checklist mua sắm doanh nghiệp thực sự tham chiếu, khả thi mà không vướng những ràng buộc đôi khi bất khả thi của AAA (ví dụ contrast 7:1 loại bỏ phần lớn bảng màu thương hiệu vốn dĩ hợp lý), và tương ứng với mức mà phần lớn rào cản accessibility thực tế thực sự được gỡ bỏ. Một hệ thống nên coi AA là ngưỡng sàn không thể thương lượng cho mọi component đã ship, đồng thời tận dụng cơ hội vượt qua nó khi có thể (ví dụ nhắm contrast cao hơn khi bảng màu thương hiệu cho phép) thay vì đuổi theo tuân thủ AAA toàn diện như một mục tiêu bao trùm.
Color contrast
Contrast là một trong số ít yêu cầu WCAG có thể kiểm tra trực tiếp, cơ học — một tỷ lệ toán học giữa relative luminance của màu foreground và background — điều này khiến nó trở thành ràng buộc accessibility đầu tiên lý tưởng để đưa vào hệ thống token thay vì để lại như một phán đoán cảm tính.
| Loại nội dung | Tỷ lệ tối thiểu (AA) | Tỷ lệ tối thiểu (AAA) |
|---|---|---|
| Text thường | 4.5:1 | 7:1 |
| Text lớn (≥24px, hoặc ≥19px bold) | 3:1 | 4.5:1 |
| UI component và graphical object (icon, viền input, focus indicator) | 3:1 | Không định nghĩa |
| Nội dung disabled/inactive | Không yêu cầu | Không yêu cầu |
| Logo và text trang trí/không quan trọng | Không yêu cầu | Không yêu cầu |
Điều này có hệ quả trực tiếp, mang tính cấu trúc với bảng color token được đề cập trong ./03-design-tokens-and-visual-foundations.md: mọi cặp semantic color thực sự sẽ render dưới dạng text-trên-background hoặc icon-trên-background trong sản phẩm (text-primary trên surface-default, text-on-brand trên brand-500, icon-error trên surface-default) cần được xác minh theo các tỷ lệ này ở cấp độ token, chứ không để mặc cho việc phát hiện khi một designer tình cờ chọn hai token xung khắc. Trong thực tế, điều này có nghĩa là:
- Các màu “thương hiệu” phổ biến (xanh dương thương hiệu tươi sáng, tông pastel) thường trượt 4.5:1 khi đặt trên nền trắng và cần một biến thể token đậm hơn dành riêng cho text, tách biệt với biến thể nhạt hơn dùng cho các bề mặt trang trí lớn.
- Một hệ thống token nên tài liệu hóa, ngay cạnh mỗi color token, nó đã được xác minh contrast với những background token nào — biến câu hỏi “text này có đạt contrast không” từ một câu hỏi từng-màn-hình thành một câu trả lời tra-cứu-token.
- Công cụ tự động (contrast checker tích hợp trong Figma plugin, Storybook addon, hoặc linting tích hợp CI) nên chạy trên chính bảng token, không chỉ trên các màn hình hoàn thiện, để một cặp lỗi bị bắt một lần, tại nguồn, thay vì mỗi lần một designer tình cờ kết hợp hai token đó.
- Các thành phần UI phi văn bản — viền input, viền checkbox, nút chỉ có icon, focus ring — cũng là một yêu cầu contrast (3:1) không kém gì body text, và là những thứ hay bị bỏ sót nhất vì designer theo phản xạ kiểm tra contrast của text nhưng quên kiểm tra viền input xám nhạt trên nền trắng.
Khả năng đọc (Readability)
Contrast là cần nhưng chưa đủ để text thực sự dễ đọc; các typography token của design system cũng nên mã hóa một vài ràng buộc readability dễ bị làm sai khi mở rộng quy mô.
| Yếu tố | Hướng dẫn | Vì sao quan trọng |
|---|---|---|
| Kích thước font tối thiểu | ~16px (1rem) cho body text trên web; không bao giờ dưới ~12–13px cho bất kỳ nội dung nào cần đọc | Dưới ngưỡng này, một phần đáng kể người dùng — không chỉ những người thị lực kém — phải zoom hoặc nheo mắt, và các trình duyệt mobile theo lịch sử tự động zoom input dưới 16px, gây layout shift khó chịu |
| Độ dài dòng | 45–75 ký tự mỗi dòng (~66 thường được coi là lý tưởng) | Dòng quá dài khiến mắt khó lần lại đúng dòng tiếp theo; dòng quá ngắn phá vỡ nhịp đọc do wrap quá nhiều |
| Line height | 1.4–1.6 lần kích thước font cho body text | Line height chật khiến các dòng va chạm nhau về mặt thị giác, đặc biệt với người dùng bị dyslexia hoặc thị lực kém; WCAG 1.4.12 (AAA) yêu cầu khả năng đặt line height thành 1.5 lần mà không mất nội dung |
| Text nhúng trong ảnh | Tránh; dùng text thật, có thể chọn được, với CSS styling thay thế | Text đóng gói trong ảnh không thể resize, đổi màu theo theme, dịch thuật, chọn/copy, hoặc được screen reader đọc đúng (nó chỉ thấy alt text nếu có được cung cấp) |
| Text căn đều hai bên (justified) | Tránh cho body copy | Tạo ra khoảng cách từ không đều (“dòng sông” khoảng trắng) khiến việc đọc khó hơn một cách đo lường được, đặc biệt với người dùng dyslexia |
Mã hóa những điều này thành một phần của thang typography token (như mô tả trong ./03-design-tokens-and-visual-foundations.md) — một token line-height-body cố định ở 1.5, một token font-size-body không bao giờ xuống dưới 16px — nghĩa là từng team sản phẩm riêng lẻ không phải tự nhớ và tự suy luận lại các ràng buộc này trên từng màn hình.
Khái niệm chính
Điều hướng bàn phím và quản lý focus
Mọi component tương tác trong một design system — button, link, form field, dropdown tùy chỉnh, tabs, modal, tooltip có nội dung tương tác — phải vận hành đầy đủ chỉ bằng bàn phím. Đây không phải là một yêu cầu ngách: nó bao phủ người dùng khiếm thị điều hướng hoàn toàn bằng screen reader và bàn phím, người dùng có khiếm khuyết vận động không thể thao tác chuột chính xác (hoặc hoàn toàn không thể), power user điều hướng nhanh hơn bằng bàn phím, và người dùng voice-control mà phần mềm của họ thường tổng hợp keyboard event ngầm bên dưới. Một component chỉ phản hồi sự kiện click và bỏ qua input bàn phím là vô hình với tất cả những người dùng đó.
Ba quy tắc nền tảng áp dụng cho mọi component:
- Mọi phần tử tương tác phải tiếp cận được bằng Tab và kích hoạt được bằng Enter hoặc Space, theo thứ tự khớp với thứ tự đọc trực quan. Điều này miễn phí với các phần tử native (
<button>,<a href>,<input>,<select>) và phải được hiện thực thủ công (quatabindexvà keydown handler) cho bất kỳ widget tương tác tự viết nào — một lý do nữa để ưu tiên phần tử native bất cứ khi nào chúng đáp ứng được yêu cầu. - Focus phải luôn hiển thị. Không bao giờ ship
outline: none(hoặc:focus { outline: 0 }) mà không cung cấp một focus style thay thế — làm vậy sẽ âm thầm phá hỏng sản phẩm đối với mọi người dùng bàn phím, vì khi đó không còn dấu hiệu trực quan cho biết input bàn phím sẽ đi đến đâu. Một design system nên định nghĩa một tokenfocus-ring(màu, độ dày, offset) một lần và áp dụng nhất quán qua:focus-visible(hiển thị ring khi focus bằng bàn phím nhưng không hiển thị khi click chuột, tránh phàn nàn phổ biến rằng focus ring trông “lộn xộn” khi click). - Không bao giờ bẫy focus ngoài ý muốn. Người dùng tab qua trang phải luôn có thể đến được mọi phần tử, và không bao giờ bị mắc kẹt ở đâu đó không có cách tab ngược ra được — ngoại lệ cố ý duy nhất là modal, sẽ bàn tiếp theo, nơi một cái bẫy tạm thời, có chủ đích là bắt buộc để đúng đắn.
Một ví dụ tối giản nhưng đầy đủ tính minh họa là custom dropdown/select, nơi hỗ trợ bàn phím phải được hiện thực thủ công vì đây là widget tùy chỉnh chứ không phải <select> native. Ví dụ dưới đây theo ARIA Authoring Practices Guide’s Collapsible Select-Only Combobox pattern: một <button> trigger toggle một listbox, phím mũi tên di chuyển một “virtual focus” thông qua aria-activedescendant trong khi focus DOM thật vẫn ở lại trên listbox, và chọn một option trả focus về trigger.
<div class="dropdown">
<label id="colorLabel">Favorite color</label>
<button
type="button"
id="colorButton"
class="dropdown-trigger"
aria-haspopup="listbox"
aria-expanded="false"
aria-labelledby="colorLabel colorButton"
>
Select a color
</button>
<ul
class="dropdown-listbox"
role="listbox"
id="colorListbox"
aria-labelledby="colorLabel"
tabindex="-1"
hidden
>
<li role="option" id="opt-red" data-value="red">Red</li>
<li role="option" id="opt-green" data-value="green">Green</li>
<li role="option" id="opt-blue" data-value="blue">Blue</li>
</ul>
</div>
class AccessibleDropdown {
constructor(root) {
this.button = root.querySelector('.dropdown-trigger');
this.listbox = root.querySelector('.dropdown-listbox');
this.options = Array.from(this.listbox.querySelectorAll('[role="option"]'));
this.activeIndex = -1;
this.button.addEventListener('click', () => this.toggle());
this.button.addEventListener('keydown', (e) => this.onButtonKeydown(e));
this.listbox.addEventListener('keydown', (e) => this.onListboxKeydown(e));
this.options.forEach((opt, i) =>
opt.addEventListener('click', () => this.selectOption(i))
);
document.addEventListener('click', (e) => {
if (!root.contains(e.target)) this.close();
});
}
open() {
this.listbox.hidden = false;
this.button.setAttribute('aria-expanded', 'true');
const startIndex = this.activeIndex >= 0 ? this.activeIndex : 0;
this.setActiveOption(startIndex);
this.listbox.focus(); // real DOM focus moves to the listbox itself
}
close() {
this.listbox.hidden = true;
this.button.setAttribute('aria-expanded', 'false');
this.listbox.removeAttribute('aria-activedescendant');
}
toggle() {
this.listbox.hidden ? this.open() : this.close();
}
// Moves the "virtual" focus indicator without moving real DOM focus off the listbox.
setActiveOption(index) {
this.options.forEach((opt) => opt.setAttribute('aria-selected', 'false'));
this.activeIndex = index;
const active = this.options[index];
active.setAttribute('aria-selected', 'true');
this.listbox.setAttribute('aria-activedescendant', active.id);
active.scrollIntoView({ block: 'nearest' });
}
selectOption(index) {
this.setActiveOption(index);
this.button.textContent = this.options[index].textContent;
this.close();
this.button.focus(); // return focus to the trigger after selection
}
onButtonKeydown(event) {
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
event.preventDefault();
this.open();
}
}
onListboxKeydown(event) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.setActiveOption(Math.min(this.activeIndex + 1, this.options.length - 1));
break;
case 'ArrowUp':
event.preventDefault();
this.setActiveOption(Math.max(this.activeIndex - 1, 0));
break;
case 'Home':
event.preventDefault();
this.setActiveOption(0);
break;
case 'End':
event.preventDefault();
this.setActiveOption(this.options.length - 1);
break;
case 'Enter':
case ' ':
event.preventDefault();
this.selectOption(this.activeIndex);
break;
case 'Escape':
event.preventDefault();
this.close();
this.button.focus();
break;
case 'Tab':
this.close(); // don't trap Tab — let it continue past the widget
break;
}
}
}
document.querySelectorAll('.dropdown').forEach((el) => new AccessibleDropdown(el));
Hãy chú ý những gì cách làm này mang lại so với một dropdown dựa trên <div> đơn giản: trigger tự công bố mình là một button với aria-haspopup="listbox" và trạng thái mở/đóng của nó; listbox công bố các option của mình với role="option" và phơi bày option nào đang active thông qua aria-activedescendant mà không bao giờ di chuyển focus DOM thật khỏi container listbox; và mọi tương tác — mở, di chuyển, chọn, đóng, tab ra khỏi — đều đạt được chỉ bằng bàn phím. Đây chính xác là loại component đáng để làm đúng một lần, trong design system, chính vì lập luận leverage từ phần Tổng quan: mọi màn hình cần một custom select đều thừa hưởng tất cả điều này miễn phí. Xem ./04-core-components-inputs-and-actions.md để biết bộ pattern component input rộng hơn mà dropdown này thuộc về.
Bẫy focus (focus trapping) trong modal
Modal dialog là ngoại lệ cố ý, được chấp nhận duy nhất cho quy tắc “không bao giờ bẫy focus”: trong khi modal đang mở, focus phải bị giới hạn bên trong nó. Nếu không, một người dùng bàn phím hoặc screen reader nhấn Tab liên tục cuối cùng sẽ tab “ra phía sau” modal vào nội dung trang lẽ ra phải bị vô hiệu hóa — link trong header trang, form field trong nội dung mà modal đang có ý định chặn — trong khi người dùng chuột nhìn thấy chỉ có modal và đúng đắn cho rằng không còn gì khác tương tác được. Sự không khớp này gây mất phương hướng, và trong trường hợp tệ nhất, cho phép người dùng submit hoặc điều hướng khỏi nội dung mà họ thậm chí không nhìn thấy, vì nó bị che khuất về mặt trực quan phía sau modal overlay.
Một hiện thực modal đúng đắn cần bốn điều: dialog phơi bày role và tính chất modal của nó cho assistive technology (role="dialog" và aria-modal="true"), nó có một accessible name (aria-labelledby trỏ tới heading của nó), focus di chuyển vào nó ngay khi nó mở, focus bị bẫy một cách cơ học bên trong nó (thông qua keydown handler xoay vòng Tab/Shift+Tab giữa phần tử focusable đầu tiên và cuối cùng) trong suốt thời gian nó mở, và focus được khôi phục về phần tử nào đã mở modal một khi nó đóng lại — bước cuối này quan trọng không kém gì việc bẫy focus ngay từ đầu, vì nếu thiếu nó, một người dùng bàn phím đóng dialog và focus của họ âm thầm reset về đầu trang, buộc họ phải điều hướng lại từ đầu.
<div class="modal-overlay" id="modalOverlay" hidden>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modalTitle"
aria-describedby="modalDesc"
id="modal"
>
<h2 id="modalTitle">Delete this item?</h2>
<p id="modalDesc">This action cannot be undone. The item will be permanently removed.</p>
<div class="modal-actions">
<button type="button" id="cancelBtn">Cancel</button>
<button type="button" id="confirmBtn" class="danger">Delete</button>
</div>
<button type="button" class="modal-close" aria-label="Close dialog" id="closeBtn">×</button>
</div>
</div>
class FocusTrapModal {
constructor(overlayEl, modalEl, triggerEl) {
this.overlay = overlayEl;
this.modal = modalEl;
this.trigger = triggerEl;
this.previouslyFocused = null;
this.handleKeydown = this.handleKeydown.bind(this);
}
get focusableElements() {
const selector =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
return Array.from(this.modal.querySelectorAll(selector)).filter(
(el) => el.offsetParent !== null // exclude elements hidden via display:none
);
}
open() {
this.previouslyFocused = document.activeElement; // remember what to return focus to
this.overlay.hidden = false;
document.addEventListener('keydown', this.handleKeydown);
const focusables = this.focusableElements;
(focusables[0] || this.modal).focus(); // move focus into the dialog immediately
}
close() {
this.overlay.hidden = true;
document.removeEventListener('keydown', this.handleKeydown);
if (this.previouslyFocused) this.previouslyFocused.focus(); // restore focus on close
}
handleKeydown(event) {
if (event.key === 'Escape') {
this.close();
return;
}
if (event.key !== 'Tab') return;
const focusables = this.focusableElements;
if (focusables.length === 0) {
event.preventDefault(); // nothing to focus — don't let Tab escape the dialog
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus(); // wrap from first to last on Shift+Tab
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus(); // wrap from last to first on Tab
}
}
}
const modal = new FocusTrapModal(
document.getElementById('modalOverlay'),
document.getElementById('modal'),
document.activeElement
);
document.getElementById('closeBtn').addEventListener('click', () => modal.close());
document.getElementById('cancelBtn').addEventListener('click', () => modal.close());
Hai lưu ý bổ sung đáng tài liệu hóa cùng với component này trong một design system. Thứ nhất, nội dung nền cũng nên được đánh dấu inert đối với assistive technology trong khi modal đang mở — hoặc dùng thuộc tính native inert áp dụng cho mọi thứ bên ngoài modal, hoặc aria-hidden="true" trên wrapper nội dung chính của trang, để người dùng screen reader vuốt qua nội dung (thay vì tab) cũng không thể tiếp cận nó. Thứ hai, phần tử <dialog> native (dialogEl.showModal()) hiện đã cung cấp phần lớn hành vi này miễn phí trên các trình duyệt hiện đại — bẫy focus, Escape để đóng, và tính inert của nền — và đáng cân nhắc làm phần tử nền tảng cho component Modal của hệ thống khi trình duyệt hỗ trợ cho phép, giảm bớt lượng logic focus-trap thủ công phải duy trì và test lại. Xem ./05-core-components-content-and-feedback.md để biết modal nằm ở đâu trong các component feedback và overlay của hệ thống.
ARIA: khi nào dùng, và khi nào không
Quy tắc quan trọng nhất trong đặc tả WAI-ARIA được chính W3C phát biểu rõ ràng: “No ARIA is better than Bad ARIA” (Không có ARIA còn tốt hơn ARIA sai). Các thuộc tính ARIA chỉ thay đổi những gì được phơi bày ra accessibility tree (cấu trúc dữ liệu mà screen reader và các assistive technology khác thực sự đọc) — chúng không thay đổi gì về hành vi bàn phím, focus, hay hình thức trực quan. Thêm role="button" vào một <div> khiến screen reader công bố nó như một button, nhưng không làm gì để nó focusable hay phản hồi Enter/Space — điều đó vẫn phải được viết tay bằng tabindex="0" và keydown handler. Một <div role="button"> không có xử lý bàn phím, trong thực tế, tệ hơn một <div> trơn không có role nào cả: nó hứa hẹn với người dùng screen reader một button hoạt động rồi không thực hiện lời hứa đó, trong khi <div> không đánh dấu ít nhất không tạo ra kỳ vọng sai.
Quy tắc quyết định thực tiễn theo sau điều này là: ưu tiên semantic HTML native trước, chỉ dùng ARIA cho những gì HTML không thể diễn đạt. Một <button> là focusable, thao tác được bằng bàn phím, và được công bố như một button mà không cần ARIA nào — dùng <div role="button" tabindex="0"> thay vào đó chỉ hợp lý trong trường hợp hiếm hoi khi một <button> thật không thể dùng vì lý do styling hoặc cấu trúc DOM, và ngay cả khi đó, toàn bộ hành vi bàn phím mà <button> cho miễn phí phải được tái hiện thủ công. Logic tương tự áp dụng xuyên suốt: ưu tiên <nav> hơn <div role="navigation">, ưu tiên <h2> hơn <div role="heading" aria-level="2">, ưu tiên <table> thật hơn ARIA grid role. ARIA tồn tại để lấp khoảng trống: các pattern như tabs, combobox, và toast notification mà HTML không có phần tử native tương ứng, và nơi role, state, property thực sự cần được khai báo thủ công.
Một vài pattern phổ biến nhất mà một design system cần làm đúng:
| Pattern | Role/thuộc tính chính | Ghi chú |
|---|---|---|
| Tabs | role="tablist" trên container, role="tab" trên mỗi trigger kèm aria-selected và aria-controls, role="tabpanel" trên mỗi panel kèm aria-labelledby trỏ ngược về tab của nó | Phím mũi tên (không phải Tab) di chuyển giữa các tab trong hầu hết hiện thực; chỉ tab đang active nằm trong Tab order (tabindex="0" trên nó, tabindex="-1" trên các tab còn lại) — pattern “roving tabindex” |
| Combobox / custom select | role="combobox" (hoặc một button trigger với aria-haspopup="listbox"), role="listbox", role="option", aria-expanded, aria-activedescendant | Xem ví dụ dropdown ở trên; các pattern combobox trong APG bao phủ nhiều biến thể editable/non-editable |
| Toast / thông báo inline | role="status" (ngầm định aria-live="polite") cho xác nhận không khẩn cấp; role="alert" (ngầm định aria-live="assertive") cho thông báo khẩn/lỗi; aria-atomic="true" để công bố toàn bộ vùng, không chỉ phần đã thay đổi | Screen reader tự động công bố nội dung khi nó thay đổi — không nên có việc di chuyển focus cho một toast, vì đây là thông báo thụ động, không phải thứ người dùng cần hành động ngay lập tức |
| Modal dialog | role="dialog", aria-modal="true", aria-labelledby/aria-describedby | Đã trình bày đầy đủ ở trên; kết hợp với focus trap thủ công hoặc native |
| Disclosure (accordion, “show more”) | aria-expanded trên trigger button, aria-controls trỏ tới vùng nó hiển thị | Nội dung thường chỉ cần hidden khi thu gọn — không cần thêm role nào trên chính panel |
Toast là một minh họa tối giản tốt cho aria-live một cách độc lập:
<div role="status" aria-live="polite" aria-atomic="true" class="toast">
Changes saved successfully.
</div>
<div role="alert" class="toast toast--error">
Could not save changes. Check your connection and try again.
</div>
Sự khác biệt giữa hai cách trên là có chủ đích: role="status" lịch sự chờ screen reader đọc xong bất cứ điều gì nó đang công bố trước khi đọc toast, phù hợp cho một xác nhận thường lệ; role="alert" ngắt ngay lập tức, chỉ phù hợp cho thông tin thực sự khẩn cấp, vì lạm dụng nó huấn luyện người dùng mất niềm tin vào hoặc tắt tiếng các ngắt quãng. Xem ./05-core-components-content-and-feedback.md để biết danh mục đầy đủ hơn các component feedback mà toast thuộc về.
Testing accessibility: tự động và thủ công
Không một phương pháp test đơn lẻ nào bắt được mọi thứ, và chiến lược testing của một design system nên kết hợp có chủ đích cả hai loại thay vì chỉ dựa vào một trong hai.
Công cụ tự động — axe DevTools, Lighthouse, axe-core tích hợp vào CI hoặc Storybook — nhanh, có thể script hóa, và bắt được một nhóm vấn đề thực sự, có ý nghĩa: thiếu alt text, contrast màu không đủ, form thiếu label, ARIA dùng sai, thiếu landmark tài liệu. Ước tính của ngành thống nhất rằng độ bao phủ tự động rơi vào khoảng 30–40% success criterion của WCAG — những criterion có thể xác minh bằng static analysis của DOM. Đây là một ngưỡng sàn thực sự hữu ích: chạy axe-core với mọi component trong CI, và fail build khi có vi phạm, ngăn chặn toàn bộ nhóm lỗi regression không bao giờ đến được production, và đủ rẻ để chạy ở mỗi commit.
Điều mà công cụ tự động về mặt cấu trúc không thể bắt được là mọi thứ đòi hỏi phán đoán của con người về khả năng sử dụng thực tế: liệu thứ tự đọc mà screen reader công bố có hợp lý về mặt logic không, liệu một alt text có chính xác không (một hình ảnh có thể có alt text tồn tại về mặt kỹ thuật nhưng vô dụng — “image123.png” vượt qua kiểm tra tự động nhưng không cho người dùng screen reader biết gì cả), liệu hành vi bàn phím của một widget tùy chỉnh có thực sự khớp với kỳ vọng người dùng không, liệu focus có thực sự đáp xuống một nơi hợp lý sau khi một hành động hoàn tất không, hay liệu một trang có sử dụng được từ đầu đến cuối bởi ai đó hoàn toàn không nhìn thấy nó không. Đây là lý do testing thủ công không phải là tùy chọn cho 60–70% còn lại:
- Điều hướng chỉ bằng bàn phím: rút chuột ra (hoặc đơn giản cam kết không chạm vào nó) và hoàn thành mọi luồng cốt lõi chỉ dùng Tab, Shift+Tab, phím mũi tên, Enter, Space, và Escape. Chỉ riêng điều này đã phát lộ một phần lớn vấn đề thực tế: focus indicator vô hình, tab order phi logic, widget tùy chỉnh không thể tiếp cận, keyboard trap.
- Testing với screen reader: test tối thiểu với VoiceOver (tích hợp sẵn trong macOS/iOS) và NVDA (miễn phí, Windows) — lý tưởng nhất là thêm cả JAWS, vì các screen reader không diễn giải ARIA giống nhau hoàn toàn, và một pattern hoạt động sạch sẽ ở cái này có thể công bố gây nhầm lẫn ở cái khác. Testing nên bao phủ cả chế độ “đọc xuyên suốt” (vuốt/mũi tên qua nội dung tuần tự) lẫn chế độ “tương tác” (tab giữa các control), vì cả hai đều là mẫu hình sử dụng thực tế.
- Testing zoom và reflow: xác minh UI vẫn dùng được ở mức zoom trình duyệt 200% và ở các thiết lập cỡ chữ mức hệ điều hành cao, không có cuộn ngang hay nội dung bị cắt xén (WCAG 1.4.10 Reflow).
- Reduced motion: xác minh component tôn trọng
prefers-reduced-motionvà không chỉ dựa vào animation để truyền tải thay đổi trạng thái.
Một chiến lược testing thực tiễn cho design system chạy kiểm tra tự động (axe-core) như một cổng CI bắt buộc trên mọi component — bắt regression một cách rẻ và liên tục — trong khi coi các lượt pass chỉ-bàn-phím và screen-reader là một bước bắt buộc, không thể bỏ qua trong review ban đầu của component và trong các audit định kỳ sau đó, chứ không phải thứ để dồn lại thành một audit accessibility hiếm hoi, tốn kém, mỗi năm một lần cho toàn bộ sản phẩm. Xem ./14-measuring-success-analytics-and-testing.md để biết độ bao phủ test accessibility phù hợp thế nào với bộ chỉ số rộng hơn mà một design system nên theo dõi theo thời gian.
Best Practices
- Sửa accessibility trong component, không phải trên trang tiêu thụ nó. Đây là lập luận leverage cốt lõi từ phần Tổng quan — coi mọi lỗi accessibility tìm thấy trong một component dùng chung như một lỗi ảnh hưởng đến mọi màn hình tiêu thụ đồng thời, và ưu tiên tương ứng.
- Mặc định dùng semantic HTML trước khi dùng đến ARIA. Một
<button>,<a>,<input>, hay<table>native cho hành vi bàn phím đúng đắn và phơi bày ra accessibility tree miễn phí; ARIA nên lấp các khoảng trống mà HTML không thể diễn đạt, không thay thế các phần tử HTML vốn đã cung cấp. - Không bao giờ gỡ bỏ focus outline mà không ship một thay thế. Một design token
focus-ring, áp dụng qua:focus-visible, nên là không thể thương lượng trên mọi component tương tác trong hệ thống. - Xác minh các cặp color token theo tỷ lệ contrast ở cấp độ token, không phải theo từng màn hình — tài liệu hóa text token nào đã được xác minh contrast với background token nào để designer không phải tự suy luận lại trên từng màn hình.
- Xây bẫy focus và khôi phục focus vào component
Modaldùng chung một lần, dùng hoặc một hiện thực thủ công đã được test kỹ hoặc phần tử<dialog>native, để không team tiêu thụ nào phải tự viết lại nó. - Coi
role="alert"như một tài nguyên khan hiếm. Dành các thông báo assertive, ngắt quãng cho thông tin thực sự khẩn cấp; lạm dụng nó huấn luyện người dùng mất niềm tin vào hoặc phớt lờ các ngắt quãng. - Chạy kiểm tra accessibility tự động (axe-core) trong CI với mọi component, và fail build khi có vi phạm — điều này rẻ, liên tục, và bắt được ~30–40% vấn đề trước khi con người nhìn vào.
- Yêu cầu một lượt pass chỉ-bàn-phím và một lượt pass screen-reader cho mọi component tương tác mới hoặc thay đổi, không chỉ cho một audit toàn sản phẩm mỗi năm một lần — đây là nơi 60–70% vấn đề thực tế còn lại phát lộ.
- Tài liệu hóa hành vi accessibility ngay cạnh tài liệu API/prop cho mỗi component (phím tắt kỳ vọng, ARIA role sử dụng, hành vi focus) để team tiêu thụ biết mình đang nhận được gì mà không cần tự suy luận lại từ source.
Tài liệu tham khảo
- Web Content Accessibility Guidelines (WCAG) 2.2 — W3C
- WAI-ARIA Authoring Practices Guide (APG)
- WAI-ARIA 1.2 Specification — W3C
- axe DevTools — Deque Systems
- Lighthouse Accessibility Audits — Chrome Developers
- Understanding WCAG Success Criteria — W3C
- Inclusive Components — Heydon Pickering
- roadmap.sh/design-system
Part of the Design System Roadmap knowledge base.
Overview
Accessibility is the practice of designing and building products that people with disabilities — visual, auditory, motor, cognitive, or situational — can actually use. In most organizations, accessibility is treated as a checklist applied at the end of a feature: ship the UI, then run an audit, then patch whatever the audit found. A design system exists precisely to break that cycle, and accessibility is the single clearest illustration of why the whole discipline is worth investing in.
Consider a Button component consumed by two hundred screens across a product. If that button is built with a <div onclick="..."> instead of a real <button>, every one of those two hundred screens ships an element that cannot be reached by keyboard, has no accessible name announced to screen readers, and does not respond to Enter or Space the way users expect. Fixing this after the fact means finding and re-testing two hundred call sites. But if the fix happens once, inside the shared Button component — swap the <div> for a <button>, verify focus and keyboard behavior, ship a new version — every one of those two hundred screens is fixed simultaneously, without any of the consuming teams even being aware a fix happened. This is leverage, the same leverage that makes design systems valuable for visual consistency, applied to accessibility. It is the strongest argument for building accessibility into the system’s components rather than auditing it onto finished screens: one correct implementation, inherited everywhere, versus the same bug reproduced and re-fixed hundreds of times.
This leverage effect changes who is responsible for what. Without a design system, every product team must independently know the accessibility rules for every component they build — an unrealistic expectation at scale. With a design system, the burden shifts: a small group of people (often with dedicated accessibility expertise) get the Button, Modal, Dropdown, and Tabs components right once, and consuming teams inherit correctness by default simply by using the system rather than rolling their own. Accessibility stops being “one more thing every engineer has to remember” and becomes “a property of the components you already had to use anyway.” That shift — from individual diligence to systemic guarantee — is why accessibility belongs in the charter of a design system from day one, not as a phase-two initiative bolted on after the component library already exists.
There is also a legal and business dimension worth naming briefly: accessibility failures are documented, litigated, and costly (in jurisdictions covered by the ADA, EN 301 549, or similar regulations), and inaccessible products exclude a meaningful fraction of the user base, including people using assistive technology and people in temporary or situational impairment (a broken arm, bright sunlight on a phone screen, a noisy environment making audio unusable). But the leverage argument above is the one that matters for design systems specifically: it is the reason “make it accessible” is best solved as a systems-design problem, not an individual-screen problem.
Fundamentals
The four POUR principles
The Web Content Accessibility Guidelines (WCAG), maintained by the W3C’s Web Accessibility Initiative, organize every accessibility requirement under four principles, memorized by the acronym POUR. Every success criterion in WCAG — from color contrast to keyboard support to page titles — falls under exactly one of these four buckets, which makes POUR a useful mental checklist when evaluating whether a new component is accessible.
| Principle | Meaning | Design system implication |
|---|---|---|
| Perceivable | Information and UI components must be presentable to users in ways they can perceive — not reliant on a single sense alone | Color tokens must meet contrast minimums; icons need text alternatives; video needs captions; content must not be conveyed by color alone |
| Operable | UI components and navigation must be operable — usable without a mouse, without a fixed time limit, without triggering seizures | Every interactive component must be keyboard-operable; focus must be visible; no keyboard traps; enough time to complete flows |
| Understandable | Information and the operation of the UI must be understandable | Consistent navigation and component behavior across the system; clear error messages; predictable component states |
| Robust | Content must be robust enough to be interpreted reliably by a wide variety of user agents, including assistive technologies | Valid, semantic HTML; correct ARIA that exposes name, role, and value to the accessibility tree; components tested with real screen readers |
POUR is deliberately implementation-agnostic — it describes outcomes, not techniques — which is exactly why it is a good fit for a design system’s principles layer: it gives engineers and designers a shared vocabulary for why a component must behave a certain way, independent of which specific WCAG success criterion or ARIA attribute is involved.
Conformance levels: A, AA, AAA
WCAG success criteria are grouped into three conformance levels of increasing strictness. Understanding the difference matters because “we’re WCAG compliant” is a meaningless claim without specifying which level, and because a design system has to pick a target level and hold every component to it consistently.
| Level | What it represents | Typical requirement examples | Practicality |
|---|---|---|---|
| A (minimum) | Baseline accessibility; without it, some users cannot access content at all | Non-text content has a text alternative; keyboard access to all functionality; no seizure-inducing flashing content | Necessary but far from sufficient — passing Level A alone still leaves a product very hard to use for many people |
| AA (mid, the common target) | The level most laws, procurement standards, and organizations require or recommend | 4.5:1 text contrast (3:1 for large text); visible focus indicators; resizable text up to 200% without loss of function; consistent navigation across pages | The pragmatic default — achievable for most products, legally referenced in most jurisdictions (ADA case law, EN 301 549, Section 508), and covers the vast majority of real user impact |
| AAA (strictest) | The highest, most comprehensive level, with some criteria the W3C itself notes are not achievable for all content types | 7:1 text contrast (4.5:1 for large text); sign language interpretation for prerecorded audio; no timing at all for any interaction | Rarely targeted wholesale; typically cherry-picked for specific high-stakes content (e.g., government emergency information) rather than applied product-wide |
AA is the practical target for the overwhelming majority of design systems for three converging reasons: it is what regulations and enterprise procurement checklists actually reference, it is achievable without the sometimes-impossible constraints of AAA (7:1 contrast, for instance, rules out a huge portion of otherwise reasonable brand color palettes), and it corresponds to the level at which the majority of real-world accessibility barriers are actually removed. A system should treat AA as the non-negotiable floor for every shipped component, while opportunistically exceeding it (e.g., aiming for higher contrast where the brand palette allows) rather than chasing full AAA compliance as a blanket goal.
Color contrast
Contrast is one of the few WCAG requirements that is directly, mechanically checkable — a mathematical ratio between the relative luminance of a foreground and background color — which makes it an ideal first accessibility constraint to bake into a token system rather than an experiential judgment call.
| Content type | Minimum ratio (AA) | Minimum ratio (AAA) |
|---|---|---|
| Normal text | 4.5:1 | 7:1 |
| Large text (≥24px, or ≥19px bold) | 3:1 | 4.5:1 |
| UI components and graphical objects (icons, input borders, focus indicators) | 3:1 | Not defined |
| Disabled/inactive content | No requirement | No requirement |
| Logos and incidental/decorative text | No requirement | No requirement |
This has a direct, structural consequence for the color token palette covered in ./03-design-tokens-and-visual-foundations.md: every semantic color pairing that will actually render as text-on-background or icon-on-background in the product (text-primary on surface-default, text-on-brand on brand-500, icon-error on surface-default) needs to be verified against these ratios at the token level, not left to be discovered when a designer happens to pick two tokens that clash. In practice this means:
- Popular “brand” colors (bright brand blues, pastel accents) frequently fail 4.5:1 against white and need a darker token variant reserved specifically for text use, distinct from the lighter variant used for large decorative surfaces.
- A token system should document, next to each color token, which background tokens it is contrast-verified against — turning “does this text pass contrast” from a per-screen question into a token-lookup answer.
- Automated tools (contrast checkers built into Figma plugins, Storybook addons, or CI-integrated linting) should run against the token palette itself, not just against finished screens, so a failing pairing is caught once, at the source, rather than every time a designer happens to combine those two tokens.
- Non-text UI components — input borders, checkbox outlines, icon-only buttons, focus rings — are just as much a contrast requirement (3:1) as body text, and are the ones most commonly missed because designers instinctively check text contrast but forget to check a light-gray input border against a white background.
Readability
Contrast is necessary but not sufficient for text to be genuinely readable; a design system’s typography tokens should also encode a handful of readability constraints that are easy to get wrong at scale.
| Factor | Guidance | Why it matters |
|---|---|---|
| Minimum font size | ~16px (1rem) for body text on the web; never below ~12–13px for any readable content | Below this, a meaningful fraction of users — not only those with low vision — must zoom or squint, and mobile browsers historically auto-zoom inputs under 16px, causing jarring layout shifts |
| Line length | 45–75 characters per line (~66 is often cited as ideal) | Lines that are too long make it hard for the eye to track back to the correct next line; lines that are too short break reading rhythm with excessive wrapping |
| Line height | 1.4–1.6× the font size for body text | Tight line height causes lines to visually collide, especially for users with dyslexia or low vision; WCAG 1.4.12 (AAA) requires the ability to set line height to 1.5× without loss of content |
| Text in images | Avoid; use real, selectable text with CSS styling instead | Text baked into an image cannot be resized, re-colored for a theme, translated, selected/copied, or read aloud correctly by a screen reader (which sees only whatever alt text was provided, if any) |
| Justified/fully-justified text | Avoid for body copy | Creates uneven word spacing (“rivers” of whitespace) that is measurably harder to read, especially for users with dyslexia |
Encoding these as part of the typography token scale (as described in ./03-design-tokens-and-visual-foundations.md) — a line-height-body token fixed at 1.5, a font-size-body token that never dips below 16px — means individual product teams do not have to independently remember and re-derive these constraints on every screen.
Key Concepts
Keyboard navigation and focus management
Every interactive component in a design system — buttons, links, form fields, custom dropdowns, tabs, modals, tooltips with interactive content — must be fully operable using only a keyboard. This is not a niche requirement: it covers users who are blind and navigate exclusively via screen reader and keyboard, users with motor impairments who cannot operate a mouse precisely (or at all), power users who navigate faster by keyboard, and voice-control users whose software often synthesizes keyboard events under the hood. A component that only responds to click events and ignores keyboard input is invisible to all of them.
Three baseline rules apply to every component:
- Every interactive element must be reachable via Tab and activatable via Enter or Space, in an order that matches the visual reading order. This is free with native elements (
<button>,<a href>,<input>,<select>) and must be manually implemented (viatabindexand keydown handlers) for any custom-built interactive widget — one more reason to prefer native elements whenever they can do the job. - Focus must always be visible. Never ship
outline: none(or:focus { outline: 0 }) without providing a replacement focus style — doing so silently breaks the product for every keyboard user, because there is then no visual indication of where keyboard input will go. A design system should define afocus-ringtoken (color, width, offset) once and apply it consistently via:focus-visible(which shows the ring for keyboard focus but not for a mouse click, avoiding the common complaint that focus rings look “messy” on click). - Never trap focus unintentionally. A user tabbing through the page must always be able to reach every element, and must never land somewhere with no way to tab back out — the one deliberate exception being modals, discussed next, where a temporary, intentional trap is required for correctness.
A minimal but instructive example is a custom dropdown/select, where keyboard support has to be implemented by hand because it is a custom widget rather than a native <select>. The example below follows the ARIA Authoring Practices Guide’s Collapsible Select-Only Combobox pattern: a trigger <button> toggles a listbox, arrow keys move a “virtual focus” via aria-activedescendant while real DOM focus stays on the listbox, and selecting an option returns focus to the trigger.
<div class="dropdown">
<label id="colorLabel">Favorite color</label>
<button
type="button"
id="colorButton"
class="dropdown-trigger"
aria-haspopup="listbox"
aria-expanded="false"
aria-labelledby="colorLabel colorButton"
>
Select a color
</button>
<ul
class="dropdown-listbox"
role="listbox"
id="colorListbox"
aria-labelledby="colorLabel"
tabindex="-1"
hidden
>
<li role="option" id="opt-red" data-value="red">Red</li>
<li role="option" id="opt-green" data-value="green">Green</li>
<li role="option" id="opt-blue" data-value="blue">Blue</li>
</ul>
</div>
class AccessibleDropdown {
constructor(root) {
this.button = root.querySelector('.dropdown-trigger');
this.listbox = root.querySelector('.dropdown-listbox');
this.options = Array.from(this.listbox.querySelectorAll('[role="option"]'));
this.activeIndex = -1;
this.button.addEventListener('click', () => this.toggle());
this.button.addEventListener('keydown', (e) => this.onButtonKeydown(e));
this.listbox.addEventListener('keydown', (e) => this.onListboxKeydown(e));
this.options.forEach((opt, i) =>
opt.addEventListener('click', () => this.selectOption(i))
);
document.addEventListener('click', (e) => {
if (!root.contains(e.target)) this.close();
});
}
open() {
this.listbox.hidden = false;
this.button.setAttribute('aria-expanded', 'true');
const startIndex = this.activeIndex >= 0 ? this.activeIndex : 0;
this.setActiveOption(startIndex);
this.listbox.focus(); // real DOM focus moves to the listbox itself
}
close() {
this.listbox.hidden = true;
this.button.setAttribute('aria-expanded', 'false');
this.listbox.removeAttribute('aria-activedescendant');
}
toggle() {
this.listbox.hidden ? this.open() : this.close();
}
// Moves the "virtual" focus indicator without moving real DOM focus off the listbox.
setActiveOption(index) {
this.options.forEach((opt) => opt.setAttribute('aria-selected', 'false'));
this.activeIndex = index;
const active = this.options[index];
active.setAttribute('aria-selected', 'true');
this.listbox.setAttribute('aria-activedescendant', active.id);
active.scrollIntoView({ block: 'nearest' });
}
selectOption(index) {
this.setActiveOption(index);
this.button.textContent = this.options[index].textContent;
this.close();
this.button.focus(); // return focus to the trigger after selection
}
onButtonKeydown(event) {
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
event.preventDefault();
this.open();
}
}
onListboxKeydown(event) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.setActiveOption(Math.min(this.activeIndex + 1, this.options.length - 1));
break;
case 'ArrowUp':
event.preventDefault();
this.setActiveOption(Math.max(this.activeIndex - 1, 0));
break;
case 'Home':
event.preventDefault();
this.setActiveOption(0);
break;
case 'End':
event.preventDefault();
this.setActiveOption(this.options.length - 1);
break;
case 'Enter':
case ' ':
event.preventDefault();
this.selectOption(this.activeIndex);
break;
case 'Escape':
event.preventDefault();
this.close();
this.button.focus();
break;
case 'Tab':
this.close(); // don't trap Tab — let it continue past the widget
break;
}
}
}
document.querySelectorAll('.dropdown').forEach((el) => new AccessibleDropdown(el));
Notice what this buys over a naive <div>-based dropdown: the trigger announces itself as a button with aria-haspopup="listbox" and its expanded/collapsed state; the listbox announces its options with role="option" and exposes which one is active via aria-activedescendant without ever moving real focus away from the listbox container; and every interaction — open, navigate, select, dismiss, tab away — is achievable with only a keyboard. This is exactly the kind of component worth getting right once, in the design system, precisely because of the leverage argument from the Overview: every screen that needs a custom select inherits all of this for free. See ./04-core-components-inputs-and-actions.md for the broader set of input component patterns this dropdown belongs to.
Focus trapping in modals
A modal dialog is the one deliberate, sanctioned exception to “never trap focus”: while the modal is open, focus must be confined inside it. Without this, a keyboard or screen reader user pressing Tab repeatedly will eventually tab “behind” the modal into page content that is supposed to be inert — links in the page header, form fields in the content the modal is meant to be blocking — while sighted mouse users see only the modal and correctly assume nothing else is interactive. That mismatch is disorienting and, in the worst case, lets a user submit or navigate away from content they cannot even see, since it is visually obscured behind the modal overlay.
A correct modal implementation needs four things: the dialog exposes its role and modal-ness to assistive technology (role="dialog" and aria-modal="true"), it has an accessible name (aria-labelledby pointing at its heading), focus moves into it the moment it opens, focus is mechanically trapped inside it (via a keydown handler cycling Tab/Shift+Tab between its first and last focusable elements) for as long as it is open, and focus is restored to whatever element opened the modal once it closes — this last step matters just as much as trapping focus in the first place, because without it a keyboard user closes the dialog and their focus silently resets to the top of the page, forcing them to re-navigate from scratch.
<div class="modal-overlay" id="modalOverlay" hidden>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modalTitle"
aria-describedby="modalDesc"
id="modal"
>
<h2 id="modalTitle">Delete this item?</h2>
<p id="modalDesc">This action cannot be undone. The item will be permanently removed.</p>
<div class="modal-actions">
<button type="button" id="cancelBtn">Cancel</button>
<button type="button" id="confirmBtn" class="danger">Delete</button>
</div>
<button type="button" class="modal-close" aria-label="Close dialog" id="closeBtn">×</button>
</div>
</div>
class FocusTrapModal {
constructor(overlayEl, modalEl, triggerEl) {
this.overlay = overlayEl;
this.modal = modalEl;
this.trigger = triggerEl;
this.previouslyFocused = null;
this.handleKeydown = this.handleKeydown.bind(this);
}
get focusableElements() {
const selector =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
return Array.from(this.modal.querySelectorAll(selector)).filter(
(el) => el.offsetParent !== null // exclude elements hidden via display:none
);
}
open() {
this.previouslyFocused = document.activeElement; // remember what to return focus to
this.overlay.hidden = false;
document.addEventListener('keydown', this.handleKeydown);
const focusables = this.focusableElements;
(focusables[0] || this.modal).focus(); // move focus into the dialog immediately
}
close() {
this.overlay.hidden = true;
document.removeEventListener('keydown', this.handleKeydown);
if (this.previouslyFocused) this.previouslyFocused.focus(); // restore focus on close
}
handleKeydown(event) {
if (event.key === 'Escape') {
this.close();
return;
}
if (event.key !== 'Tab') return;
const focusables = this.focusableElements;
if (focusables.length === 0) {
event.preventDefault(); // nothing to focus — don't let Tab escape the dialog
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus(); // wrap from first to last on Shift+Tab
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus(); // wrap from last to first on Tab
}
}
}
const modal = new FocusTrapModal(
document.getElementById('modalOverlay'),
document.getElementById('modal'),
document.activeElement
);
document.getElementById('closeBtn').addEventListener('click', () => modal.close());
document.getElementById('cancelBtn').addEventListener('click', () => modal.close());
Two additional notes worth documenting alongside this component in a design system. First, background content should also be marked inert to assistive technology while the modal is open — either the native inert attribute applied to everything outside the modal, or aria-hidden="true" on the page’s main content wrapper, so a screen reader user swiping through content (rather than tabbing) also cannot reach it. Second, the native <dialog> element (dialogEl.showModal()) now provides much of this behavior for free in modern browsers — focus trapping, Escape to close, and background inertness — and is worth adopting as the underlying element for a system’s Modal component where browser support allows, reducing the amount of manual focus-trap logic that has to be maintained and re-tested. See ./05-core-components-content-and-feedback.md for where modals sit among the system’s feedback and overlay components.
ARIA: when to use it, and when not to
The single most important rule in the WAI-ARIA specification is stated explicitly by the W3C itself: “No ARIA is better than Bad ARIA.” ARIA attributes only change what is exposed to the accessibility tree (the data structure screen readers and other assistive technology actually read) — they change nothing about keyboard behavior, focus, or visual appearance. Adding role="button" to a <div> makes a screen reader announce it as a button, but does nothing to make it focusable or respond to Enter/Space — that still has to be coded by hand with tabindex="0" and keydown handlers. A <div role="button"> with no keyboard handling is, in practice, worse than a plain <div> with no role at all: it promises screen reader users a functioning button and then fails to deliver one, whereas the unmarked <div> at least does not create a false expectation.
The practical decision rule that follows from this is: use native semantic HTML first, and reach for ARIA only for what HTML cannot express. A <button> is focusable, keyboard-operable, and announced as a button with zero ARIA required — using <div role="button" tabindex="0"> instead only makes sense in the rare case where a real <button> cannot be used for styling or DOM-structure reasons, and even then, all of the keyboard behavior a <button> gives for free must be manually reimplemented. The same logic applies across the board: prefer <nav> over <div role="navigation">, prefer <h2> over <div role="heading" aria-level="2">, prefer a real <table> over ARIA grid roles. ARIA exists for the gap: patterns like tabs, comboboxes, and toast notifications that HTML has no native element for, and where roles, states, and properties genuinely need to be declared by hand.
A few of the most common patterns a design system will need to get right:
| Pattern | Key roles/attributes | Notes |
|---|---|---|
| Tabs | role="tablist" on the container, role="tab" on each trigger with aria-selected and aria-controls, role="tabpanel" on each panel with aria-labelledby pointing back at its tab | Arrow keys (not Tab) move between tabs in most implementations; only the active tab is in the Tab order (tabindex="0" on it, tabindex="-1" on the rest) — the “roving tabindex” pattern |
| Combobox / custom select | role="combobox" (or a trigger button with aria-haspopup="listbox"), role="listbox", role="option", aria-expanded, aria-activedescendant | See the dropdown example above; the APG combobox patterns cover several editable/non-editable variants |
| Toast / inline notification | role="status" (implicit aria-live="polite") for non-urgent confirmations; role="alert" (implicit aria-live="assertive") for urgent/error messages; aria-atomic="true" to announce the whole region, not just the changed part | A screen reader announces the content automatically when it changes — no focus movement should happen for a toast, since it is a passive notification, not something the user needs to act on immediately |
| Modal dialog | role="dialog", aria-modal="true", aria-labelledby/aria-describedby | Covered in full above; pair with a manual or native focus trap |
| Disclosure (accordion, “show more”) | aria-expanded on the trigger button, aria-controls pointing at the region it reveals | Content can usually just be hidden when collapsed — no additional role needed on the panel itself |
A toast is a good minimal illustration of aria-live in isolation:
<div role="status" aria-live="polite" aria-atomic="true" class="toast">
Changes saved successfully.
</div>
<div role="alert" class="toast toast--error">
Could not save changes. Check your connection and try again.
</div>
The difference between the two is deliberate: role="status" politely waits for the screen reader to finish whatever it is currently announcing before reading the toast, appropriate for a routine confirmation; role="alert" interrupts immediately, appropriate only for genuinely urgent information, since overusing it trains users to distrust or mute interruptions. See ./05-core-components-content-and-feedback.md for the fuller catalog of feedback components this belongs to.
Accessibility testing: automated and manual
No single testing method catches everything, and a design system’s testing strategy should combine both categories deliberately rather than relying on either alone.
Automated tools — axe DevTools, Lighthouse, axe-core integrated into CI or Storybook — are fast, scriptable, and catch a real, meaningful class of issues: missing alt text, insufficient color contrast, missing form labels, invalid ARIA usage, missing document landmarks. Industry estimates consistently put automated coverage at roughly 30–40% of WCAG success criteria — the criteria that can be verified by static analysis of the DOM. This is a genuinely useful floor: running axe-core against every component in CI, and failing the build on violations, prevents entire categories of regressions from ever reaching production, and it is cheap enough to run on every commit.
What automated tools structurally cannot catch is everything that requires human judgment about actual usability: whether the reading order announced by a screen reader makes logical sense, whether an alt text is accurate (an image can have technically-present alt text that is nonetheless useless — “image123.png” passes automated checks but tells a screen reader user nothing), whether a custom widget’s keyboard behavior actually matches user expectations, whether focus genuinely lands somewhere sensible after an action completes, or whether a page is usable end to end by someone who cannot see it at all. This is why manual testing is not optional for the remaining 60–70%:
- Keyboard-only navigation: unplug the mouse (or simply commit to not touching it) and complete every core flow using only Tab, Shift+Tab, arrow keys, Enter, Space, and Escape. This alone surfaces an enormous share of real issues: invisible focus indicators, illogical tab order, unreachable custom widgets, keyboard traps.
- Screen reader testing: test with VoiceOver (built into macOS/iOS) and NVDA (free, Windows) at minimum — ideally also JAWS, since screen readers do not all interpret ARIA identically, and a pattern that works cleanly in one can announce confusingly in another. Testing should cover both “read through” (swiping/arrowing through content linearly) and “interact” modes (tabbing between controls), since both are real usage patterns.
- Zoom and reflow testing: verify the UI remains usable at 200% browser zoom and at high OS-level text-size settings, without horizontal scrolling or clipped content (WCAG 1.4.10 Reflow).
- Reduced motion: verify components respect
prefers-reduced-motionand do not rely on animation alone to convey state changes.
A practical design system testing strategy runs automated checks (axe-core) as a required CI gate on every component — catching regressions cheaply and continuously — while treating keyboard-only and screen-reader passes as a required, non-skippable step in the component’s initial review and in periodic audits thereafter, not something deferred to a rare, expensive, once-a-year accessibility audit of the whole product. See ./14-measuring-success-analytics-and-testing.md for how accessibility test coverage fits into the broader set of metrics a design system should track over time.
Best Practices
- Fix accessibility in the component, not on the page that consumes it. This is the core leverage argument from the Overview — treat every accessibility bug found in a shared component as a bug affecting every consuming screen simultaneously, and prioritize accordingly.
- Default to semantic HTML before reaching for ARIA. A native
<button>,<a>,<input>, or<table>gives correct keyboard behavior and accessibility-tree exposure for free; ARIA should fill gaps HTML cannot express, not replace elements HTML already provides. - Never remove a focus outline without shipping a replacement. A
focus-ringdesign token, applied via:focus-visible, should be non-negotiable across every interactive component in the system. - Verify color-token pairings against contrast ratios at the token level, not per-screen — document which text tokens are contrast-verified against which background tokens so designers don’t have to re-derive this on every screen.
- Build focus trapping and focus restoration into the shared
Modalcomponent once, using either a well-tested manual implementation or the native<dialog>element, so no consuming team has to reimplement it. - Treat
role="alert"as a scarce resource. Reserve assertive, interrupting announcements for genuinely urgent information; overuse trains users to distrust or tune out interruptions. - Run automated accessibility checks (axe-core) in CI against every component, and fail the build on violations — this is cheap, continuous, and catches ~30–40% of issues before a human ever looks.
- Require a keyboard-only pass and a screen-reader pass for every new or changed interactive component, not just for a once-a-year full-product audit — this is where the remaining 60–70% of real issues surface.
- Document accessibility behavior next to API/prop documentation for every component (expected keyboard shortcuts, ARIA roles used, focus behavior) so consuming teams know what they’re getting and don’t need to re-derive it from source.
References
- Web Content Accessibility Guidelines (WCAG) 2.2 — W3C
- WAI-ARIA Authoring Practices Guide (APG)
- WAI-ARIA 1.2 Specification — W3C
- axe DevTools — Deque Systems
- Lighthouse Accessibility Audits — Chrome Developers
- Understanding WCAG Success Criteria — W3C
- Inclusive Components — Heydon Pickering
- roadmap.sh/design-system