Product Management
Đăng nhập
ESC

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

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

Bài 11 — Keyboard Navigation Patterns

Đã đọc 0/17 mục · còn khoảng 14 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 thử một thí nghiệm nhỏ ngay bây giờ: cất chuột (hoặc trackpad) qua một bên, rồi mở trang web bạn đang xây dựng và cố gắng hoàn thành một tác vụ — đăng nhập, thêm sản phẩm vào giỏ hàng, gửi form — mà chỉ dùng bàn phím. Với đa số sản phẩm ở Việt Nam mà tôi từng audit, người thử nghiệm sẽ bị "kẹt" trong vòng chưa đến 30 giây: focus biến mất không biết đang ở đâu, có nút bấm được bằng chuột nhưng phím Tab không tới được, hoặc mở một menu ra rồi không thoát ra nổi.

Keyboard navigation — điều hướng bằng bàn phím — là xương sống của khả năng truy cập web. Lý do rất thực tế:

  • Người khiếm khuyết vận động (motor impairment) thường không dùng được chuột. Một người bị run tay do Parkinson, liệt một bên sau tai biến, hay dùng thiết bị hỗ trợ như switch, sip-and-puff, eye-tracking — tất cả đều "nói chuyện" với trình duyệt thông qua giao diện bàn phím. Nếu web của bạn không điều hướng được bằng bàn phím, họ không dùng được, chấm hết.
  • Người dùng screen reader (trình đọc màn hình) điều hướng gần như hoàn toàn bằng bàn phím. Chuột với họ vô nghĩa vì họ không nhìn thấy con trỏ.
  • Power user — lập trình viên, nhân viên nhập liệu, kế toán — chuộng bàn phím vì tốc độ. Một nhân viên chăm sóc khách hàng xử lý 200 ticket/ngày mà phải rời tay khỏi bàn phím để rê chuột mỗi lần thì hiệu suất giảm thấy rõ.
Điểm mấu chốt bạn cần nhớ suốt bài: nếu một chức năng làm được bằng chuột thì nó BẮT BUỘC phải làm được bằng bàn phím (đây chính là tiêu chí WCAG 2.1.1 Keyboard). Bài này tập trung vào các pattern điều hướng — thứ tự Tab, các phím tương tác chuẩn, bẫy focus, và cách bố cục để dòng di chuyển của bàn phím trở nên logic. (Cách hiển thị focus ra sao thì Bài 12 sẽ đào sâu, còn kỹ thuật screen reader nằm ở Bài 13 — ở đây ta chỉ chạm tới mức đủ hiểu.)

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

Focus và "focus ring"

Tại một thời điểm, chỉ có một phần tử trên trang đang "được focus" — tức đang nhận input từ bàn phím. Trình duyệt vẽ một đường viền quanh nó (focus ring) để người dùng biết mình đang ở đâu. Khi bạn nhấn Enter hay Space, hành động sẽ tác động lên chính phần tử đang focus đó. Không thấy focus ở đâu = không biết mình đang đứng chỗ nào = lạc lối hoàn toàn.

Thứ tự Tab (tab order) và DOM order

Phím Tab di chuyển focus tới phần tử tương tác kế tiếp; Shift + Tab đi ngược lại. Mặc định, thứ tự này bám theo thứ tự xuất hiện trong DOM (thứ tự bạn viết HTML), không phải theo vị trí hiển thị trên màn hình. Đây là nguồn gốc của vô số lỗi: bạn dùng CSS (flexbox order, position: absolute, grid) để đẩy một nút lên góc trên, nhưng trong DOM nó vẫn nằm cuối trang — kết quả là focus nhảy loạn xạ, không khớp với thứ tự người dùng nhìn thấy.

Phần tử focus được và không focus được

  • Tương tác được sẵn (natively focusable): <a href>, <button>, <input>, <select>, <textarea>. Chúng vào tab order một cách tự nhiên và có sẵn hành vi bàn phím đúng chuẩn.
  • Không focus được sẵn: <div>, <span>, <p>, <img>... Nếu bạn biến một <div> thành nút bấm (điều nên tránh — xem Bài 20), bạn phải tự thêm tabindex="0" và tự viết xử lý phím. Đây là lý do lớn nhất khiến "web đẹp mà không bấm được bằng bàn phím".

tabindex — con dao hai lưỡi

  • tabindex="0": đưa một phần tử vốn không focus được vào tab order, đúng vị trí theo DOM. Dùng đúng lúc thì hữu ích.
  • tabindex="-1": phần tử KHÔNG vào tab order (Tab không tới), nhưng có thể focus bằng JavaScript (element.focus()). Cực kỳ quan trọng để quản lý focus khi mở modal, chuyển trang SPA, nhảy tới thông báo lỗi.
  • tabindex="1" trở lên (số dương): gần như luôn là sai lầm. Nó ép một thứ tự tab tùy tiện lên toàn trang, phá vỡ dòng chảy tự nhiên và cực kỳ khó bảo trì. Quy tắc vàng: đừng bao giờ dùng tabindex dương.

Phím tương tác chuẩn theo từng loại component

Người dùng bàn phím mang theo "kỳ vọng" từ desktop. Tuân thủ chuẩn (theo ARIA Authoring Practices Guide) giúp họ không phải học lại:

  • Tab / Shift+Tab: di chuyển giữa các thành phần (widget).
  • Enter: kích hoạt link, submit form, bấm nút.
  • Space: bấm nút, tick checkbox, mở dropdown.
  • Phím mũi tên (Arrow): di chuyển bên trong một widget phức hợp — chọn item trong menu, chuyển tab, di chuyển trong radio group, danh sách, lịch.
  • Esc: đóng modal, hủy dropdown, thoát khỏi một tương tác.
  • Home / End: nhảy về đầu / cuối trong list, menu, hoặc ô nhập liệu.
Một nguyên tắc thiết kế quan trọng nằm sau đây: "Tab để nhảy giữa các widget, mũi tên để di chuyển trong widget". Một menu 20 mục KHÔNG nên bắt người dùng Tab 20 lần — họ Tab một lần vào menu, rồi dùng mũi tên bên trong. Đây gọi là roving tabindex (chỉ một item trong nhóm có tabindex="0", phần còn lại tabindex="-1", và JS chuyển "quyền focus" đó theo phím mũi tên).

Focus trap có chủ đích vs. bẫy focus vô ý

Có hai mặt của cùng một khái niệm:

  • Bẫy focus vô ý (keyboard trap — lỗi): người dùng Tab vào một chỗ (thường là widget của bên thứ ba, video player, quảng cáo) rồi không Tab ra được. WCAG 2.1.2 cấm điều này.
  • Focus trap có chủ đích (đúng): khi một modal đang mở, focus PHẢI bị giữ bên trong modal đó — Tab tới phần tử cuối rồi vòng lại phần tử đầu — và Esc để đóng, focus quay về nút đã mở nó. (Chi tiết modal ở Bài 21; ở đây bạn chỉ cần phân biệt "giữ focus có mục đích" khác với "nhốt focus vì lỗi".)

Skip link

Một liên kết ẩn (hiện ra khi được focus) ở đầu trang, thường là "Bỏ qua tới nội dung chính". Nó cho người dùng bàn phím nhảy vọt qua thanh menu 30 mục để tới nội dung ngay, thay vì phải Tab 30 lần trên mỗi trang. Đây là một trong những cải thiện rẻ và hiệu quả nhất.

Tình huống thực tế

Ví dụ 1 — Sàn thương mại điện tử: nút "Thêm vào giỏ" bấm được bằng chuột nhưng không bằng bàn phím

Một đội phát triển tại một sàn TMĐT ở TP.HCM (giả định, nhưng lấy từ mẫu bug tôi gặp nhiều lần) làm mới trang chi tiết sản phẩm. Designer muốn nút "Thêm vào giỏ" trông thật đẹp nên dev dựng nó bằng <div class="btn" onclick="addToCart()">. Trên desktop dùng chuột, mọi thứ hoàn hảo. Nhưng khi một khách hàng khiếm thị dùng screen reader thử mua hàng, phím Tab lướt qua nút đó như thể nó không tồn tại — vì <div> không nằm trong tab order. Anh ấy nghe hết mô tả sản phẩm rồi… không cách nào thêm vào giỏ.

Con số thực tế: sau khi đội đo lại, họ phát hiện 11 phần tử tương tác trên trang sản phẩm được dựng bằng <div>/<span> với onclick, và không một cái nào tới được bằng bàn phím. Cách sửa đơn giản đến bất ngờ: đổi tất cả sang <button>. Chỉ một thay đổi này, phím Tab tới được, Enter/Space kích hoạt được, screen reader đọc đúng "button", tất cả miễn phí nhờ hành vi native.

Bài học: đừng tái tạo nút bấm bằng <div>. Dùng phần tử ngữ nghĩa đúng thì khả năng điều hướng bàn phím đến "kèm sẵn". Mỗi lần bạn viết onclick trên một thẻ không tương tác, hãy tự hỏi "phím Tab có tới đây không?".

Ví dụ 2 — Ngân hàng số: thứ tự Tab lộn xộn vì CSS

Một ngân hàng số ở Đông Nam Á thiết kế màn hình chuyển khoản với bố cục hai cột. Trên Figma và trên màn hình, thứ tự mắt đọc là: Ô "Tài khoản nguồn" → "Tài khoản đích" → "Số tiền" → "Nội dung" → nút "Xác nhận". Nhưng vì lý do responsive, dev dùng flex-direction và order để sắp lại các block, khiến DOM order thực sự là: Số tiền → Nội dung → Tài khoản nguồn → Tài khoản đích → Xác nhận.

Hậu quả: một khách hàng lớn tuổi dùng bàn phím Tab vào form, con trỏ nhảy thẳng xuống ô "Số tiền" trước cả khi chọn tài khoản, rồi lại vọt lên trên. Trong một phiên test nội bộ, 4/5 người dùng bàn phím nhập nhầm số tiền vào sai ô, và một người suýt xác nhận giao dịch khi chưa điền đủ. Với sản phẩm tài chính, đây không chỉ là bất tiện — đó là rủi ro mất tiền.

Cách khắc phục: sắp lại thứ tự trong DOM cho khớp thứ tự đọc, rồi mới dùng CSS để layout — chứ không dùng CSS để "sửa" thứ tự sai. Đội cũng bổ sung một kiểm thử tự động chạy Tab qua form và log ra thứ tự focus để phát hiện lệch.

Bài học: thứ tự Tab bám theo DOM, không theo mắt nhìn. Hãy để HTML phản ánh đúng thứ tự logic; CSS chỉ để trình bày. Đây cũng là lý do "tab order lộn xộn" luôn nằm trong top lỗi a11y phổ biến nhất.

Ví dụ 3 — Trang tin tức: quảng cáo iframe làm bẫy focus

Một trang báo điện tử Việt Nam nhúng một quảng cáo tương tác của bên thứ ba trong <iframe> giữa bài viết. Người dùng bàn phím đọc bài, Tab xuống, focus rơi vào iframe quảng cáo — và kẹt luôn. Quảng cáo bắt phím Tab để chuyển giữa các phần của nó nhưng không bao giờ nhả focus ra ngoài. Người dùng không đọc tiếp được phần dưới bài, cũng không tới được phần bình luận. Họ buộc phải tải lại trang hoặc bỏ đi.

Đội kỹ thuật ban đầu đổ lỗi cho nhà quảng cáo (đúng một phần), nhưng giải pháp thực dụng là: đặt một skip link ngay trước iframe ("Bỏ qua quảng cáo") và cân nhắc bỏ những mạng quảng cáo tạo keyboard trap. Sau khi thêm skip link, tỉ lệ người dùng bàn phím hoàn thành việc đọc trọn bài (đo qua sự kiện scroll đến cuối) tăng đáng kể.

Bài học: bẫy focus thường đến từ code không do bạn kiểm soát (widget bên thứ ba). Bạn vẫn có trách nhiệm và vẫn có cách chữa cháy — skip link, đóng khung nội dung bên thứ ba, hoặc loại bỏ chúng.

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

Đây là quy trình bạn có thể áp dụng cho bất kỳ trang nào, không cần công cụ đặc biệt (test tool chuyên sâu để dành cho Bài 37–39).

  • Cất chuột đi và Tab từ trên xuống. Bắt đầu từ khi trang vừa tải, nhấn Tab liên tục. Quan sát: focus có luôn nhìn thấy được không? Có nhảy đúng thứ tự đọc không? Có phần tử tương tác nào bị bỏ qua không? Có bị kẹt ở đâu không (thử cả Shift+Tab để đi ngược)?
  • Kiểm tra từng widget với đúng phím kỳ vọng. Vào menu → thử phím mũi tên và Esc. Vào form → thử Enter để submit. Vào nút → thử cả Enter và Space. Nếu một component "chỉ phản ứng với chuột", đánh dấu lại.
  • Thêm skip link. Đặt ngay sau <body> một <a href="#main-content"> ẩn bằng CSS nhưng hiện ra khi focus. Đảm bảo nội dung chính có id="main-content" (và cân nhắc tabindex="-1" trên vùng đích để focus nhảy vào đó thật sự).
  • Rà soát mọi onclick trên phần tử không tương tác. Tìm <div onclick>, <span onclick>. Chuyển sang <button> hoặc <a href> bất cứ khi nào có thể. Nếu buộc phải giữ, thêm tabindex="0", role phù hợp, và xử lý cả phím Enter/Space.
  • Đối chiếu DOM order với thứ tự đọc. Với các layout dùng flex order, grid, position: absolute — kiểm tra xem thứ tự Tab có khớp thứ tự mắt nhìn không. Nếu lệch, sửa DOM trước, CSS sau.
  • Với nhóm nhiều phần tử cùng loại (tabs, menu, toolbar, radio group), áp dụng roving tabindex. Chỉ một phần tử tabindex="0", còn lại -1, mũi tên chuyển focus. Đừng bắt người dùng Tab qua từng item.
  • Quản lý focus khi nội dung thay đổi động. Khi mở panel/modal, chủ động focus() vào nó. Khi đóng, trả focus về nút đã kích hoạt. Khi submit form lỗi, đưa focus tới thông báo lỗi đầu tiên.
  • Kiểm tra lại :focus có style rõ ràng. (Chi tiết ở Bài 12, nhưng ở bước này bạn ít nhất phải chắc rằng không có outline: none nào xóa sạch focus ring mà không thay bằng gì khác.)

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

  • outline: none mà không thay thế. Đây là "tội tổ tông" của a11y. Reset CSS hoặc designer ghét đường viền xanh mặc định nên xóa nó — và người dùng bàn phím lập tức mất la bàn. Nếu bỏ outline mặc định, BẮT BUỘC thay bằng style focus khác rõ ràng.
  • Dùng tabindex dương. tabindex="3" tưởng "giúp sắp thứ tự" nhưng thực ra phá vỡ toàn bộ dòng chảy tự nhiên và trở thành cơn ác mộng bảo trì. Chỉ dùng 0 và -1.
  • Bắt người dùng Tab qua hàng chục item. Mega-menu, danh sách sản phẩm, bảng dữ liệu lớn mà mỗi ô đều vào tab order → người dùng bàn phím phải nhấn Tab cả trăm lần. Giải pháp: roving tabindex + skip link.
  • Quên trả focus về sau khi đóng modal/popup. Người dùng mở dropdown, chọn xong, dropdown đóng — nhưng focus "rơi về đầu trang" hoặc biến mất. Luôn nhớ trả focus về đúng nơi xuất phát.
  • Chặn hành vi phím mặc định. Viết preventDefault() bừa bãi khiến Space không cuộn trang, Enter không submit được. Chỉ chặn khi bạn thực sự đang thay thế bằng hành vi đúng.
  • Hover-only. Tooltip, submenu chỉ hiện khi rê chuột (:hover) mà không có tương ứng với :focus → người dùng bàn phím không bao giờ thấy chúng. Luôn cặp :hover với :focus.
  • Mẹo test nhanh 10 giây: nhấn Tab một lần trên trang bất kỳ. Nếu bạn không thấy focus xuất hiện rõ ràng ở đâu, trang đó đã có vấn đề nghiêm trọng về điều hướng bàn phím.
  • Mẹo bố cục: viết HTML theo đúng thứ tự bạn muốn người dùng đi qua, coi đó là "kịch bản mù" — nhắm mắt lại thì thứ tự này có kể được một câu chuyện mạch lạc không?

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

  • Audit "không chuột" một trang thật. Chọn một trang bạn đang làm (hoặc một website Việt Nam bạn hay dùng: một sàn TMĐT, một trang ngân hàng, một tờ báo). Cất chuột, Tab từ đầu tới cuối. Ghi lại: (a) mọi phần tử tương tác không tới được, (b) mọi chỗ focus không nhìn thấy, (c) mọi bẫy focus, (d) thứ tự Tab có lệch thứ tự đọc không. Viết thành một bảng "vấn đề — vị trí — mức độ".
  • Sửa một nút giả. Tìm một <div onclick> hoặc <span onclick> trong dự án. Chuyển nó thành <button>. Xác nhận: Tab tới được, cả Enter và Space đều kích hoạt được, và bạn không phải viết thêm JS xử lý phím.
  • Thêm skip link hoàn chỉnh. Cài một "Bỏ qua tới nội dung chính" ẩn-hiện-khi-focus, trỏ tới vùng nội dung chính. Test bằng bàn phím: Tab lần đầu vào trang phải thấy nó xuất hiện, Enter phải nhảy focus xuống đúng nội dung.
  • Dựng một tab group với roving tabindex. Làm một component có 3 tab. Tab (bàn phím) chỉ vào một tab đang chọn; dùng mũi tên trái/phải để chuyển giữa các tab; Home/End nhảy về tab đầu/cuối. Đối chiếu hành vi của bạn với kỳ vọng chuẩn.
  • So sánh 3 website. Chọn 3 sản phẩm cùng lĩnh vực, chấm điểm điều hướng bàn phím mỗi cái theo thang: focus nhìn thấy? thứ tự đúng? mọi chức năng bấm được? có bẫy focus? Viết một đoạn ngắn về cái nào tốt nhất và vì sao — đây chính là tư duy của một a11y specialist.

Tóm tắt

Điều hướng bàn phím không phải "tính năng thêm cho người khuyết tật" — nó là kiểm tra sức khỏe cơ bản cho biết sản phẩm của bạn có vận hành đúng hay không. Ghi nhớ những cột trụ sau:

  • Nguyên tắc bất di bất dịch: làm được bằng chuột thì phải làm được bằng bàn phím (WCAG 2.1.1). Không có ngoại lệ.
  • Dùng phần tử ngữ nghĩa đúng (<button>, <a>, <input>) để nhận hành vi bàn phím "miễn phí". Đừng dựng nút bằng <div>.
  • Thứ tự Tab bám theo DOM, không theo CSS. Sửa thứ tự ở HTML, dùng CSS chỉ để trình bày.
  • Tab để nhảy giữa các widget, mũi tên để di chuyển bên trong widget. Áp dụng roving tabindex cho nhóm; đừng bắt người dùng Tab hàng chục lần.
  • Chỉ dùng tabindex="0" và tabindex="-1". Tránh xa tabindex dương.
  • Không có keyboard trap vô ý (WCAG 2.1.2); nhưng biết cách giữ focus có chủ đích trong modal.
  • Quản lý focus khi nội dung động — mở, đóng, lỗi form — luôn đưa focus tới nơi hợp lý và trả về đúng chỗ.
  • Skip link là cải thiện rẻ mà tác động lớn.
Khi bạn đã điều hướng được toàn trang bằng bàn phím một cách mượt mà, bước tiếp theo là làm cho dấu vết của focus thật rõ ràng và đẹp — đó là nội dung của Bài 12: Focus Indicators.

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