Product Management
Đăng nhập
ESC

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

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

Bài 24 — Tabs Component A11y

Đã đọc 0/14 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

Tabs (thẻ tab) là một trong những component xuất hiện nhiều nhất trên web. Bạn thấy chúng ở trang chi tiết sản phẩm của Shopee (tab "Mô tả sản phẩm", "Đánh giá", "Thông tin vận chuyển"), ở trang quản lý tài khoản ngân hàng của Techcombank (tab "Tài khoản thanh toán", "Tiết kiệm", "Thẻ tín dụng"), hay đơn giản là ở dashboard nội bộ của công ty bạn. Chúng gọn gàng, tiết kiệm không gian, cho phép người dùng nhảy qua lại giữa các nhóm nội dung mà không phải cuộn dài.

Nhưng đây cũng chính là một trong những component bị làm sai accessibility nhiều nhất. Lý do rất đơn giản: về mặt hình ảnh, một bộ tab trông "hoàn thành" ngay khi nó chuyển được nội dung khi bấm chuột. Người thiết kế và lập trình viên nhìn thấy nó hoạt động, thế là xong. Nhưng với người dùng bàn phím, người dùng screen reader, hay người dùng chuyển đổi giữa các tab bằng phím mũi tên như mọi hệ điều hành đã dạy họ suốt 30 năm qua — thì một bộ tab tự chế bằng <div> và JavaScript thường là một cái bẫy: không tab tới được, không biết đang ở tab nào, không biết còn bao nhiêu tab, và không có cách nào thoát ra.

Điều đặc biệt ở tabs là nó có một pattern chuẩn được định nghĩa rất rõ ràng trong ARIA Authoring Practices Guide (APG) — tài liệu chính thức của W3C. Nghĩa là bạn không cần "sáng tạo" cách làm cho đúng; bạn chỉ cần làm theo đúng công thức. Bài này sẽ giúp bạn nắm chắc công thức đó: từ cấu trúc HTML, các thuộc tính ARIA, đến hành vi bàn phím cụ thể mà người dùng mong đợi. Đây là kiến thức bạn dùng lại được cho hầu hết mọi component phức tạp về sau.

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

Ba vai trò (role) tạo nên một bộ tab

Một bộ tab accessible được cấu thành từ ba loại phần tử, mỗi loại mang một role riêng:

  • tablist: phần bao ngoài chứa toàn bộ các tab. Nó cho screen reader biết "đây là một nhóm tab", và thông báo tổng số tab (ví dụ "tab 2 of 4").
  • tab: chính là nút bấm để chọn một tab. Mỗi tab phải trỏ tới panel nội dung của nó.
  • tabpanel: vùng chứa nội dung tương ứng với tab đang được chọn.
Cấu trúc HTML chuẩn theo APG như sau:

<div role="tablist" aria-label="Thông tin khóa học">
  <button role="tab"
          id="tab-1"
          aria-selected="true"
          aria-controls="panel-1">
    Giới thiệu
  </button>
  <button role="tab"
          id="tab-2"
          aria-selected="false"
          aria-controls="panel-2"
          tabindex="-1">
    Chương trình học
  </button>
  <button role="tab"
          id="tab-3"
          aria-selected="false"
          aria-controls="panel-3"
          tabindex="-1">
    Đánh giá
  </button>
</div>

<div role="tabpanel" id="panel-1" aria-labelledby="tab-1" tabindex="0">
  Nội dung phần Giới thiệu...
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" tabindex="0" hidden>
  Nội dung phần Chương trình học...
</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" tabindex="0" hidden>
  Nội dung phần Đánh giá...
</div>

Hãy để ý những mối liên kết chéo. Mỗi tab có aria-controls trỏ tới id của panel mà nó điều khiển. Ngược lại, mỗi tabpanel có aria-labelledby trỏ về id của tab đặt tên cho nó. Đây là cách bạn "khâu" ngữ nghĩa lại với nhau để screen reader hiểu mối quan hệ.

aria-selected — trạng thái được chọn

Thuộc tính aria-selected="true" chỉ đặt trên đúng một tab tại một thời điểm — tab đang active. Tất cả các tab còn lại là aria-selected="false". Đây là điều mà screen reader đọc lên để người dùng biết mình đang đứng ở tab nào ("Đánh giá, tab, selected, 3 of 3"). Đừng nhầm lẫn: trạng thái active KHÔNG được truyền tải chỉ bằng màu sắc hay gạch chân — nó phải nằm trong aria-selected.

Roving tabindex — bí quyết quan trọng nhất

Đây là điểm mà 90% người mới làm sai. Trong một bộ tab đúng chuẩn, toàn bộ tablist chỉ chiếm MỘT điểm dừng Tab (một tab stop). Nghĩa là khi người dùng nhấn phím Tab để di chuyển trên trang, họ dừng lại đúng một lần ở tablist, rồi Tab tiếp là nhảy vào panel nội dung — chứ không phải nhấn Tab qua từng cái tab một.

Vậy làm sao chuyển giữa các tab? Bằng phím mũi tên (Arrow Left/Right). Kỹ thuật để đạt được điều này gọi là "roving tabindex":

  • Tab đang được chọn có tabindex="0" (nằm trong luồng Tab).
  • Tất cả các tab khác có tabindex="-1" (bị loại khỏi luồng Tab nhưng vẫn focus được bằng JavaScript).
  • Khi người dùng bấm mũi tên để chuyển tab, bạn di chuyển giá trị tabindex="0" sang tab mới và focus vào nó.
Cái tabindex "lăn" (roving) từ tab này sang tab khác — đó là lý do có tên gọi này. Kết quả: một điểm dừng Tab duy nhất, và điều hướng nội bộ bằng mũi tên — đúng như cách menu trong Windows, macOS hay bất kỳ ứng dụng desktop nào hoạt động.

Hai kiểu kích hoạt: automatic vs manual

APG cho phép hai kiểu:

  • Automatic activation: khi người dùng dùng mũi tên di chuyển tới một tab, panel tương ứng hiển thị ngay lập tức. Phù hợp khi nội dung nhẹ, tải nhanh.
  • Manual activation: mũi tên chỉ di chuyển focus, người dùng phải nhấn Enter hoặc Space để thực sự mở panel. Phù hợp khi mỗi panel tốn tài nguyên (gọi API, tải ảnh nặng) — vì bạn không muốn nạp dữ liệu chỉ vì người dùng lướt qua.

Tình huống thực tế

Ví dụ 1 — Trang chi tiết sản phẩm của một sàn TMĐT Việt Nam

Một sàn thương mại điện tử tại TP.HCM (tạm gọi là "MuaNhanh") có trang chi tiết sản phẩm với 3 tab: "Mô tả", "Thông số kỹ thuật", "Đánh giá (1.284)". Đội frontend làm tab bằng <div> gắn onclick. Bấm chuột thì hoạt động mượt.

Khi công ty ký hợp đồng bán hàng cho một cơ quan nhà nước, họ phải qua kiểm định accessibility. Auditor dùng bàn phím và phát hiện: không thể Tab tới được các tab (vì <div> không nằm trong luồng Tab), và screen reader đọc ba tab đó chỉ là "text" vô nghĩa, không hề biết đó là tab, cũng không biết tab "Đánh giá" đang mở. Với người khiếm thị muốn đọc review trước khi mua — một hành vi cực kỳ phổ biến ở TMĐT — nội dung đó gần như vô hình.

Đội kỹ thuật đổi <div> thành <button role="tab">, thêm role="tablist", aria-selected, aria-controls, và cài roving tabindex với điều hướng bằng mũi tên. Sau khi sửa, một tester khiếm thị của họ điều hướng được toàn bộ ba tab bằng bàn phím trong vài giây. Bài học: một component "trông xong" trên chuột có thể hoàn toàn hỏng với bàn phím và screen reader; và việc sửa chủ yếu là dùng đúng phần tử ngữ nghĩa (button) cộng với ba role của tabs.

Ví dụ 2 — Dashboard ngân hàng số và bẫy "automatic activation nặng"

Một ngân hàng số ở Đông Nam Á có màn hình tổng quan tài khoản với các tab: "Tài khoản thanh toán", "Tiết kiệm", "Đầu tư", "Vay". Họ đã làm tabs khá chuẩn với ARIA, nhưng chọn kiểu automatic activation — nghĩa là mỗi khi người dùng dùng mũi tên lướt qua một tab, hệ thống lập tức gọi API lấy dữ liệu số dư của tab đó.

Vấn đề: người dùng screen reader thường dùng mũi tên để "khám phá" xem có những tab nào trước khi quyết định. Khi họ lướt từ "Thanh toán" sang "Vay" và ngược lại chỉ để nghe tên các tab, hệ thống gọi tới 4 API liên tiếp, mỗi lần làm panel nhấp nháy và screen reader đọc lại nội dung mới — gây rối loạn và chậm rõ rệt trên mạng 3G ở vùng nông thôn.

Đội kỹ thuật chuyển sang manual activation: mũi tên chỉ di chuyển focus và đọc tên tab, chỉ khi người dùng nhấn Enter thì mới gọi API và mở panel. Trải nghiệm trở nên êm hơn hẳn, và lưu lượng API giảm đáng kể. Bài học: chọn automatic hay manual activation không phải chuyện sở thích — nó phụ thuộc vào chi phí của việc mở panel. Panel nặng thì chọn manual.

Ví dụ 3 — Nhầm tabs với accordion trên mobile

Một startup edtech ở Hà Nội thiết kế trang khóa học có bộ tab ngang ("Giới thiệu", "Nội dung", "Giảng viên", "Câu hỏi"). Trên desktop, tabs chạy tốt. Nhưng trên điện thoại, bốn nhãn tab bị bóp lại, chữ tràn và người dùng phải cuộn ngang mới thấy hết — nhiều người không biết còn tab "Câu hỏi" ở bên phải.

Designer định "sửa" bằng cách cho các nhãn tab xuống dòng thành nhiều hàng, nhưng như vậy hàng tab trở nên khó hiểu về mặt điều hướng bằng mũi tên (mũi tên trái/phải không còn khớp với bố cục). Cuối cùng nhóm quyết định: trên màn hình hẹp, chuyển pattern sang accordion (nội dung xếp dọc, mở/đóng từng mục) — một pattern phù hợp với không gian dọc hơn. Họ giữ tabs cho desktop và accordion cho mobile.

Bài học: tabs là pattern cho không gian ngang. Khi không gian không đủ, đừng cố nhồi — hãy cân nhắc đổi sang pattern khác phù hợp hơn. (Chi tiết về accordion và cách làm nó accessible sẽ được học ở bài riêng.)

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

Đây là quy trình để dựng một bộ tab accessible từ đầu:

  • Dùng đúng phần tử. Dùng <button> cho mỗi tab, không dùng <div> hay <a>. Button đã có sẵn hành vi bàn phím và trạng thái focus. Bọc chúng trong một phần tử có role="tablist".
  • Gắn nhãn cho tablist. Thêm aria-label (ví dụ aria-label="Thông tin khóa học") hoặc aria-labelledby để screen reader biết nhóm tab này nói về cái gì.
  • Thiết lập quan hệ tab ↔ panel. Mỗi tab có id riêng và aria-controls trỏ tới id của panel. Mỗi tabpanel có role="tabpanel", id riêng, và aria-labelledby trỏ về id của tab.
  • Đặt trạng thái selected. Tab đang active có aria-selected="true"; các tab còn lại aria-selected="false". Cập nhật giá trị này mỗi khi đổi tab.
  • Cài roving tabindex. Tab active có tabindex="0"; các tab khác tabindex="-1". Khi đổi tab, di chuyển tabindex="0" sang tab mới.
  • Xử lý panel ẩn/hiện. Ẩn các panel không active bằng thuộc tính hidden (không chỉ CSS display:none qua class — dùng hidden để screen reader cũng bỏ qua). Cho panel tabindex="0" để người dùng có thể Tab vào vùng nội dung.
  • Viết xử lý bàn phím. Trên mỗi tab, lắng nghe phím:
- ArrowRight / ArrowLeft: chuyển focus sang tab kế tiếp / trước đó (và nên "vòng lại" — từ tab cuối bấm phải thì quay về tab đầu). - Home / End: nhảy tới tab đầu / tab cuối. - Enter / Space: kích hoạt tab (bắt buộc nếu bạn dùng manual activation).

  • Chọn automatic hay manual activation dựa trên chi phí mở panel, như đã phân tích ở trên.
  • Kiểm thử bằng bàn phím và screen reader. Tự tay Tab qua trang, dùng mũi tên chuyển tab, và bật một screen reader (VoiceOver trên Mac, NVDA trên Windows) để nghe xem nó có đọc đúng "tab, selected, X of Y" không.

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

  • Dùng <div onclick> thay vì <button>. Đây là lỗi gốc rễ. <div> không focus được, không có hành vi phím, và screen reader không hiểu. Luôn bắt đầu bằng <button>.
  • Để mọi tab đều tab-vào-được. Nếu bạn không cài roving tabindex, mỗi tab thành một điểm dừng Tab. Với bộ 6 tab, người dùng bàn phím phải nhấn Tab 6 lần chỉ để đi qua chúng — trái với kỳ vọng và mệt mỏi. Nhớ: tablist = một tab stop, mũi tên để chuyển bên trong.
  • Chỉ đổi màu, quên aria-selected. Trạng thái active phải nằm trong ARIA, không chỉ trong CSS. Nếu chỉ đổi màu, người dùng screen reader không biết tab nào đang mở.
  • Ẩn panel bằng CSS opacity/visibility thay vì hidden. Panel ẩn kiểu nửa vời (ví dụ opacity:0 hay position:absolute đẩy ra ngoài) vẫn bị screen reader đọc và vẫn tab được vào — gây ra "nội dung ma". Dùng thuộc tính hidden hoặc display:none để ẩn triệt để.
  • Quên vòng lặp mũi tên. Người dùng mong khi đang ở tab cuối và bấm mũi tên phải thì quay về tab đầu (và ngược lại). Đây là chi tiết nhỏ nhưng làm trải nghiệm mượt hơn nhiều.
  • Nhồi quá nhiều tab. Nếu bạn có hơn 6–7 tab, hoặc nhãn tab quá dài, hãy cân nhắc lại — có thể pattern khác phù hợp hơn, hoặc bạn cần rút gọn.
  • Mẹo tận dụng thư viện có sẵn. Nếu dùng React, Vue, hay các thư viện headless như Radix UI, Headless UI, Reach UI — chúng đã hiện thực đầy đủ pattern tabs của APG (roving tabindex, phím mũi tên, ARIA). Dùng lại chúng thường an toàn hơn tự viết từ đầu, miễn là bạn vẫn kiểm thử lại bằng bàn phím và screen reader.
  • Mẹo đặt nhãn tab ngắn gọn, mô tả rõ. "Đánh giá", "Vận chuyển" tốt hơn "Xem thêm 1", "Xem thêm 2". Screen reader đọc nhãn này ra, nên nó cần có nghĩa khi tách khỏi ngữ cảnh hình ảnh.

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

  • Audit một bộ tab có sẵn. Chọn một trang web tiếng Việt bạn hay dùng (ví dụ trang chi tiết sản phẩm của một sàn TMĐT, hoặc trang tin có tab). Chỉ dùng bàn phím: nhấn Tab để tới bộ tab, thử phím mũi tên, Home, End, Enter. Ghi lại: (a) tablist chiếm mấy tab stop? (b) mũi tên có chuyển tab không? (c) trạng thái active có được thông báo không? Viết một đoạn 5–7 dòng nhận xét nó theo đúng APG được bao nhiêu phần.
  • Dựng một bộ tab tối thiểu. Tạo một trang HTML với 3 tab ("Giới thiệu", "Nội dung", "Đánh giá") theo đúng cấu trúc trong bài: đủ role, aria-selected, aria-controls, aria-labelledby, roving tabindex, và xử lý phím mũi tên + Home/End. Đảm bảo cả tablist chỉ là một tab stop.
  • Thử với screen reader. Bật VoiceOver (Mac: Cmd+F5) hoặc NVDA (Windows, miễn phí) và điều hướng bộ tab bạn vừa dựng. Nghe xem nó có đọc "tab", "selected", "X of Y" không. Nếu chưa, kiểm tra lại xem thiếu role hay thuộc tính nào.
  • So sánh automatic vs manual. Chỉnh bộ tab của bạn thành manual activation (mũi tên chỉ di chuyển focus, Enter mới mở panel). Thử cả hai kiểu và viết vài dòng: với nội dung như thế nào thì bạn sẽ chọn kiểu nào, và vì sao.

Tóm tắt

Tabs là component quen thuộc nhưng dễ làm sai accessibility, và may mắn là nó có một pattern chuẩn rất rõ trong ARIA Authoring Practices Guide để bạn làm theo. Những điểm cốt lõi cần khắc ghi:

  • Ba role: tablist bọc ngoài, tab là các nút bấm (<button>), tabpanel chứa nội dung.
  • "Khâu" quan hệ bằng aria-controls (tab → panel) và aria-labelledby (panel → tab).
  • Trạng thái active nằm trong aria-selected="true", không chỉ trong màu sắc.
  • Roving tabindex là chìa khóa: tablist chỉ chiếm một tab stop, chuyển tab bằng phím mũi tên, hỗ trợ Home/End và vòng lặp.
  • Chọn automatic hay manual activation dựa trên chi phí mở panel: panel nặng thì dùng manual.
  • Ẩn panel triệt để bằng hidden; ưu tiên dùng thư viện headless đã hiện thực đúng pattern; và luôn kiểm thử lại bằng bàn phím và screen reader.
Nắm được pattern này, bạn không chỉ làm đúng tabs mà còn có nền tảng tư duy (roving tabindex, quan hệ ARIA, hành vi bàn phím theo APG) để tiếp cận các component phức tạp hơn ở những bài sau.

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