Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đang dùng một ứng dụng ngân hàng, nhấn nút "Chuyển tiền", rồi... không có gì xảy ra. Màn hình đứng im. Bạn không biết lệnh đã được nhận chưa, tiền đã trừ chưa, hay hệ thống bị treo. Bạn nhấn lại lần nữa — và giờ thì lo lắng thật sự: liệu mình vừa chuyển tiền hai lần?
Đó chính xác là trải nghiệm mà người dùng screen reader (trình đọc màn hình) phải chịu đựng hàng ngày trên vô số trang web — nhưng ở mức độ nghiêm trọng hơn nhiều. Bởi vì với người sáng mắt, dù trang có im lặng thì họ vẫn thấy một spinner (biểu tượng xoay tròn) đang quay, một thanh progress bar đang chạy, hay ít nhất một hiệu ứng mờ trên nút bấm. Những tín hiệu thị giác này trấn an họ: "Hệ thống đang làm việc, cứ chờ đi."
Nhưng screen reader không đọc được spinner đang quay. Một cái vòng tròn CSS xoay đẹp mắt, với người mù, hoàn toàn vô hình. Nếu bạn không chủ động thông báo trạng thái "đang tải" và "đã tải xong" bằng cách mà công nghệ hỗ trợ có thể hiểu, thì người dùng khiếm thị sẽ rơi vào đúng cảnh "màn hình đứng im" mà chúng ta vừa mô tả ở đầu bài.
Đây là một trong những khoảng trống accessibility bị bỏ quên nhiều nhất, vì lập trình viên thường chỉ kiểm tra "spinner có hiện không" bằng mắt và cho rằng thế là đủ. Bài này sẽ dạy bạn cách làm cho mọi trạng thái chờ — từ spinner, skeleton loading, đến progress bar và trạng thái nút — trở nên "nói được" với công nghệ hỗ trợ, để không ai bị bỏ lại trong sự im lặng.
Khái niệm cốt lõi
Vấn đề gốc rễ: trạng thái động và sự im lặng
Web hiện đại thay đổi nội dung liên tục mà không tải lại trang (nhờ AJAX, JavaScript). Khi nội dung mới xuất hiện, screen reader cần biết để thông báo cho người dùng. Nhưng theo mặc định, screen reader chỉ đọc những gì người dùng đang focus (đang trỏ tới) hoặc điều hướng thủ công tới. Một khối "Đang tải..." xuất hiện ở góc màn hình mà người dùng không đứng ở đó thì sẽ bị lờ đi hoàn toàn.
Giải pháp là ARIA live regions (vùng trực tiếp ARIA) — một cơ chế cho phép bạn đánh dấu một vùng trên trang là "vùng động", để bất cứ khi nào nội dung bên trong thay đổi, screen reader sẽ tự động đọc lên mà không cần người dùng phải focus vào đó.
Live region — trái tim của loading state accessibility
Một live region đơn giản trông như sau:
<div role="status" aria-live="polite" class="loading-message">
Đang tải danh sách sản phẩm...
</div>
Có hai thuộc tính quan trọng:
aria-live: xác định mức độ khẩn cấp khi đọc.
polite (lịch sự): screen reader chờ đọc xong câu hiện tại rồi mới thông báo. Dùng cho hầu hết các trường hợp loading — vì trạng thái tải không khẩn cấp đến mức phải ngắt lời người dùng.
- assertive (quyết đoán): ngắt ngay lập tức mọi thứ để đọc. Chỉ dùng cho lỗi nghiêm trọng hoặc cảnh báo cần chú ý tức thì. Lạm dụng assertive cho loading sẽ khiến trải nghiệm rất khó chịu.role="status": đây là một "shortcut" (viết tắt). Nó tương đương vớiaria-live="polite"cộng thêm ngữ nghĩa "đây là vùng thông báo trạng thái". Với loading,role="status"là lựa chọn phổ biến và an toàn nhất.
Cơ chế "cả chu trình": bắt đầu VÀ kết thúc
Điểm mấu chốt mà nhiều người bỏ sót: bạn phải thông báo cả hai đầu của chu trình tải.
- Khi bắt đầu: "Đang tải..."
- Khi hoàn tất: "Đã tải xong 24 sản phẩm."
Progress bar — khi tiến trình có thể đo được
Khi tác vụ có tiến độ đo được (upload file, cài đặt, xử lý theo phần trăm), bạn nên dùng role="progressbar" với các thuộc tính giá trị:
<div role="progressbar"
aria-valuenow="60"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Đang tải lên hồ sơ">
</div>
aria-valuenow: giá trị hiện tại (cập nhật bằng JavaScript khi tiến trình chạy).aria-valuemin/aria-valuemax: khoảng giá trị.- Khi tiến trình không xác định được (indeterminate — không biết bao lâu, không có phần trăm), bạn bỏ
aria-valuenowđi; screen reader sẽ hiểu đây là tiến trình vô định.
Skeleton screen — cái bẫy accessibility
Skeleton screen (những khối xám placeholder nhấp nháy trước khi nội dung thật load về) rất thịnh hành. Nhưng với screen reader, nếu không xử lý đúng, chúng có thể bị đọc thành một mớ hỗn độn hoặc gây rối. Nguyên tắc: các khối skeleton thuần trang trí nên được ẩn khỏi công nghệ hỗ trợ bằng aria-hidden="true", và bạn thông báo trạng thái tải thật sự qua một live region riêng.
Tình huống thực tế
Tình huống 1: Sàn thương mại điện tử và cú nhấn "Xem thêm" im lặng
Một sàn thương mại điện tử lớn tại Việt Nam (giả định gọi là ShopViet) có trang danh mục với nút "Xem thêm sản phẩm". Người dùng sáng mắt nhấn nút, thấy spinner quay 1-2 giây, rồi 20 sản phẩm mới xuất hiện phía dưới — trải nghiệm mượt mà.
Nhưng trong một buổi kiểm thử với một người dùng khiếm thị dùng NVDA, đội ngũ QA phát hiện vấn đề nghiêm trọng: sau khi nhấn "Xem thêm", screen reader không đọc bất cứ điều gì. Người dùng ngồi chờ, không biết trang có phản hồi không, rồi nhấn nút thêm ba lần nữa. Kết quả là API bị gọi bốn lần, danh sách nhân đôi lộn xộn, và người dùng bỏ cuộc sau 40 giây loay hoay.
Nguyên nhân: spinner chỉ là hiệu ứng CSS, sản phẩm mới được chèn vào DOM nhưng không nằm trong bất kỳ live region nào.
Cách sửa: đội ngũ thêm một live region ẩn về mặt thị giác (nhưng screen reader vẫn đọc):
<div role="status" aria-live="polite" class="visually-hidden" id="load-status"></div>
Khi nhấn nút, họ đặt nội dung "Đang tải thêm sản phẩm...", và khi API trả về, họ cập nhật thành "Đã tải thêm 20 sản phẩm, tổng cộng 60 sản phẩm.". Ngoài ra, nút "Xem thêm" bị disabled trong lúc tải để chống nhấn lặp. Sau khi sửa, thời gian hoàn thành tác vụ của người dùng khiếm thị giảm từ 40 giây xuống còn 8 giây, và không còn tình trạng gọi API trùng lặp.
Bài học: một spinner đẹp không có nghĩa là trạng thái tải đã accessible. Luôn ghép spinner thị giác với một thông báo live region tương ứng.
Tình huống 2: Ứng dụng ví điện tử và thanh progress "đóng băng"
Một ví điện tử phổ biến ở Đông Nam Á (giả định gọi là PayNow) có tính năng xác minh danh tính (eKYC): người dùng chụp ảnh CMND/CCCD, rồi hệ thống xử lý mất khoảng 5-15 giây. Họ hiển thị một thanh progress bar chạy từ 0% đến 100%.
Vấn đề: thanh progress được vẽ hoàn toàn bằng <div> với chiều rộng thay đổi qua CSS, không có role hay thuộc tính ARIA nào. Người dùng khiếm thị chụp ảnh xong thì chỉ nghe screen reader im lặng suốt 15 giây. Nhiều người tưởng app bị treo và đóng ứng dụng, làm hỏng cả quy trình eKYC và khiến tỷ lệ hoàn tất xác minh của nhóm người dùng này thấp bất thường.
Cách sửa: đội ngũ chuyển thanh progress thành:
<div role="progressbar"
aria-valuenow="45"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Đang xác minh danh tính">
</div>
Nhưng họ nhận ra việc để screen reader đọc từng phần trăm ("45%, 46%, 47%...") lại quá ồn. Giải pháp thông minh: họ chỉ cập nhật aria-valuenow ở các mốc quan trọng (25%, 50%, 75%, hoàn tất) và thêm một live region riêng thông báo các bước có ý nghĩa: "Đang tải ảnh lên", "Đang phân tích", "Xác minh thành công". Điều này biến sự im lặng đáng sợ thành một chuỗi phản hồi rõ ràng, dễ chịu.
Bài học: progress bar cần role và thuộc tính đúng, nhưng đừng làm screen reader đọc từng con số một cách máy móc — hãy thông báo ở các mốc có ý nghĩa với con người.
Tình huống 3: Trang tin tức với skeleton loading gây nhiễu
Một tòa soạn báo điện tử triển khai skeleton screen cho trang chủ: hàng chục khối xám nhấp nháy tượng trưng cho tiêu đề, ảnh, đoạn tóm tắt bài viết đang tải. Trên máy tính để bàn, hiệu ứng rất mượt và hiện đại.
Tuy nhiên khi test với VoiceOver trên macOS, họ phát hiện screen reader đọc một tràng dài các phần tử rỗng và văn bản placeholder vô nghĩa (họ đã dùng chữ "Lorem ipsum" làm nội dung giả bên trong skeleton). Người dùng nghe một chuỗi "Lorem ipsum dolor... Lorem ipsum..." lặp đi lặp lại, hoàn toàn rối trí.
Cách sửa: họ bọc toàn bộ khối skeleton trong một container với aria-hidden="true" để ẩn khỏi công nghệ hỗ trợ, đồng thời thêm một live region đơn giản thông báo "Đang tải tin tức mới nhất..." và sau đó "Đã tải xong, 12 bài viết." Khi nội dung thật render, skeleton bị gỡ và các bài viết thật (không có aria-hidden) trở nên khả dụng bình thường.
Bài học: hiệu ứng loading mang tính trang trí phải được ẩn khỏi screen reader; đừng để nội dung giả lọt vào trải nghiệm nghe của người dùng.
Hướng dẫn từng bước
Đây là quy trình chuẩn để triển khai một loading state accessible cho một hành động bất kỳ (ví dụ: tải danh sách sau khi lọc).
Bước 1 — Đặt sẵn một live region trống trong DOM ngay từ đầu. Điều quan trọng: live region nên tồn tại trong DOM trước khi bạn chèn nội dung vào, không phải tạo mới rồi chèn cùng lúc. Nhiều screen reader không đọc nội dung nếu cả vùng live lẫn nội dung xuất hiện đồng thời.
<div role="status" aria-live="polite" class="visually-hidden" id="status"></div>
Bước 2 — Tạo class ẩn thị giác nhưng vẫn đọc được.
Đừng dùng display: none hay visibility: hidden — chúng ẩn khỏi cả screen reader. Dùng kỹ thuật "visually hidden":
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
Bước 3 — Khi hành động bắt đầu, cập nhật nội dung live region.
document.getElementById('status').textContent = 'Đang tải kết quả...';
Bước 4 — Vô hiệu hóa nút kích hoạt để chống nhấn lặp.
Đặt button.disabled = true (hoặc aria-disabled="true" nếu bạn cần nút vẫn nhận focus). Điều này ngăn tình huống gọi API trùng như ở ShopViet.
Bước 5 — Khi hoàn tất, cập nhật live region với kết quả cụ thể.
document.getElementById('status').textContent = 'Đã tải xong 24 kết quả.';
button.disabled = false;
Lưu ý: nếu văn bản mới giống hệt văn bản cũ, một số screen reader sẽ không đọc lại. Nếu bạn cần lặp cùng một thông báo, hãy xóa nội dung về rỗng trước rồi mới đặt lại sau một khoảng nhỏ.
Bước 6 — Với spinner thị giác, ẩn nó khỏi công nghệ hỗ trợ.
Spinner là trang trí; nó không nên được đọc. Đặt aria-hidden="true" cho phần tử spinner. Trạng thái đã được truyền tải qua live region rồi.
Bước 7 — Xử lý trường hợp lỗi.
Nếu tải thất bại, đừng để live region im lặng. Thông báo lỗi: "Không thể tải kết quả. Vui lòng thử lại.". Với lỗi, bạn có thể cân nhắc role="alert" (tương đương aria-live="assertive") để ngắt và thông báo ngay.
Lỗi thường gặp & mẹo
Lỗi 1 — Chỉ có spinner, không có thông báo. Đây là lỗi phổ biến nhất. Spinner CSS hoàn toàn vô hình với screen reader. Luôn ghép cặp spinner với một live region.
Lỗi 2 — Tạo live region và chèn nội dung cùng một lúc. Nếu bạn createElement một div role="status" rồi đặt text ngay và append vào trang, nhiều screen reader sẽ bỏ qua. Hãy để live region rỗng có sẵn trong DOM từ trước, chỉ cập nhật textContent.
Lỗi 3 — Dùng aria-live="assertive" cho mọi thứ. assertive ngắt lời người dùng ngay lập tức. Dùng nó cho loading thông thường sẽ khiến trải nghiệm giật cục, khó chịu. Quy tắc: loading dùng polite / role="status"; chỉ lỗi hoặc cảnh báo khẩn mới dùng assertive / role="alert".
Lỗi 4 — Chỉ thông báo "đang tải" mà quên "đã xong". Người dùng mắc kẹt trong chờ đợi mơ hồ. Luôn đóng vòng lặp bằng thông báo hoàn tất, tốt nhất là có con số cụ thể.
Lỗi 5 — Ẩn live region bằng display: none. Điều này ẩn khỏi cả screen reader, khiến thông báo không bao giờ được đọc. Dùng kỹ thuật visually-hidden ở trên.
Lỗi 6 — Progress bar đọc từng phần trăm liên tục. Quá ồn. Cập nhật aria-valuenow cho phần thị giác, nhưng chỉ thông báo qua live region ở các mốc có ý nghĩa.
Lỗi 7 — Skeleton screen chứa nội dung giả không được ẩn. Bọc skeleton trong aria-hidden="true".
Mẹo vàng: Sau khi code xong, hãy tự test bằng cách bật screen reader (NVDA trên Windows miễn phí, VoiceOver có sẵn trên Mac), nhắm mắt lại và thực hiện hành động. Nếu bạn không nghe thấy "đang tải" và "đã xong", thì người dùng của bạn cũng vậy. Đây là bài kiểm tra 30 giây quý giá hơn mọi lý thuyết.
Mẹo về thời điểm: Với các tác vụ rất nhanh (dưới ~500ms), việc thông báo "đang tải" rồi ngay lập tức "đã xong" có thể gây nhiễu. Cân nhắc chỉ thông báo kết quả cuối, hoặc dùng debounce để chỉ hiện trạng thái tải khi tác vụ thực sự kéo dài.
Bài tập thực hành
- Xây dựng một nút "Tải bình luận" accessible. Tạo một nút mà khi nhấn sẽ giả lập gọi API 2 giây (dùng
setTimeout). Yêu cầu: có live region thông báo "Đang tải bình luận...", nút bị disabled trong lúc tải, và khi xong thông báo "Đã tải xong X bình luận." Bật screen reader và kiểm chứng bạn nghe được đủ hai đầu chu trình.
- Chuyển đổi một progress bar "câm". Tìm hoặc tự viết một thanh progress chỉ dùng CSS width. Thêm
role="progressbar"cùng các thuộc tínharia-valuenow/min/maxvàaria-label. Viết JavaScript cập nhật giá trị, nhưng chỉ thông báo qua live region ở các mốc 25/50/75/100%.
- Sửa một skeleton screen. Lấy một layout skeleton bất kỳ (ví dụ ba khối card xám nhấp nháy). Bọc chúng trong
aria-hidden="true", thêm live region thông báo "Đang tải nội dung..." rồi "Đã tải xong." Test với VoiceOver/NVDA để chắc chắn không còn nghe thấy các khối placeholder rỗng.
- Thử thách nâng cao: xử lý trường hợp lỗi. Sửa bài tập 1 để 30% số lần gọi sẽ "thất bại", và khi thất bại, thông báo lỗi qua
role="alert". Quan sát sự khác biệt giữapolite(thành công) vàassertive/alert(lỗi) khi nghe bằng screen reader.
Tóm tắt
Trạng thái chờ là một trong những khoảng trống accessibility dễ bị bỏ quên nhất, vì lập trình viên thường chỉ kiểm tra bằng mắt. Điều cốt lõi cần nhớ:
- Spinner thị giác là vô hình với screen reader. Luôn ghép nó với một ARIA live region để truyền tải trạng thái bằng âm thanh.
- Dùng
role="status"(tứcaria-live="polite") cho loading thông thường; để dànhrole="alert"/assertivecho lỗi và cảnh báo khẩn. - Đóng trọn vòng lặp: thông báo cả "đang tải" lẫn "đã xong", và ưu tiên kết quả cụ thể có con số.
- Live region phải tồn tại rỗng trong DOM từ trước rồi mới cập nhật nội dung; ẩn nó bằng kỹ thuật
visually-hidden, không dùngdisplay: none. - Với tiến trình đo được, dùng
role="progressbar"và các thuộc tính giá trị, nhưng chỉ thông báo ở các mốc có ý nghĩa để tránh gây nhiễu. - Ẩn skeleton screen và mọi hiệu ứng trang trí bằng
aria-hidden="true". - Vô hiệu hóa nút kích hoạt trong lúc tải để chống nhấn lặp và gọi API trùng.