Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Bài 25 — Accordion A11y

Đã đọc 0/15 mục · còn khoảng 12 phút
Cách chúng tôi ghi nhận

Một mục được tính là đã đọc khi nó nằm giữa màn hình đủ lâu so với độ dài của nó (bảng và sơ đồ tính một nửa), bạn vẫn đang cuộn hoặc bấm phím, và bạn đã thấy tới cuối mục. Lướt nhanh hoặc để tab mở không được tính. Chúng tôi chỉ lưu thời gian đọc từng mục — không lưu gì khác.

Mở đầu — vì sao bài này quan trọng

Accordion (đàn xếp) là một trong những component xuất hiện nhiều nhất trên web mà ít ai để ý. Bạn thấy nó ở phần FAQ (câu hỏi thường gặp) của trang bán hàng, ở bảng thông tin sản phẩm được gom lại thành từng nhóm có thể mở/đóng, ở phần "Điều khoản dịch vụ" dài lê thê được chia thành nhiều mục thu gọn, hay ở sidebar điều hướng nhiều cấp. Ý tưởng rất đơn giản: giấu bớt nội dung để trang gọn gàng, người dùng bấm vào tiêu đề thì phần nội dung mới bung ra.

Đơn giản là vậy, nhưng accordion lại là "cái bẫy" accessibility kinh điển. Vì sao? Vì nó có trạng thái đóng/mở (expanded/collapsed), và trạng thái đó phải được truyền tải tới người dùng không nhìn thấy màn hình. Một người dùng screen reader cần biết ba điều: đây là một nút bấm để mở rộng nội dung, hiện nó đang đóng hay đang mở, và khi bấm thì nội dung nào sẽ hiện ra. Nếu bạn chỉ dùng một cái <div> với onclick để làm accordion — điều rất phổ biến ở các dự án Việt Nam mình từng review — thì tất cả thông tin đó biến mất. Người dùng bàn phím không tab tới được, người dùng screen reader nghe thấy "văn bản trống, không rõ làm gì".

Bài này tập trung riêng vào accordion. Chúng ta sẽ đi từ giải pháp đơn giản nhất mà web đã tặng sẵn cho bạn — cặp thẻ <details>/<summary> — cho tới cách tự xây accordion bằng ARIA khi thiết kế yêu cầu tùy biến sâu. Đây là kiến thức bạn dùng được ngay hôm nay trên gần như mọi dự án.

Khái niệm cốt lõi

Accordion là gì, và "một" hay "nhiều" mở cùng lúc

Về mặt tương tác, accordion có hai biến thể chính. Loại thứ nhất cho phép nhiều panel mở cùng lúc (giống FAQ — bạn có thể mở nhiều câu hỏi một lúc). Loại thứ hai chỉ cho phép một panel mở tại một thời điểm, mở panel mới thì panel cũ tự đóng (thường gọi là "single-select accordion"). Quyết định này ảnh hưởng tới cách bạn viết ARIA và cách screen reader thông báo, nên hãy chốt nó ngay từ đầu.

Cách đơn giản nhất: <details> và <summary>

Trước khi nghĩ tới JavaScript và ARIA, hãy nhớ rằng trình duyệt đã có sẵn một accordion "miễn phí accessibility":

<details>
  <summary>Câu hỏi 1: Làm sao để đổi trả hàng?</summary>
  <p>Bạn liên hệ hotline trong vòng 7 ngày kể từ khi nhận hàng...</p>
</details>

<details>
  <summary>Câu hỏi 2: Phí vận chuyển tính thế nào?</summary>
  <p>Nội thành miễn phí cho đơn từ 300.000đ...</p>
</details>

Điều tuyệt vời của <details>/<summary> là bạn không cần viết một dòng JavaScript nào. Trình duyệt tự lo hết:

  • <summary> tự động nhận được focus khi tab tới, và có thể mở/đóng bằng phím Enter hoặc Space.
  • Screen reader thông báo đúng vai trò và trạng thái. NVDA/VoiceOver đọc kiểu "nút thu gọn/mở rộng, đang thu gọn".
  • Trạng thái mở được phản ánh qua thuộc tính open trên thẻ <details>, nên bạn style bằng CSS details[open] cực dễ.
Bạn muốn một panel mở sẵn khi tải trang? Chỉ cần thêm open:

<details open>
  <summary>Mục này mở sẵn</summary>
  <p>Nội dung...</p>
</details>

Quy tắc mentor mình luôn nhắc: nếu <details>/<summary> đáp ứng được yêu cầu, hãy dùng nó. Bạn tiết kiệm hàng trăm dòng code và tránh được cả một danh sách bug accessibility. Chỉ chuyển sang giải pháp ARIA khi bạn thực sự cần thứ mà <details> không làm được.

Khi nào <details> chưa đủ

<details> có vài giới hạn khiến team thiết kế đôi khi phải tự xây:

  • Animation mở/đóng mượt (trượt xuống có easing) khó làm với <details> thuần vì trình duyệt bung nội dung tức thì. Có thể làm được với CSS mới như interpolate-size, nhưng hỗ trợ trình duyệt còn chưa đồng đều.
  • Chế độ "single-select" (mở cái này đóng cái kia) không có sẵn, phải thêm JavaScript.
  • Tùy biến hoàn toàn cấu trúc HTML bên trong summary (ví dụ nhiều tầng markup phức tạp) đôi khi vướng.
Khi rơi vào các trường hợp này, ta xây accordion bằng ARIA theo đúng chuẩn WAI-ARIA Authoring Practices.

Cấu trúc accordion bằng ARIA

Một accordion ARIA đúng chuẩn gồm các phần sau. Điểm mấu chốt cần nhớ:

  • Mỗi tiêu đề accordion là một thẻ heading (<h2>, <h3>... tùy cấp phù hợp trong cấu trúc trang) bọc quanh một <button>. Dùng <button> thật, không phải <div>.
  • Button mang aria-expanded="true|false" để báo trạng thái đóng/mở. Đây là thuộc tính quan trọng nhất của cả component.
  • Button mang aria-controls="id-cua-panel" trỏ tới panel mà nó điều khiển.
  • Panel là một <div> với id tương ứng, và (theo khuyến nghị) role="region" cùng aria-labelledby trỏ ngược về button, để screen reader biết panel này "thuộc về" tiêu đề nào.
<h3>
  <button aria-expanded="false" aria-controls="panel-1" id="tieude-1">
    Chính sách bảo hành
  </button>
</h3>
<div id="panel-1" role="region" aria-labelledby="tieude-1" hidden>
  <p>Sản phẩm được bảo hành 12 tháng...</p>
</div>

Chú ý thuộc tính hidden trên panel khi đóng. Đây không chỉ là chuyện ẩn bằng CSS. hidden (hoặc display: none) loại panel khỏi cây accessibility, nghĩa là screen reader không đọc và bàn phím không tab lạc vào nội dung đang bị ẩn. Nếu bạn chỉ ẩn bằng opacity: 0 hay height: 0 mà vẫn để nội dung trong DOM "sống", người dùng bàn phím sẽ tab vào một khoảng vô hình — một bug rất khó chịu và rất hay gặp.

Bàn phím phải hoạt động thế nào

Vì ta dùng <button> thật nên Enter và Space để mở/đóng đã có sẵn — đây là lý do lớn để không dùng <div>. Ngoài ra, chuẩn WAI-ARIA còn khuyến nghị (không bắt buộc) các phím điều hướng giữa các tiêu đề: Mũi tên xuống/Mũi tên lên để nhảy giữa các button tiêu đề, Home/End để nhảy tới tiêu đề đầu/cuối. Với accordion FAQ đơn giản, chỉ cần Tab + Enter/Space là đã hợp lệ WCAG rồi; các phím mũi tên là phần nâng cao cho trải nghiệm.

Tình huống thực tế

Ví dụ 1 — Trang FAQ của một sàn TMĐT Việt Nam làm sai bằng <div>

Một team làm trang FAQ cho một sàn thương mại điện tử nội địa (giả định tên "ChợViệt"). Họ dựng khoảng 20 câu hỏi thường gặp bằng cấu trúc kiểu:

<div class="faq-item" onclick="toggle(this)">Làm sao huỷ đơn?</div>
<div class="faq-answer" style="display:none">...</div>

Nhìn bằng mắt thì mọi thứ hoàn hảo: bấm chuột là mở, có mũi tên xoay đẹp. Nhưng khi họ chạy audit trước khi ký hợp đồng với một khách hàng doanh nghiệp yêu cầu tuân thủ WCAG, hàng loạt vấn đề lộ ra. Người dùng bàn phím không Tab tới được câu hỏi nào vì <div> không nằm trong tab order. Screen reader NVDA đọc lướt qua như văn bản thường, không hề báo "có thể bấm để mở". Không có aria-expanded nên người mù hoàn toàn không biết câu trả lời đã bung ra hay chưa — họ bấm mà tưởng như không có gì xảy ra.

Bài học: nhóm này chỉ cần thay toàn bộ bằng <details>/<summary> là giải quyết được 90% vấn đề trong một buổi chiều. Họ giữ lại phần CSS mũi tên xoay (dùng details[open] summary::after), bỏ hoàn toàn hàm toggle() JavaScript. Kết quả: nhẹ hơn, đúng chuẩn hơn, ít code hơn. Đây là minh chứng kinh điển rằng accessibility đôi khi làm code của bạn đơn giản đi chứ không phức tạp thêm.

Ví dụ 2 — Fintech cần animation nên tự xây bằng ARIA

Một startup fintech ở TP.HCM (giả định tên "VíXanh") có trang "Điều khoản & Biểu phí" với 8 mục lớn. Bộ phận thiết kế yêu cầu animation trượt mượt 250ms khi mở, và chế độ single-select để người dùng không bị choáng bởi quá nhiều nội dung mở cùng lúc. <details> thuần không đáp ứng animation mượt như họ muốn, nên dev quyết định tự xây bằng ARIA.

Lần đầu triển khai, họ mắc lỗi kinh điển: để làm animation trượt bằng max-height, họ giữ nội dung panel luôn trong DOM và chỉ set max-height: 0 khi đóng. Hậu quả là người dùng bàn phím tab được vào các link bên trong panel đang đóng — con trỏ focus biến mất khỏi màn hình, người dùng bấm Tab mà không thấy gì di chuyển. Người dùng screen reader thì nghe đọc cả nội dung của những mục đang đóng.

Cách họ sửa: khi panel đóng xong animation, JavaScript set hidden (hoặc inert) lên panel để loại nó khỏi tab order và cây accessibility; khi mở thì bỏ hidden trước rồi mới chạy animation. Đồng thời họ đặt aria-expanded đúng trên từng button và, vì là single-select, khi mở panel mới thì cập nhật aria-expanded="false" cho button cũ. Bài học: animation là chuyện thị giác, nhưng trạng thái ẩn/hiện phải đồng bộ với cây accessibility — hai thứ này không được lệch nhau.

Ví dụ 3 — Accordion điều hướng trên mobile của một app giao đồ ăn

Một app giao đồ ăn (giả định "GơiMón") dùng accordion cho menu danh mục món trên phiên bản web mobile: bấm "Món chính" thì bung ra danh sách, bấm "Đồ uống" thì bung danh sách khác. Vấn đề họ gặp không phải ở đóng/mở mà ở heading. Ban đầu họ để mỗi tiêu đề accordion là <h3>, nhưng trên trang đó <h1> là tên nhà hàng và không có <h2> nào ở giữa, tạo ra bước nhảy heading từ h1 xuống h3. Người dùng screen reader duyệt trang bằng cách nhảy giữa các heading (một thao tác cực phổ biến) bị rối vì cấu trúc heading gãy.

Cách sửa: đưa các tiêu đề accordion về đúng cấp <h2> để nối liền với <h1> phía trên, đồng thời giữ nguyên <button> bên trong mỗi heading. Bài học: accordion không sống một mình — tiêu đề của nó là một phần của cấu trúc heading tổng thể của trang, và bạn phải chọn cấp heading sao cho liền mạch với ngữ cảnh xung quanh.

Hướng dẫn từng bước

Đây là quy trình mình khuyên bạn áp dụng cho mọi accordion mới.

Bước 1 — Hỏi: <details> có đủ không? Nếu bạn chỉ cần đóng/mở đơn giản, cho phép nhiều panel mở, không cần animation cầu kỳ, hãy chọn <details>/<summary>. Dừng ở đây. Bạn vừa tiết kiệm rất nhiều công sức.

Bước 2 — Nếu phải tự xây, dựng cấu trúc HTML đúng ngữ nghĩa. Mỗi mục gồm một heading (<h2>/<h3> phù hợp ngữ cảnh) bọc một <button>, và một panel <div> riêng có id.

Bước 3 — Gắn thuộc tính ARIA. Trên mỗi button: aria-expanded (khởi tạo "false" nếu đóng) và aria-controls trỏ tới id panel. Trên mỗi panel: role="region" và aria-labelledby trỏ về id của button.

Bước 4 — Quản lý trạng thái ẩn/hiện bằng hidden. Panel đóng phải có hidden. Khi mở, gỡ hidden; khi đóng, thêm lại. Đừng chỉ dựa vào opacity hay height để ẩn nội dung khỏi bàn phím.

Bước 5 — Viết JavaScript toggle. Khi bấm button: đảo aria-expanded giữa "true"/"false", đảo hidden trên panel tương ứng. Nếu là single-select, đóng tất cả panel khác và set aria-expanded="false" cho các button khác.

button.addEventListener('click', () => {
  const expanded = button.getAttribute('aria-expanded') === 'true';
  button.setAttribute('aria-expanded', String(!expanded));
  panel.hidden = expanded; // đang mở thì đóng lại, và ngược lại
});

Bước 6 — (Tùy chọn nâng cao) Thêm điều hướng bằng phím mũi tên giữa các button tiêu đề bằng Mũi tên lên/xuống, Home, End. Nhớ: Enter/Space đã có sẵn nhờ <button>.

Bước 7 — Kiểm thử. Tab qua toàn bộ accordion chỉ bằng bàn phím. Mở/đóng bằng Enter và Space. Bật screen reader (VoiceOver trên Mac, NVDA trên Windows) và xác nhận nó đọc đúng "nút, thu gọn/mở rộng" và trạng thái thay đổi khi bấm.

Lỗi thường gặp & mẹo

Dùng <div onclick> thay cho <button>. Đây là lỗi số một. <div> không vào tab order, không nhận Enter/Space, không có role button. Luôn dùng <button> thật, hoặc dùng <details>.

Quên aria-expanded. Nếu thiếu thuộc tính này, người dùng screen reader không biết panel đang đóng hay mở — mất hoàn toàn phản hồi trạng thái. Đây là trái tim của accordion accessibility.

Ẩn panel bằng opacity/visibility nhưng để nội dung sống trong DOM. Bàn phím sẽ tab vào "vùng vô hình", focus biến mất. Dùng hidden, display:none, hoặc inert cho panel đóng.

aria-expanded đặt sai chỗ. Nó phải nằm trên chính phần tử tương tác (button), không phải trên panel hay trên thẻ heading bao ngoài.

Nhồi heading vào button hoặc button vào heading sai thứ tự. Đúng chuẩn là <h3><button>...</button></h3> — button bên trong heading. Đừng đặt role="heading" lên button; hãy dùng thẻ heading thật bọc ngoài.

Chọn sai cấp heading. Như ví dụ app giao đồ ăn: cấp heading của accordion phải liền mạch với cấu trúc trang, không nhảy cóc.

Icon mũi tên gây nhiễu screen reader. Nếu bạn nhét icon mũi tên bằng ký tự hay <i> vào trong button, nhớ cho nó aria-hidden="true" để screen reader không đọc "tam giác đen chỉ xuống". Trạng thái đã được aria-expanded truyền tải rồi.

Mẹo lớn nhất: trước khi tự viết ARIA, hãy thử <details>/<summary> một lần. Bạn sẽ ngạc nhiên vì nó đủ dùng trong đa số trường hợp thực tế.

Bài tập thực hành

  • Chuyển đổi: Tìm một trang FAQ bất kỳ đang dùng <div onclick> (hoặc tự dựng một cái), rồi viết lại hoàn toàn bằng <details>/<summary>. Thêm CSS để có mũi tên xoay khi mở (details[open] summary::after). Xác nhận không còn dòng JavaScript nào.
  • Xây bằng ARIA: Dựng một accordion 3 mục bằng <button> + aria-expanded + aria-controls + panel có role="region" và hidden. Viết JavaScript toggle. Yêu cầu: tab được tới từng button, Enter và Space đều mở/đóng được.
  • Single-select: Nâng cấp bài 2 thành chế độ chỉ-một-panel-mở. Khi mở panel mới, đóng panel cũ và cập nhật aria-expanded của button cũ về "false".
  • Kiểm thử screen reader: Bật VoiceOver (Cmd+F5 trên Mac) hoặc NVDA (Windows), duyệt qua accordion bạn vừa làm. Ghi lại đúng câu screen reader đọc khi focus vào button đóng, và khi bạn bấm mở. So sánh với accordion <div onclick> để cảm nhận sự khác biệt.

Tóm tắt

Accordion tưởng đơn giản nhưng là nơi rất dễ đánh rơi accessibility vì nó mang trạng thái đóng/mở phải được truyền tải cho người dùng không nhìn màn hình. Ba điều cốt lõi cần nhớ:

  • Ưu tiên <details>/<summary>. Trình duyệt tặng bạn focus, bàn phím, và thông báo trạng thái miễn phí. Đừng viết lại thứ đã có sẵn.
  • Nếu tự xây, dùng <button> thật trong một heading đúng cấp, gắn aria-expanded (trái tim của component), aria-controls, và cho panel role="region" + aria-labelledby.
  • Đồng bộ trạng thái ẩn/hiện với cây accessibility bằng hidden/inert, đừng chỉ ẩn bằng CSS để tránh focus lạc vào nội dung đóng.
Nắm chắc accordion, bạn đã có trong tay một mẫu tư duy dùng lại được cho nhiều component có trạng thái khác. Ở các bài kế tiếp, bạn sẽ thấy chính tư duy "trạng thái phải nói được cho screen reader" này quay lại với dropdown, tabs và tooltip.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi