← Design System← Design System
Design SystemDesign System19 Th7, 2026Jul 19, 202631 phút đọc25 min read

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 quanColor 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 technologyHTML 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ĩaVí dụ yêu cầu điển hìnhTí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 dungNộ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 seizureCầ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 trangMặ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 dungContrast 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àoHiế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 dungTỷ lệ tối thiểu (AA)Tỷ lệ tối thiểu (AAA)
Text thường4.5:17:1
Text lớn (≥24px, hoặc ≥19px bold)3:14.5:1
UI component và graphical object (icon, viền input, focus indicator)3:1Không định nghĩa
Nội dung disabled/inactiveKhông yêu cầuKhông yêu cầu
Logo và text trang trí/không quan trọngKhông yêu cầuKhô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à:

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ẫnVì 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 đọcDướ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òng45–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 height1.4–1.6 lần kích thước font cho body textLine 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 ảnhTrá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 copyTạ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:

  1. 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 (qua tabindex và 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.
  2. 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 token focus-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).
  3. 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"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">&times;</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:

PatternRole/thuộc tính chínhGhi chú
Tabsrole="tablist" trên container, role="tab" trên mỗi trigger kèm aria-selectedaria-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 selectrole="combobox" (hoặc một button trigger với aria-haspopup="listbox"), role="listbox", role="option", aria-expanded, aria-activedescendantXem 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 inlinerole="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 đổiScreen 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 dialogrole="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ự độngaxe 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:

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

Tài liệu tham khảo

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.

PrincipleMeaningDesign system implication
PerceivableInformation and UI components must be presentable to users in ways they can perceive — not reliant on a single sense aloneColor tokens must meet contrast minimums; icons need text alternatives; video needs captions; content must not be conveyed by color alone
OperableUI components and navigation must be operable — usable without a mouse, without a fixed time limit, without triggering seizuresEvery interactive component must be keyboard-operable; focus must be visible; no keyboard traps; enough time to complete flows
UnderstandableInformation and the operation of the UI must be understandableConsistent navigation and component behavior across the system; clear error messages; predictable component states
RobustContent must be robust enough to be interpreted reliably by a wide variety of user agents, including assistive technologiesValid, 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.

LevelWhat it representsTypical requirement examplesPracticality
A (minimum)Baseline accessibility; without it, some users cannot access content at allNon-text content has a text alternative; keyboard access to all functionality; no seizure-inducing flashing contentNecessary 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 recommend4.5:1 text contrast (3:1 for large text); visible focus indicators; resizable text up to 200% without loss of function; consistent navigation across pagesThe 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 types7:1 text contrast (4.5:1 for large text); sign language interpretation for prerecorded audio; no timing at all for any interactionRarely 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 typeMinimum ratio (AA)Minimum ratio (AAA)
Normal text4.5:17:1
Large text (≥24px, or ≥19px bold)3:14.5:1
UI components and graphical objects (icons, input borders, focus indicators)3:1Not defined
Disabled/inactive contentNo requirementNo requirement
Logos and incidental/decorative textNo requirementNo 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:

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.

FactorGuidanceWhy it matters
Minimum font size~16px (1rem) for body text on the web; never below ~12–13px for any readable contentBelow 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 length45–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 height1.4–1.6× the font size for body textTight 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 imagesAvoid; use real, selectable text with CSS styling insteadText 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 textAvoid for body copyCreates 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:

  1. 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 (via tabindex and keydown handlers) for any custom-built interactive widget — one more reason to prefer native elements whenever they can do the job.
  2. 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 a focus-ring token (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).
  3. 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">&times;</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:

PatternKey roles/attributesNotes
Tabsrole="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 tabArrow 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 selectrole="combobox" (or a trigger button with aria-haspopup="listbox"), role="listbox", role="option", aria-expanded, aria-activedescendantSee the dropdown example above; the APG combobox patterns cover several editable/non-editable variants
Toast / inline notificationrole="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 partA 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 dialogrole="dialog", aria-modal="true", aria-labelledby/aria-describedbyCovered 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 revealsContent 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 toolsaxe 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%:

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

References