Mở đầu — vì sao bài này quan trọng
Modal (hay dialog) là một trong những thành phần giao diện phổ biến nhất trên web hiện đại: hộp thoại xác nhận xóa, form đăng nhập bật lên, giỏ hàng trượt ra, popup xác nhận thanh toán. Cũng chính vì nó xuất hiện ở khắp nơi, modal lại là "ổ chứa" nhiều lỗi accessibility nghiêm trọng bậc nhất. Một modal làm sai có thể khiến người dùng khiếm thị hoàn toàn không biết là có hộp thoại vừa mở ra, người dùng chỉ dùng bàn phím bị "mắc kẹt" không thoát được, hoặc tệ hơn là focus (con trỏ điều khiển) rơi vào khoảng trống vô định phía sau lớp phủ tối màu.
Hãy tưởng tượng bạn là một người dùng screen reader trên trang thanh toán của một sàn thương mại điện tử Việt Nam. Bạn nhấn "Đặt hàng", một modal xác nhận địa chỉ hiện ra — nhưng screen reader vẫn tiếp tục đọc nội dung trang phía sau vì lập trình viên chỉ dùng CSS để "làm mờ" nền chứ không thực sự khóa nó lại. Bạn nhấn Tab, con trỏ chạy vào các link trong footer bị che khuất, bạn gõ Enter và... vô tình mở một trang khác. Trải nghiệm này không chỉ khó chịu — nó khiến giao dịch thất bại và người dùng rời bỏ.
Trong bài này, chúng ta sẽ đi sâu vào cách xây dựng một modal đúng chuẩn accessibility: từ cấu trúc ARIA, quản lý focus, bẫy focus (focus trap), đến việc trả focus về đúng chỗ khi đóng. Đây là kiến thức mà bất kỳ designer hay developer nào cũng phải nắm nếu muốn sản phẩm thực sự dùng được cho mọi người.
Khái niệm cốt lõi
Một modal dialog được xem là "accessible" khi nó thỏa mãn năm điều kiện cốt lõi. Hãy nắm chắc năm điều này vì mọi thứ còn lại đều xoay quanh chúng.
1. Vai trò và nhãn đúng với ARIA
Modal cần được đánh dấu rõ ràng để trợ lý công nghệ (assistive technology) hiểu đây là một hộp thoại, chứ không phải một khối div bình thường. Cấu trúc ARIA cơ bản như sau:
<div role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-desc">
<h2 id="modal-title">Xác nhận xóa tài khoản</h2>
<p id="modal-desc">Hành động này không thể hoàn tác. Toàn bộ dữ liệu của bạn sẽ bị xóa vĩnh viễn.</p>
<button type="button">Hủy</button>
<button type="button">Xóa tài khoản</button>
</div>
Ý nghĩa từng thuộc tính:
role="dialog": báo cho screen reader biết đây là một hộp thoại. Nếu modal yêu cầu người dùng phản hồi khẩn cấp (ví dụ cảnh báo lỗi nghiêm trọng), dùngrole="alertdialog"thay vìdialog.aria-modal="true": cho biết modal này "chặn" tương tác với phần còn lại của trang. Đây là tín hiệu quan trọng để screen reader biết chỉ nên đọc nội dung bên trong modal.aria-labelledby: trỏ tớiidcủa tiêu đề, giúp screen reader đọc tên modal ngay khi mở. Nếu không có tiêu đề nhìn thấy được, dùngaria-labelvới chuỗi văn bản trực tiếp.aria-describedby: trỏ tới phần mô tả, giúp người dùng hiểu ngữ cảnh trước khi hành động.
2. Focus phải di chuyển vào modal khi mở
Khi modal mở ra, focus của bàn phím phải được đưa vào bên trong modal — thường là phần tử tương tác đầu tiên, hoặc chính container modal. Nếu bạn không làm điều này, người dùng bàn phím vẫn đang đứng ở nút vừa nhấn (ở nền phía sau), và họ hoàn toàn không biết modal đã xuất hiện.
Một nguyên tắc thực hành tốt: nếu modal mang tính phá hủy (như xóa dữ liệu), hãy đặt focus ban đầu vào nút an toàn nhất (như "Hủy") thay vì nút nguy hiểm, để tránh người dùng vô tình nhấn Enter và xóa nhầm.
3. Focus trap — bẫy focus bên trong modal
Đây là phần khó nhất và cũng bị làm sai nhiều nhất. Khi modal đang mở, người dùng nhấn Tab liên tục phải chỉ di chuyển giữa các phần tử bên trong modal, không bao giờ "thoát" ra nền phía sau. Khi tới phần tử cuối cùng và nhấn Tab, focus phải quay vòng về phần tử đầu tiên; khi ở phần tử đầu và nhấn Shift+Tab, focus phải nhảy về phần tử cuối.
4. Đóng bằng phím Escape và trả focus về đúng chỗ
Người dùng phải luôn đóng được modal bằng phím Escape. Và quan trọng không kém: khi modal đóng, focus phải quay về đúng phần tử đã kích hoạt nó (nút "Đặt hàng", "Sửa hồ sơ"...). Nếu không, người dùng screen reader sẽ bị "bỏ rơi" ở đầu trang và mất phương hướng hoàn toàn.
5. Ẩn nền khỏi screen reader
Khi modal mở, toàn bộ nội dung nền phía sau phải bị ẩn khỏi cây accessibility. Cách phổ biến là thêm inert (thuộc tính HTML hiện đại) vào phần tử chứa nội dung nền, hoặc aria-hidden="true" cho các anh em (sibling) của modal. Thuộc tính inert là lựa chọn tốt nhất hiện nay vì nó vừa ẩn khỏi screen reader vừa ngăn tương tác chuột và bàn phím.
Tình huống thực tế
Ví dụ 1: Modal xác nhận xóa của một startup fintech tại TP.HCM
Một startup fintech (giả định tên là "MoMoney") có tính năng cho phép người dùng xóa liên kết thẻ ngân hàng. Ban đầu, đội ngũ dùng một div với class="modal" được style bằng CSS, không có bất kỳ thuộc tính ARIA nào. Khi kiểm thử với NVDA (một screen reader miễn phí phổ biến), họ phát hiện: modal mở ra nhưng screen reader vẫn đọc trang danh sách thẻ phía sau, và người dùng không hề biết có hộp thoại xác nhận.
Đội ngũ đã sửa lại: thêm role="alertdialog" (vì đây là hành động phá hủy cần cảnh báo), aria-modal="true", gắn aria-labelledby vào tiêu đề "Bạn có chắc muốn xóa thẻ này?", đặt focus mặc định vào nút "Giữ lại" (nút an toàn), và thêm inert cho phần thân trang. Sau khi sửa, tỷ lệ người dùng nhấn nhầm nút "Xóa" giảm hẳn — không chỉ người khiếm thị mà cả người dùng bình thường cũng ít nhấn nhầm hơn vì luồng tương tác rõ ràng hơn.
Bài học rút ra: role="alertdialog" cộng với việc đặt focus vào nút an toàn là "combo" bảo vệ người dùng khỏi hành động phá hủy vô ý.
Ví dụ 2: Giỏ hàng trượt (slide-out cart) trên sàn TMĐT
Một sàn thương mại điện tử Đông Nam Á triển khai giỏ hàng dạng panel trượt từ bên phải. Về mặt kỹ thuật, nó cũng là một dạng modal. Vấn đề ban đầu: khi người dùng bàn phím nhấn Tab trong giỏ hàng, sau phần tử cuối cùng (nút "Thanh toán"), focus lại nhảy ra ngoài, xuống footer của trang bị lớp phủ che khuất. Người dùng "lạc" vào những link không nhìn thấy được, gõ Enter và bị chuyển trang bất ngờ.
Đội ngũ phát hiện họ đã quên focus trap. Họ thêm một hàm xử lý sự kiện keydown: khi nhấn Tab ở phần tử cuối, preventDefault() và đưa focus về phần tử đầu; khi Shift+Tab ở phần tử đầu, đưa focus về phần tử cuối. Đồng thời họ thêm nút đóng có aria-label="Đóng giỏ hàng" và cho phép nhấn Escape để đóng.
Kết quả kiểm thử cho thấy: thời gian hoàn tất một đơn hàng bằng bàn phím giảm khoảng 40%, và số phiên bị "kẹt" trong giỏ hàng gần như biến mất. Đáng chú ý, nhóm QA của họ dùng chính bàn phím (không dùng chuột) để kiểm thử — một mẹo đơn giản mà hiệu quả.
Bài học rút ra: bất kỳ panel nào "chặn" tương tác với phần còn lại của trang đều phải được xử lý như một modal đầy đủ, bao gồm focus trap và trả focus.
Ví dụ 3: Modal "khảo sát" tự bật lên gây bực bội
Một trang tin tức triển khai modal khảo sát tự động bật lên sau 30 giây. Modal này không có nút đóng rõ ràng, không phản hồi phím Escape, và tệ nhất là nó "cướp" focus giữa lúc người dùng đang đọc bài. Với người dùng screen reader, họ đang nghe đọc dở một đoạn văn thì đột nhiên bị kéo sang nội dung khảo sát mà không hiểu chuyện gì xảy ra.
Cách khắc phục: nhóm sản phẩm quyết định không tự bật modal cắt ngang trải nghiệm đọc; thay vào đó dùng một banner cố định phía dưới (không cướp focus). Nếu bắt buộc phải dùng modal, họ đảm bảo có tiêu đề rõ, nút đóng gắn aria-label, hỗ trợ Escape, và chỉ đưa focus vào modal khi người dùng chủ động mở nó.
Bài học rút ra: "accessible" không chỉ là kỹ thuật ARIA — nó còn là tôn trọng sự chú ý của người dùng. Một modal tự động cướp focus giữa chừng gây hại cho tất cả mọi người.
Hướng dẫn từng bước
Dưới đây là quy trình xây dựng một modal accessible hoàn chỉnh. Bạn có thể áp dụng dù dùng framework nào.
Bước 1 — Đánh dấu ngữ nghĩa. Bọc modal trong container với role="dialog" (hoặc role="alertdialog" cho cảnh báo), thêm aria-modal="true", và gắn aria-labelledby (bắt buộc phải có nhãn). Nếu có mô tả, thêm aria-describedby.
Bước 2 — Lưu lại phần tử kích hoạt. Ngay trước khi mở modal, lưu tham chiếu tới phần tử đang có focus (nút vừa được nhấn). Bạn sẽ cần nó để trả focus khi đóng.
const triggerElement = document.activeElement;
Bước 3 — Ẩn nền. Thêm inert vào phần tử chứa nội dung trang chính (thường là phần tử anh em của modal, không phải cha của modal), hoặc dùng aria-hidden="true" nếu cần hỗ trợ trình duyệt cũ. Đừng đặt inert lên phần tử bao cả modal.
Bước 4 — Chuyển focus vào modal. Sau khi hiển thị, đặt focus vào phần tử tương tác đầu tiên bên trong (hoặc nút an toàn nếu là hành động phá hủy). Với modal chỉ để đọc, có thể đặt tabindex="-1" lên container và focus vào chính nó.
Bước 5 — Cài focus trap. Lắng nghe sự kiện keydown. Với phím Tab, kiểm tra danh sách các phần tử focus được (button, link, input, phần tử có tabindex >= 0) và quay vòng focus ở hai đầu.
Bước 6 — Hỗ trợ đóng. Cho phép đóng bằng phím Escape, bằng nút đóng (có aria-label rõ ràng như "Đóng"), và tùy chọn bằng cách nhấn ra ngoài vùng modal (backdrop). Lưu ý: với alertdialog cảnh báo quan trọng, cân nhắc không cho đóng bằng nhấn backdrop để tránh mất dữ liệu ngoài ý muốn.
Bước 7 — Dọn dẹp khi đóng. Gỡ inert/aria-hidden khỏi nền, gỡ bộ lắng nghe sự kiện, và trả focus về triggerElement đã lưu ở Bước 2.
function closeModal() {
backgroundEl.removeAttribute('inert');
document.removeEventListener('keydown', handleKeydown);
triggerElement.focus();
}
Một lưu ý thực tế: nếu bạn dùng thẻ HTML <dialog> gốc cùng phương thức .showModal(), trình duyệt hiện đại đã tự lo phần focus trap, ẩn nền bằng inert ngầm, và hỗ trợ Escape sẵn. Đây là lựa chọn nên ưu tiên cho dự án mới. Tuy nhiên bạn vẫn phải tự lo aria-labelledby và việc trả focus vẫn cần kiểm tra kỹ trên từng trình duyệt.
Lỗi thường gặp & mẹo
Lỗi 1 — Quên focus trap. Đây là lỗi số một. Modal trông đẹp bằng chuột nhưng người dùng bàn phím "lọt" ra nền. Mẹo: luôn kiểm thử bằng cách chỉ dùng phím Tab, Shift+Tab, Escape.
Lỗi 2 — Không trả focus khi đóng. Người dùng đóng modal xong bị "vứt" về đầu trang. Luôn lưu và trả focus về phần tử kích hoạt.
Lỗi 3 — Dùng div hoặc span làm nút đóng mà không có role. Dấu "×" đặt trong <span onclick> không được screen reader nhận diện là nút và không nhấn được bằng bàn phím. Dùng <button> thật với aria-label="Đóng".
Lỗi 4 — Chỉ ẩn nền bằng CSS. Lớp phủ tối màu (opacity, background) chỉ ẩn về mặt thị giác. Screen reader vẫn đọc nền. Phải dùng inert hoặc aria-hidden.
Lỗi 5 — Thiếu nhãn. Modal không có aria-labelledby hay aria-label khiến screen reader chỉ đọc "dialog" mà không biết là dialog gì.
Lỗi 6 — Lạm dụng aria-modal="true" nhưng không thực sự khóa nền. Thuộc tính này chỉ là "lời hứa" với screen reader; bạn vẫn phải tự ẩn nền bằng inert. Đặt aria-modal mà không khóa nền là tự tạo mâu thuẫn.
Mẹo vàng: nếu có thể, hãy dùng một thư viện đã được kiểm chứng về accessibility (như Radix UI, Headless UI, hoặc thẻ <dialog> gốc) thay vì tự viết từ đầu. Việc tự viết modal accessible đúng chuẩn tốn nhiều công hơn bạn tưởng, và rất dễ bỏ sót một tình huống biên.
Bài tập thực hành
Bài tập 1 — Audit một modal có sẵn. Chọn một website Việt Nam bất kỳ có modal (đăng nhập, giỏ hàng, popup khuyến mãi). Chỉ dùng bàn phím, hãy kiểm tra: (a) focus có vào modal khi mở không? (b) Tab có bị bẫy bên trong không? (c) Escape có đóng được không? (d) Đóng xong focus quay về đâu? Ghi lại từng lỗi bạn tìm thấy.
Bài tập 2 — Sửa một modal. Lấy đoạn div modal thô (chỉ có CSS) và bổ sung đầy đủ: role="dialog", aria-modal, aria-labelledby, focus trap, hỗ trợ Escape, và trả focus. So sánh trải nghiệm trước và sau bằng screen reader (NVDA trên Windows hoặc VoiceOver trên macOS).
Bài tập 3 — Viết checklist. Tạo một checklist 7 điểm cho đội của bạn để mọi modal mới phải vượt qua trước khi lên production. Đây sẽ là tài liệu bạn tái sử dụng trong các bài sau về testing và design system.
Tóm tắt
Modal accessible không phải là chuyện "thêm vài thuộc tính ARIA cho có". Nó là một hệ thống hoàn chỉnh gồm năm trụ cột: đánh dấu ngữ nghĩa đúng (role="dialog" / alertdialog, aria-modal, nhãn rõ ràng), chuyển focus vào modal khi mở, bẫy focus bên trong (focus trap), hỗ trợ đóng bằng Escape kèm trả focus về đúng chỗ, và ẩn nền khỏi screen reader bằng inert.
Ba tình huống thực tế — modal xóa của fintech, giỏ hàng trượt của sàn TMĐT, và popup khảo sát gây bực bội — cho thấy cùng một bài học: modal làm sai gây hại cho tất cả mọi người, không chỉ người khuyết tật. Cách kiểm thử nhanh và rẻ nhất là bỏ chuột ra, chỉ dùng bàn phím và một screen reader miễn phí. Nếu bạn tự tin dùng modal của mình chỉ bằng phím, bạn đã đi được 80% chặng đường. Và khi có thể, hãy ưu tiên thẻ <dialog> gốc hoặc thư viện đã được kiểm chứng để tránh phải tự xử lý mọi tình huống biên.