Product Management
Đăng nhập
ESC

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

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

Bài 19 — Tables — Accessible Markup

Đã đọ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

Hãy tưởng tượng bạn là một người khiếm thị đang dùng screen reader để đọc bảng giá dịch vụ của một ngân hàng số. Bạn nghe: "150 nghìn... 5% ... 12 tháng... 200 nghìn... 5.5% ... 24 tháng". Một chuỗi con số trôi qua tai bạn mà không có bất kỳ điểm neo nào. 150 nghìn là gì? Phí thường niên hay số dư tối thiểu? 5% là lãi suất hay chiết khấu? Bạn hoàn toàn lạc lối, dù thông tin đó có sẵn ngay trước mắt người sáng mắt.

Đó chính là lý do bài học này tồn tại. Bảng dữ liệu (<table>) là một trong những thành phần bị làm sai nhiều nhất trên web Việt Nam. Rất nhiều lập trình viên hoặc dùng <div> giả lập bảng, hoặc dùng <table> nhưng thiếu hoàn toàn cấu trúc ngữ nghĩa. Kết quả: dữ liệu đọc được bằng mắt nhưng "câm điếc" với công nghệ hỗ trợ.

Điểm mấu chốt bạn cần khắc cốt ghi tâm là: một cái bảng đúng nghĩa không chỉ là hàng và cột xếp cạnh nhau. Nó là một cấu trúc quan hệ — mỗi ô dữ liệu phải "biết" nó thuộc về tiêu đề hàng nào và tiêu đề cột nào. Khi bạn đánh dấu (markup) đúng, screen reader sẽ tự động thông báo ngữ cảnh: "Lãi suất, gói 12 tháng: 5 phần trăm". Người dùng lập tức hiểu. Đó là toàn bộ mục tiêu của bài này — biến một khối số liệu vô hồn thành một bảng có ngữ cảnh, điều hướng được và hiểu được cho mọi người.

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

Khi nào dùng <table> — và khi nào tuyệt đối không

Quy tắc vàng: chỉ dùng <table> khi bạn có dữ liệu dạng bảng thực sự — nghĩa là các ô có quan hệ với nhau theo hàng và cột, và người dùng cần so sánh hoặc tra cứu chéo giữa các giá trị. Ví dụ: bảng giá theo gói dịch vụ, bảng lịch chiếu phim theo giờ, báo cáo doanh thu theo tháng, bảng so sánh tính năng sản phẩm.

Ngược lại, tuyệt đối không dùng <table> để dàn trang (layout). Đây là kỹ thuật của những năm 2000 — dùng bảng để chia bố cục trang web thành các ô. Ngày nay ta có CSS Grid và Flexbox làm việc này tốt hơn nhiều. Khi bạn dùng <table> cho layout, screen reader vẫn tưởng đó là dữ liệu và cố đọc theo kiểu "hàng 1, cột 2", làm rối loạn hoàn toàn trải nghiệm nghe. Nếu bắt buộc phải dùng bảng cho mục đích trình bày không phải dữ liệu (hiếm gặp), hãy thêm role="presentation" để screen reader bỏ qua ngữ nghĩa bảng.

Bộ khung cấu trúc chuẩn

Một bảng dữ liệu đầy đủ ngữ nghĩa gồm các thành phần sau:

<table>
  <caption>Bảng giá gói cước Internet FPT tháng 6/2026</caption>
  <thead>
    <tr>
      <th scope="col">Gói cước</th>
      <th scope="col">Tốc độ</th>
      <th scope="col">Giá/tháng</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Giga</th>
      <td>150 Mbps</td>
      <td>230.000đ</td>
    </tr>
    <tr>
      <th scope="row">Meta</th>
      <td>250 Mbps</td>
      <td>320.000đ</td>
    </tr>
  </tbody>
</table>

Hãy phân tích từng mảnh:

  • <caption>: tiêu đề của bảng, đặt ngay sau thẻ <table> mở. Đây là điều đầu tiên screen reader đọc lên, giúp người dùng biết "bảng này nói về cái gì" trước khi lao vào từng ô. Rất nhiều người bỏ qua <caption> — đừng như vậy. Nó giống như tên của bảng, và người khiếm thị cực kỳ cần nó để định hướng.
  • <thead>, <tbody>, <tfoot>: nhóm ngữ nghĩa phần đầu, thân và chân bảng. Ngoài việc giúp trình duyệt hiểu cấu trúc, chúng còn hỗ trợ tính năng in ấn (lặp lại header qua các trang) và điều hướng.
  • <th> — Table Header: ô tiêu đề. Đây là thành phần quan trọng nhất về mặt accessibility. <th> khác <td> ở chỗ nó báo cho công nghệ hỗ trợ biết "đây là nhãn của một hàng hoặc cột".
  • scope: thuộc tính then chốt. scope="col" nghĩa là ô tiêu đề này áp dụng cho cả cột phía dưới nó. scope="row" nghĩa là nó là tiêu đề cho cả hàng bên phải nó. Chính scope tạo nên "quan hệ" mà tôi nhắc từ đầu bài — nó nối mỗi ô dữ liệu với đúng tiêu đề của nó.

Bảng có tiêu đề hai chiều

Nhiều bảng thực tế có tiêu đề ở cả hàng đầu tiên và cột đầu tiên — ví dụ bảng lãi suất tiết kiệm với hàng là kỳ hạn và cột là hình thức gửi. Đây là lúc bạn phải dùng scope="row" cho ô tiêu đề đầu mỗi hàng và scope="col" cho ô tiêu đề đầu mỗi cột. Khi đó, screen reader đọc một ô dữ liệu sẽ thông báo cả hai tiêu đề: "Kỳ hạn 12 tháng, gửi online: 6,1%".

Bảng phức tạp và cặp headers/id

Với những bảng thực sự phức tạp — có ô tiêu đề trải nhiều cột (colspan) hoặc nhiều tầng tiêu đề — riêng scope không đủ. Lúc này bạn dùng cặp thuộc tính id (đặt trên mỗi <th>) và headers (đặt trên mỗi <td>, liệt kê các id tiêu đề mà ô đó liên quan). Đây là cách liên kết thủ công, chính xác tuyệt đối nhưng tốn công. Lời khuyên của tôi: nếu bảng của bạn phức tạp đến mức phải dùng headers/id, hãy tự hỏi liệu có thể tách nó thành nhiều bảng đơn giản hơn không. Bảng càng đơn giản, càng dễ dùng cho tất cả mọi người, không chỉ người khuyết tật.

Tình huống thực tế

Ví dụ 1 — Sàn TMĐT Tiki và bảng so sánh sản phẩm "câm"

Một đội frontend tại một sàn thương mại điện tử lớn (giả định theo mô hình Tiki) xây dựng tính năng "So sánh sản phẩm". Về mặt hình ảnh, bảng rất đẹp: ba chiếc điện thoại xếp thành ba cột, các thuộc tính RAM, pin, camera xếp thành hàng. Nhưng họ dựng toàn bộ bằng <div> với CSS Grid, không dùng một thẻ <table> nào.

Khi một khách hàng khiếm thị dùng screen reader NVDA thử tính năng này, trải nghiệm là một thảm họa: các giá trị "8GB", "5000mAh", "50MP" trôi qua như một danh sách rời rạc, không hề gắn với tên điện thoại hay tên thuộc tính. Người dùng không thể biết 8GB là RAM của máy nào. Họ báo cáo lỗi, và tỉ lệ hoàn thành so sánh trên nhóm người dùng screen reader gần như bằng 0.

Đội kỹ thuật viết lại bằng <table> thực sự: cột đầu là thuộc tính (scope="row"), hàng đầu là tên máy (scope="col"). Sau khi sửa, screen reader đọc rõ ràng: "iPhone 15, RAM: 8 gigabyte". Bài học: đẹp bằng mắt không đồng nghĩa với dùng được. <div> giả lập bảng đánh mất toàn bộ quan hệ ngữ nghĩa mà <table> cho miễn phí.

Ví dụ 2 — Ngân hàng số và bảng lãi suất thiếu caption + scope

Một ngân hàng số (giả định theo mô hình Timo hay TPBank) có trang biểu lãi suất tiết kiệm. Họ dùng <table> đàng hoàng — điểm cộng. Nhưng mọi ô đều là <td>, không có <th>, không có scope, không có <caption>. Cả bảng là một lưới số phẳng lì.

Khi kiểm thử với screen reader, kiểm thử viên phát hiện: không có cách nào để người dùng biết cột nào là "kỳ hạn", cột nào là "lãi suất". Screen reader chỉ đọc "1 tháng, 3,1, 3 tháng, 3,5..." — người nghe phải tự đoán số nào là kỳ hạn, số nào là lãi. Với sản phẩm tài chính, hiểu sai một con số có thể dẫn đến quyết định gửi tiền sai.

Cách sửa cực kỳ đơn giản: đổi hàng tiêu đề thành <th scope="col">, đổi cột kỳ hạn đầu mỗi hàng thành <th scope="row">, và thêm <caption>Biểu lãi suất tiết kiệm áp dụng từ 01/06/2026</caption>. Chỉ ba thay đổi, không đụng đến giao diện, nhưng biến bảng từ "câm" thành đọc được. Bài học: dùng <table> mới là điều kiện cần; <th>, scope và <caption> mới là điều kiện đủ.

Ví dụ 3 — Bảng lịch học trên nền tảng đào tạo và vấn đề bảng lồng nhau

Một startup edtech ở TP.HCM xây bảng thời khóa biểu: hàng là khung giờ, cột là các ngày trong tuần, mỗi ô chứa tên môn học. Ban đầu ổn. Nhưng khi thêm tính năng hiển thị chi tiết (giảng viên, phòng học) trong mỗi ô, một lập trình viên đã lồng một <table> vào bên trong ô <td>.

Bảng lồng bảng khiến screen reader rối loạn: người dùng đi vào bảng con rồi không biết mình đang ở đâu trong bảng cha, ngữ cảnh hàng/cột bị mất. Kiểm thử với người dùng thật cho thấy nhiều người bỏ cuộc giữa chừng.

Giải pháp là bỏ bảng con, thay bằng cấu trúc đơn giản trong ô — một đoạn văn bản ngắn với nhãn rõ ràng ("GV: Cô Lan • P.302"). Bài học: tránh lồng bảng trong bảng. Nếu ô cần chứa thông tin phức tạp, hãy dùng cấu trúc phi bảng (danh sách, đoạn văn) bên trong ô đó.

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

Đây là quy trình tôi khuyên bạn áp dụng mỗi khi cần dựng một bảng dữ liệu:

Bước 1 — Xác nhận đây có thực sự là dữ liệu bảng không. Tự hỏi: các ô có quan hệ hàng-cột không? Người dùng có cần so sánh hay tra cứu chéo không? Nếu câu trả lời là "không" (ví dụ bạn chỉ muốn xếp 3 thẻ cạnh nhau), hãy dùng CSS Grid/Flexbox, đừng dùng <table>.

Bước 2 — Dựng khung cơ bản với <table>, <thead>, <tbody>. Phân định rõ đâu là phần tiêu đề, đâu là phần thân dữ liệu.

Bước 3 — Thêm <caption> ngay sau <table>. Viết một câu ngắn mô tả nội dung và phạm vi bảng, kèm mốc thời gian nếu là dữ liệu biến động (giá, lãi suất).

Bước 4 — Chuyển ô tiêu đề thành <th> và gán scope. Hàng tiêu đề trên cùng dùng scope="col". Nếu có tiêu đề ở cột trái (tên hàng), ô đầu mỗi hàng dùng scope="row".

Bước 5 — Kiểm tra bảng có phức tạp không. Nếu có colspan/rowspan ở tiêu đề hoặc nhiều tầng header, cân nhắc dùng cặp headers/id, hoặc tốt hơn là tách thành nhiều bảng đơn giản.

Bước 6 — Kiểm thử bằng screen reader. Bật NVDA (Windows) hoặc VoiceOver (Mac), vào chế độ điều hướng bảng (thường dùng phím Ctrl + Alt + mũi tên với NVDA), di chuyển qua từng ô và lắng nghe. Screen reader có đọc đúng tiêu đề hàng và cột cho mỗi ô không? Nếu có, bạn đã làm đúng.

Bước 7 — Kiểm tra khả năng cuộn ngang trên mobile. Bảng rộng dễ vỡ trên màn hình nhỏ. Bọc bảng trong một <div> có overflow-x: auto và thêm tabindex="0" cùng role="region" với aria-label để vùng cuộn đó điều hướng được bằng bàn phím.

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

Lỗi 1: Dùng <td> cho tất cả, không có <th>. Đây là lỗi phổ biến nhất. Không có <th> thì không có quan hệ ngữ nghĩa, và bảng trở nên vô dụng với screen reader. Luôn dùng <th> cho mọi ô tiêu đề.

Lỗi 2: Quên scope. Có <th> nhưng thiếu scope thì với bảng đơn giản một số screen reader vẫn đoán được, nhưng đừng phụ thuộc vào may rủi. Luôn ghi scope rõ ràng — nó chỉ tốn vài ký tự nhưng đảm bảo hành vi nhất quán trên mọi công nghệ hỗ trợ.

Lỗi 3: Bỏ <caption> và thay bằng <h3> phía trên bảng. Một tiêu đề <h3> bên ngoài không liên kết ngữ nghĩa với bảng. Screen reader không "biết" nó là tên của bảng. <caption> mới là cách chuẩn.

Lỗi 4: Dùng bảng cho layout. Đã lỗi thời và gây hại cho accessibility. Nếu buộc phải làm, thêm role="presentation".

Lỗi 5: Nhồi nội dung tương tác phức tạp (form, bảng con) vào ô. Giữ ô đơn giản. Ô nên chứa dữ liệu, không nên chứa cả một ứng dụng con.

Mẹo hay:

  • Với bảng dài, thêm <th scope="row"> giúp người dùng luôn biết mình đang ở hàng nào khi cuộn.
  • Đừng dùng ô trống làm khoảng cách trình bày. Ô trống làm screen reader đọc "trống" gây bối rối. Dùng CSS để tạo khoảng cách.
  • Nếu một ô thực sự không có dữ liệu, cân nhắc ghi rõ như "—" hoặc "Không áp dụng" thay vì để trống.
  • Tránh gộp ô (colspan/rowspan) khi không cần thiết — ô gộp làm điều hướng bằng công nghệ hỗ trợ khó hơn nhiều.

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

Bài 1 — Sửa bảng "câm". Cho một bảng giá dịch vụ dùng toàn <td>, không <caption>, không scope. Nhiệm vụ: viết lại HTML cho đúng — thêm <caption>, chuyển hàng tiêu đề thành <th scope="col">, chuyển cột tên dịch vụ đầu mỗi hàng thành <th scope="row">. So sánh trải nghiệm nghe trước và sau bằng NVDA hoặc VoiceOver.

Bài 2 — Dựng bảng hai chiều. Tự tạo một bảng thời khóa biểu 5 ngày x 4 khung giờ, có tiêu đề ở cả hàng trên (thứ) và cột trái (giờ), gán scope đúng cho cả hai chiều. Kiểm thử: khi đứng ở một ô môn học bất kỳ, screen reader phải đọc được cả thứ lẫn khung giờ.

Bài 3 — Đánh giá một trang thật. Chọn một website Việt Nam có bảng dữ liệu (bảng giá, lãi suất, lịch chiếu). Mở DevTools, kiểm tra: nó dùng <table> hay <div>? Có <th>, scope, <caption> không? Viết một báo cáo ngắn liệt kê vấn đề và đề xuất cách sửa.

Bài 4 — Xử lý bảng rộng trên mobile. Lấy một bảng có 8 cột, bọc nó trong vùng cuộn ngang truy cập được bằng bàn phím (overflow-x: auto, tabindex="0", role="region", aria-label). Thử tab vào và cuộn bằng phím mũi tên.

Tóm tắt

Bảng dữ liệu đúng nghĩa không phải là hàng và cột đặt cạnh nhau về mặt hình ảnh — nó là một cấu trúc quan hệ, nơi mỗi ô dữ liệu biết mình thuộc tiêu đề hàng nào và cột nào. Ba trụ cột cần nhớ:

  • Dùng <table> đúng chỗ — chỉ cho dữ liệu dạng bảng cần so sánh/tra cứu, tuyệt đối không cho layout.
  • <th> + scope là linh hồn của accessibility — chúng tạo nên quan hệ ngữ nghĩa để screen reader thông báo đúng ngữ cảnh cho từng ô.
  • <caption> cho bảng một cái tên — giúp người khiếm thị định hướng ngay khi bước vào bảng.
Và luôn nhớ: đơn giản là bạn của accessibility. Bảng càng đơn giản, tránh lồng bảng, tránh gộp ô không cần thiết, thì càng dễ dùng cho tất cả mọi người. Cuối cùng, đừng tin vào mắt mình — hãy bật screen reader lên và nghe thử bảng của bạn. Chỉ cần vài phút kiểm thử đó, bạn sẽ phát hiện phần lớn lỗi mà mắt thường không bao giờ thấy.

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