Mở đầu — vì sao bài này quan trọng
Bạn vừa bấm nút "Lưu" trên trang cài đặt tài khoản. Góc dưới bên phải màn hình, một ô nhỏ màu xanh trượt lên: "Đã lưu thay đổi." Ba giây sau nó tự biến mất. Với người dùng nhìn được, đây là một trải nghiệm gần như hoàn hảo — bạn nhận được phản hồi tức thì mà không bị chặn công việc, không phải bấm "OK" để đóng gì cả.
Nhưng hãy tưởng tượng một người dùng khiếm thị đang dùng trình đọc màn hình (screen reader) như NVDA hoặc VoiceOver. Con trỏ ảo của họ đang nằm ở nút "Lưu". Cái toast màu xanh kia xuất hiện ở một góc hoàn toàn khác của DOM, hiển thị 3 giây rồi biến mất. Trình đọc màn hình không nói gì cả. Người dùng bấm Lưu, chờ đợi, rồi tự hỏi: "Nó đã lưu chưa? Hay bị lỗi? Mình có phải bấm lại không?" Họ hoàn toàn mù mờ về kết quả hành động của chính mình.
Đây chính là nghịch lý của toast notification. Nó được thiết kế để không gây phiền — không chặn, không cần tương tác, tự biến mất. Nhưng chính đặc tính "không gây phiền" ấy khiến nó dễ trở nên vô hình với công nghệ hỗ trợ. Toast là một trong những component mà designer và developer hay làm sai nhất về accessibility, vì nó trông đơn giản đến mức người ta không nghĩ có gì để làm.
Trong bài này, chúng ta sẽ mổ xẻ cách làm cho toast và notification vừa đẹp, vừa "không phiền", vừa được screen reader thông báo đúng lúc, đúng mức độ khẩn cấp — mà không đánh cắp focus của người dùng. Đây là kỹ năng rất thực dụng: gần như mọi sản phẩm web bạn làm đều có toast.
Khái niệm cốt lõi
Toast là gì và tại sao nó "khó"
Toast (còn gọi là snackbar trong Material Design) là một thông báo ngắn, xuất hiện tạm thời, không chặn luồng làm việc và thường tự đóng sau vài giây. Nó khác với modal/dialog (Bài 21) ở chỗ: modal chặn người dùng và đánh cắp focus, buộc phải xử lý; toast thì không.
Chính vì toast không đánh cắp focus, screen reader không tự động chuyển tới đọc nó. Screen reader chỉ đọc những gì con trỏ ảo đang trỏ tới, hoặc những vùng được đánh dấu đặc biệt để "công bố" (announce) khi nội dung thay đổi. Vùng đặc biệt đó gọi là live region.
ARIA live regions — trái tim của toast a11y
Live region là một vùng DOM mà bạn khai báo cho screen reader biết: "Khi nội dung bên trong vùng này thay đổi, hãy đọc nó lên, kể cả khi người dùng không đang focus vào đây." Có ba thuộc tính chính:
aria-live: nhận giá trịpolite,assertivehoặcoff.
polite: screen reader đợi đến khi người dùng ngừng thao tác rồi mới đọc. Dùng cho thông báo không khẩn cấp — "Đã lưu", "Đã thêm vào giỏ hàng".
- assertive: ngắt ngang mọi thứ screen reader đang nói để đọc ngay. Chỉ dùng cho thông báo khẩn cấp thật sự — "Mất kết nối mạng", "Lỗi thanh toán".
- off: không đọc (mặc định của mọi element).aria-atomic:truenghĩa là đọc toàn bộ nội dung vùng đó, không chỉ phần vừa thay đổi. Với toast thường đểtrueđể đọc trọn câu.
aria-relevant: kiểm soát loại thay đổi nào được công bố (thêm, xóa, sửa text). Ít khi cần chỉnh, mặc định đã hợp lý.
role="status"tương đươngaria-live="polite"— cho thông báo thường.role="alert"tương đươngaria-live="assertive"— cho cảnh báo khẩn.
Nguyên tắc vàng: live region phải tồn tại trước, nội dung đổ vào sau
Đây là điểm mà 80% bug toast xuất phát. Screen reader chỉ công bố khi nó phát hiện sự thay đổi trong một vùng đã có sẵn thuộc tính live. Nếu bạn tạo mới cả cái toast (bao gồm cả aria-live) rồi chèn vào DOM cùng một lúc, nhiều screen reader sẽ không đọc vì tại thời điểm vùng đó xuất hiện, nội dung đã "đầy đủ" ngay từ đầu — không có "sự thay đổi" nào để phát hiện.
Cách làm đúng: đặt sẵn một container rỗng có aria-live (hoặc role="status"/role="alert") ngay khi trang tải xong. Khi cần hiện toast, bạn chỉ đổ text vào container đã có sẵn đó. Lúc này screen reader phát hiện thay đổi và đọc lên.
Thời lượng auto-dismiss và mâu thuẫn với WCAG
Toast tự biến mất sau 3-5 giây là chuẩn thị giác. Nhưng WCAG 2.2 tiêu chí 2.2.1 Timing Adjustable cảnh báo: người dùng có khuyết tật vận động, nhận thức, hoặc dùng công nghệ hỗ trợ cần nhiều thời gian hơn để đọc/thao tác. Một toast chứa nút "Hoàn tác" (Undo) mà biến mất sau 4 giây là bẫy: người khiếm thị vừa nghe xong câu thông báo thì nút đã biến mất, không kịp bấm.
Nguyên tắc: nếu toast chỉ để thông báo (không có action), auto-dismiss là chấp nhận được, nhưng đừng quá ngắn. Nếu toast chứa action (nút Undo, nút "Xem chi tiết"), bạn nên: (a) không tự đóng, hoặc (b) tạm dừng đếm ngược khi người dùng hover/focus vào toast, và cho phép đóng thủ công.
Tình huống thực tế
Ví dụ 1 — Tiki và cái toast "Đã thêm vào giỏ hàng" câm lặng
Giả sử đội frontend của một sàn thương mại điện tử lớn như Tiki triển khai nút "Thêm vào giỏ". Mỗi lần bấm, một snackbar xanh hiện góc dưới: "Đã thêm 1 sản phẩm vào giỏ hàng". Đội QA thử bằng chuột — hoàn hảo. Nhưng khi một tester khiếm thị dùng NVDA kiểm tra, anh bấm "Thêm vào giỏ" liên tục 4 lần cho một sản phẩm, vì NVDA không đọc gì. Anh tưởng nút hỏng. Kết quả: giỏ hàng có 4 món trùng, và trải nghiệm gây bực bội.
Nguyên nhân kỹ thuật: mỗi lần bấm, code tạo mới toàn bộ phần tử <div class="toast" role="status">Đã thêm...</div> rồi appendChild vào body. Vì role="status" được thêm cùng lúc với nội dung, NVDA coi đó là nội dung tĩnh, không phải thay đổi trong live region đã tồn tại.
Cách sửa: đội đặt sẵn một container <div id="toast-region" role="status" aria-live="polite" aria-atomic="true"></div> rỗng trong layout gốc từ đầu. Mỗi lần thêm giỏ, họ chỉ set textContent của container này. Ngay lập tức NVDA đọc "Đã thêm 1 sản phẩm vào giỏ hàng" mỗi lần bấm.
Bài học: Toast không đọc được thường không phải vì thiếu aria-live, mà vì live region không tồn tại trước khi nội dung thay đổi. Kiến trúc "một container rỗng cố định, đổ text vào" là chuẩn công nghiệp.
Ví dụ 2 — App ngân hàng và cái toast lỗi bị "polite"
Một ngân hàng số ở Việt Nam (giả định là "VBank Digital") có màn hình chuyển khoản. Khi giao dịch thất bại vì sai số dư hoặc timeout, họ hiện toast đỏ: "Giao dịch thất bại, vui lòng thử lại." Họ dùng role="status" (polite) cho tất cả toast để "cho thống nhất codebase".
Vấn đề phát sinh: người dùng khiếm thị đang dùng VoiceOver, sau khi bấm "Xác nhận chuyển 5.000.000đ", VoiceOver đang đọc dở một nội dung khác trên form. Vì toast lỗi là polite, nó đợi cho đến khi người dùng ngừng thao tác — nhưng người dùng lại tiếp tục thao tác (chuyển focus, bấm nút khác), nên câu "Giao dịch thất bại" bị hoãn rất lâu hoặc bị nuốt mất. Người dùng tưởng đã chuyển thành công, rời trang. Với ngân hàng, đây là lỗi nghiêm trọng về niềm tin và có thể dẫn tới khiếu nại.
Cách sửa: phân loại theo mức độ khẩn cấp. Toast thông báo thành công/thông thường dùng role="status" (polite). Toast lỗi và cảnh báo khẩn dùng role="alert" (assertive) để ngắt ngang và đọc ngay. Đội cũng bổ sung: toast lỗi không tự đóng, người dùng phải chủ động đóng, đảm bảo họ chắc chắn nhận được thông tin.
Bài học: "Cho thống nhất" bằng cách gán một mức độ live cho mọi toast là sai. Mức độ khẩn cấp của live region phải khớp với ý nghĩa nghiệp vụ. Lạm dụng assertive gây khó chịu (ngắt ngang liên tục), nhưng dùng polite cho lỗi nghiêm trọng thì nguy hiểm.
Ví dụ 3 — Toast "Undo" biến mất quá nhanh trên Grab-style app
Một ứng dụng giao đồ ăn kiểu Grab/ShopeeFood có tính năng: người dùng vuốt để xóa một địa chỉ đã lưu, xuất hiện toast "Đã xóa địa chỉ. [Hoàn tác]" tự đóng sau 4 giây. Với người dùng thường, 4 giây đủ để bấm Hoàn tác. Nhưng một người dùng lớn tuổi bị run tay (khuyết tật vận động nhẹ) và một người dùng khiếm thị đều phản ánh: đến lúc họ định vị được nút "Hoàn tác" thì toast đã biến mất, địa chỉ mất vĩnh viễn.
Cách sửa theo WCAG 2.2.1: (1) tăng thời gian mặc định lên và quan trọng hơn (2) tạm dừng đếm ngược khi toast được hover hoặc nhận focus — người dùng bàn phím Tab tới toast thì bộ đếm dừng, họ có thời gian vô hạn để quyết định. (3) Với screen reader, đảm bảo nút Hoàn tác nằm trong luồng focus (focusable, có label rõ) để người khiếm thị Tab tới được. Sau khi sửa, tỉ lệ "xóa nhầm không hoàn tác được" giảm rõ rệt trong các phiên test.
Bài học: Toast có action là một trường hợp đặc biệt. Auto-dismiss + action là công thức của bẫy accessibility. Pause-on-hover/focus là giải pháp tinh tế giữ được tính "không phiền" mà vẫn công bằng với người cần thêm thời gian.
Hướng dẫn từng bước
Đây là quy trình xây một hệ thống toast accessible từ đầu:
Bước 1 — Đặt live region cố định trong layout gốc. Ngay trong template chính (ví dụ layout _app hoặc base.html), thêm sẵn hai container rỗng:
<div id="toast-polite" role="status" aria-live="polite" aria-atomic="true"
class="sr-only-visually-positioned"></div>
<div id="toast-assertive" role="alert" aria-live="assertive" aria-atomic="true"
class="sr-only-visually-positioned"></div>
Hai vùng: một cho thông báo thường (polite), một cho khẩn cấp (assertive). Chúng tồn tại từ khi trang tải, luôn rỗng cho tới khi cần.
Bước 2 — Tách phần "hiển thị" và phần "công bố cho screen reader". Toast nhìn thấy được (hộp màu, icon, animation) là một thứ. Text được đổ vào live region để screen reader đọc là một thứ khác. Bạn có thể để chính hộp toast là live region, nhưng cách an toàn và dễ kiểm soát nhất là dùng một live region riêng và đổ text vào đó khi hiện toast.
Bước 3 — Khi hiện toast, đổ text vào đúng region theo mức độ. Thông báo thường (thành công, thông tin) → set textContent của #toast-polite. Lỗi/cảnh báo khẩn → set #toast-assertive. Mẹo: xóa text về rỗng rồi mới đổ text mới sau một khoảng ngắn (dùng một tick/timeout nhỏ) để đảm bảo screen reader phát hiện thay đổi ngay cả khi hai toast liên tiếp có nội dung giống nhau.
Bước 4 — Xử lý auto-dismiss có điều kiện. Nếu toast không có action: cho tự đóng sau khoảng thời gian hợp lý (tối thiểu vài giây, đủ đọc). Nếu toast có action hoặc là lỗi quan trọng: không tự đóng, hoặc pause-on-hover/focus, và luôn có nút đóng (nút X) có aria-label="Đóng thông báo".
Bước 5 — Đảm bảo action trong toast tiếp cận được bằng bàn phím. Nút "Hoàn tác", nút "Xem chi tiết" phải là <button> hoặc <a> thật, focusable, có label rõ ràng, và nằm trong thứ tự Tab hợp lý. (Xem thêm Bài 20 về buttons vs links và Bài 11 về keyboard navigation.)
Bước 6 — Đừng đưa quá nhiều thông tin vào toast; đừng dùng toast cho nội dung quan trọng lâu dài. Toast thoáng qua. Nếu là thông tin người dùng cần đọc kỹ hoặc quay lại xem, hãy dùng thành phần khác (banner cố định, trang lỗi). Toast chỉ nên là xác nhận ngắn.
Bước 7 — Test với screen reader thật. Bật NVDA (Windows) hoặc VoiceOver (Mac), thực hiện hành động sinh toast, và nghe. Đây là bước không thể bỏ (chi tiết ở Bài 39).
Lỗi thường gặp & mẹo
Lỗi 1 — Tạo cả live region lẫn nội dung cùng lúc. Như đã phân tích, screen reader sẽ không đọc. Luôn để region tồn tại trước, đổ nội dung sau.
Lỗi 2 — Dùng aria-live="assertive" cho mọi thứ. Assertive ngắt ngang mọi thứ screen reader đang nói. Nếu mọi toast đều assertive, người dùng bị ngắt liên tục, rất khó chịu và dễ bỏ sót thông tin quan trọng thật sự. Chỉ dùng assertive cho lỗi/khẩn cấp.
Lỗi 3 — Chỉ dùng màu để phân biệt loại toast. Toast thành công màu xanh, lỗi màu đỏ — người mù màu (Bài 10) không phân biệt được. Luôn kèm icon có hình dạng khác nhau và/hoặc text tiền tố ("Lỗi:", "Thành công:").
Lỗi 4 — Toast chồng nhau, đọc loạn. Khi nhiều toast xuất hiện dồn dập (ví dụ upload nhiều file), screen reader đọc chồng chéo. Mẹo: gom thông báo lại ("Đã tải lên 3 tệp") thay vì bắn 3 toast riêng, hoặc đưa vào hàng đợi (queue) và đọc tuần tự.
Lỗi 5 — Toast biến mất mang theo action quan trọng. Không bao giờ auto-dismiss một toast chứa hành động không thể khôi phục. Pause-on-hover/focus, hoặc bỏ auto-dismiss.
Lỗi 6 — Contrast không đủ. Toast thường có nền màu đậm chữ trắng hoặc ngược lại; kiểm tra tỉ lệ tương phản đạt tối thiểu 4.5:1 cho text thường (Bài 9). Toast trong suốt/mờ dễ vi phạm.
Mẹo hay: Với các app dùng framework hiện đại, hãy đóng gói toàn bộ logic này vào một component <Toaster> hoặc một hàm showToast(message, { type, duration, action }) duy nhất, trong đó accessibility được xử lý sẵn. Như vậy toàn đội chỉ gọi một API, không ai vô tình làm sai. Nhiều thư viện (react-hot-toast, Sonner, Radix Toast) đã build sẵn live region đúng chuẩn — nếu dùng, hãy đọc kỹ tài liệu và vẫn test lại thay vì tin tuyệt đối.
Bài tập thực hành
- Kiểm toán một toast có sẵn. Chọn một trang web quen thuộc (giỏ hàng của một sàn TMĐT Việt Nam chẳng hạn). Bật NVDA hoặc VoiceOver, thực hiện một hành động sinh toast ("thêm vào giỏ", "lưu"). Ghi lại: screen reader có đọc không? Đọc đúng nội dung không? Đọc ngay hay trễ? Rút ra chẩn đoán.
- Xây live region đúng. Tạo một trang HTML nhỏ có nút "Lưu". Đặt sẵn
<div role="status" aria-live="polite" aria-atomic="true">rỗng. Viết JS để khi bấm, đổ text "Đã lưu lúc [giờ hiện tại]" vào region đó. Test bằng screen reader, xác nhận nó đọc mỗi lần bấm (kể cả bấm liên tục — nhớ mẹo xóa-rồi-đổ ở Bước 3).
- Phân loại mức độ. Viết một bảng liệt kê 8 loại toast trong một app bạn từng làm/dùng (thành công, lỗi mạng, lỗi validate form, thông báo có bản cập nhật, xóa có Undo...). Với mỗi loại, quyết định:
politehayassertive? Có auto-dismiss không? Có pause-on-focus không? Giải thích lý do.
- Sửa bẫy Undo. Lấy tình huống Ví dụ 3, viết pseudo-code cho cơ chế pause-on-hover/focus: khi toast nhận
mouseenterhoặcfocusinthì dừng timer, khimouseleave/focusoutthì tiếp tục đếm.
Tóm tắt
Toast là component "trông đơn giản mà dễ sai". Điểm mấu chốt cần nhớ:
- Toast không đánh cắp focus (đó là ưu điểm), nên screen reader không tự đọc nó — bạn phải dùng ARIA live region để công bố.
- Live region phải tồn tại trước, nội dung đổ vào sau. Kiến trúc chuẩn: một container rỗng cố định, chỉ set text khi cần.
- Phân loại mức độ:
role="status"/aria-live="polite"cho thông báo thường,role="alert"/aria-live="assertive"cho lỗi và cảnh báo khẩn. Đừng lạm dụng assertive. - Auto-dismiss là bẫy khi toast chứa action hoặc lỗi quan trọng: dùng pause-on-hover/focus, có nút đóng, hoặc bỏ auto-dismiss (WCAG 2.2.1).
- Không chỉ dựa vào màu; đảm bảo contrast; action phải focusable bằng bàn phím; gom thông báo dồn dập; và luôn test bằng screen reader thật.