Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đang dùng máy tính nhưng chuột bị hỏng. Bạn buộc phải dùng phím Tab để nhảy qua từng nút, từng ô nhập liệu, từng đường link trên trang. Nhưng có một vấn đề: bạn không hề thấy mình đang đứng ở đâu. Con trỏ bàn phím vô hình. Bạn bấm Tab năm lần, rồi Enter, và... không biết mình vừa kích hoạt cái gì. Cảm giác đó giống như đi trong một căn phòng tối om, sờ soạng từng bức tường mà không có đèn.
Đó chính xác là trải nghiệm của hàng triệu người dùng khi một website thiếu focus indicator (chỉ báo tiêu điểm) — dấu hiệu trực quan cho biết phần tử nào đang được bàn phím "chọn" tại một thời điểm. Với người khiếm thị vận động không dùng được chuột, người dùng phím do chấn thương tay (RSI), người dùng thiết bị hỗ trợ như switch device, hay đơn giản là một lập trình viên quen dùng phím — focus indicator không phải "trang trí", nó là la bàn.
Ở Bài 11, bạn đã học các mẫu điều hướng bằng bàn phím (keyboard navigation patterns). Bài 12 này tập trung sâu vào một mắt xích cụ thể và thường bị xem nhẹ nhất: làm sao để tiêu điểm bàn phím luôn nhìn thấy được, thiết kế nó đẹp mà vẫn đạt chuẩn, và triển khai đúng bằng CSS. Đây là chủ đề nhỏ nhưng là một trong những lỗi accessibility bị mắc nhiều nhất — và cũng dễ sửa nhất nếu bạn hiểu đúng.
Khái niệm cốt lõi
Focus indicator là gì?
Khi bạn nhấn Tab, trình duyệt di chuyển "keyboard focus" (tiêu điểm bàn phím) từ phần tử này sang phần tử khác — nút, link, ô input, checkbox... Phần tử đang giữ focus là phần tử sẽ nhận thao tác khi bạn gõ phím (ví dụ Enter để nhấn nút, phím mũi tên để chọn radio). Focus indicator là biểu hiện trực quan của trạng thái này — thường là một viền (outline) bao quanh phần tử.
Mặc định, mọi trình duyệt đều vẽ một focus indicator riêng (thường là viền chấm hoặc viền xanh). Vấn đề nảy sinh khi lập trình viên thấy nó "xấu" và xóa đi bằng dòng CSS khét tiếng:
*:focus { outline: none; } /* ❌ Đừng bao giờ làm thế này nếu không thay thế */
Dòng này xóa la bàn của hàng triệu người dùng phím. Đây là lỗi accessibility phổ biến bậc nhất trên web.
WCAG 2.1 SC 2.4.7 — Focus Visible (Mức AA)
Tiêu chuẩn nền tảng của bài này là WCAG 2.1 Success Criterion 2.4.7 Focus Visible, mức AA:
> "Any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible." > (Tạm dịch: Mọi giao diện thao tác được bằng bàn phím phải có một chế độ trong đó chỉ báo tiêu điểm bàn phím nhìn thấy được.)
Nói ngắn gọn: khi người dùng Tab tới bất kỳ phần tử tương tác nào, họ phải thấy được nó đang được focus. Không có ngoại lệ cho "vì thiết kế đẹp hơn".
:focus vs :focus-visible — chìa khóa để đẹp và đạt chuẩn
Đây là phần quan trọng nhất về mặt kỹ thuật. Trong nhiều năm, developer đối mặt với một mâu thuẫn: nếu để :focus hiện outline, thì khi người dùng bấm chuột vào một nút, họ cũng thấy viền outline — điều mà nhiều designer cho là thừa và xấu. Vì thế họ xóa outline luôn, và vô tình phá vỡ accessibility.
Giải pháp hiện đại là pseudo-class :focus-visible. Trình duyệt sẽ tự phán đoán (dựa trên heuristic): nếu người dùng đến bằng bàn phím, nó áp dụng :focus-visible; nếu đến bằng chuột/chạm, nó không áp dụng. Nhờ đó:
/* Không hiện outline khi bấm chuột, NHƯNG vẫn hiện khi dùng bàn phím */
button:focus:not(:focus-visible) {
outline: none;
}
button:focus-visible {
outline: 3px solid #1a73e8;
outline-offset: 2px;
}
Đây là cách bạn làm hài lòng cả designer lẫn WCAG. :focus-visible được hỗ trợ trên mọi trình duyệt hiện đại (Chrome, Edge, Firefox, Safari 15.4+).
WCAG 2.2 SC 2.4.11 — Focus Not Obscured & SC 2.4.13 — Focus Appearance
WCAG 2.2 (phát hành 2023) bổ sung thêm hai tiêu chí liên quan trực tiếp mà bạn nên biết:
- SC 2.4.11 Focus Not Obscured (Minimum, AA): phần tử đang focus không được bị che khuất hoàn toàn bởi nội dung khác (ví dụ một thanh header dính (sticky header) che mất nút vừa được Tab tới). Rất hay gặp trên các site có header cố định.
- SC 2.4.13 Focus Appearance (AAA): quy định độ dày và độ tương phản tối thiểu của focus indicator. Cụ thể, vùng chỉ báo phải có diện tích tối thiểu tương đương một viền dày 2px bao quanh, và tương phản màu ít nhất 3:1 so với trạng thái không focus.
Nguyên tắc thiết kế một focus indicator tốt
Một focus indicator đạt chuẩn cần thỏa mãn:
- Đủ tương phản: tối thiểu 3:1 so với nền xung quanh và so với chính phần tử. Viền xanh nhạt trên nền trắng thường trượt tiêu chí này.
- Đủ dày: tối thiểu 2px, khuyến nghị 2–3px cho outline thông thường.
- Không dựa duy nhất vào màu: đừng chỉ đổi màu chữ để báo focus — hãy dùng viền, khung, hoặc thay đổi hình dạng.
- Có
outline-offset: khoảng cách nhỏ (2–3px) giúp viền không dính sát vào phần tử, dễ nhìn hơn nhiều. - Không bị cắt cụt (clipped): cẩn thận với
overflow: hiddentrên phần tử cha — nó có thể cắt mất outline.
Tình huống thực tế
Ví dụ 1 — Sàn thương mại điện tử "chợ nhanh" và nút thanh toán vô hình
Một startup TMĐT giả định tại TP.HCM, tạm gọi ChợNhanh, có gần 400.000 lượt truy cập/tháng. Đội design của họ dùng một design system với dòng CSS reset đặt ngay đầu file: *:focus { outline: none; }. Trên desktop, mọi thứ trông "sạch sẽ".
Một khách hàng lớn tuổi bị run tay (Parkinson giai đoạn đầu) không dùng được chuột chính xác, chuyển sang điều hướng bằng Tab. Bà Tab từ giỏ hàng xuống nút "Thanh toán", nhưng vì outline đã bị xóa, bà không biết mình đang đứng ở đâu, vô tình bấm Enter trúng nút "Xóa giỏ hàng" nằm ngay trước đó. Mất toàn bộ đơn hàng 2,3 triệu đồng.
Đội ngũ ChợNhanh chỉ phát hiện vấn đề khi làm audit theo WCAG để đấu thầu một hợp đồng B2B. Họ sửa bằng đúng hai khối CSS: thay outline: none toàn cục bằng :focus-visible với outline: 3px solid màu tím thương hiệu và outline-offset: 2px. Chi phí kỹ thuật: chưa tới nửa ngày công.
Bài học: Xóa outline toàn cục là "quả bom hẹn giờ". Một dòng CSS vô hại về mặt hình ảnh có thể trực tiếp gây thiệt hại tài chính cho người dùng thật. :focus-visible giải quyết mâu thuẫn đẹp–chuẩn mà không phải đánh đổi.
Ví dụ 2 — Sticky header của một ngân hàng số che mất focus
TPBank và nhiều ngân hàng số ở Việt Nam đều dùng header cố định (sticky) trên trang web để giữ logo và menu luôn hiển thị khi cuộn. Giả sử một app web ngân hàng có header dính cao 72px. Khi người dùng phím Tab tới các link ở nửa trên của trang sau khi đã cuộn xuống, trình duyệt tự cuộn để đưa phần tử vào khung nhìn — nhưng phần tử lại nằm ngay dưới header dính, bị header che mất một nửa. Người dùng thấy focus nhấp nháy đâu đó nhưng bị cắt cụt, không đọc được.
Đây chính là vi phạm SC 2.4.11 (Focus Not Obscured) của WCAG 2.2. Cách sửa gọn nhất là dùng thuộc tính CSS scroll-margin-top:
:target,
a, button, input, [tabindex] {
scroll-margin-top: 88px; /* cao hơn header 72px một chút */
}
Thuộc tính này bảo trình duyệt "chừa" một khoảng lề khi cuộn phần tử vào tầm nhìn, đẩy nó xuống dưới header. Focus không còn bị che.
Bài học: Focus indicator có thể "đúng" về CSS nhưng vẫn vô dụng nếu bị layout che khuất. Luôn kiểm tra hành vi Tab trên các trang có header/footer dính. scroll-margin-top là mẹo cực kỳ đáng giá.
Ví dụ 3 — Component thẻ (card) "cả khối bấm được" ở một agency Đông Nam Á
Một agency thiết kế tại Singapore làm trang danh mục dự án cho khách hàng bất động sản. Mỗi dự án là một "card" lớn có ảnh, tiêu đề, mô tả — và cả card đều bấm được để mở chi tiết. Về mặt code, họ dùng thủ thuật phổ biến: một thẻ <a> bao trong tiêu đề rồi dùng ::after phủ toàn card.
Vấn đề: focus indicator mặc định chỉ bao quanh phần text tiêu đề nhỏ xíu, không phản ánh vùng bấm thực tế là cả card. Người dùng phím thấy một viền tí hon lơ lửng, không rõ mình đang chọn "cả card".
Họ sửa bằng cách đưa outline lên chính card khi link con nhận focus:
.card:has(a:focus-visible) {
outline: 3px solid #0b5fff;
outline-offset: 4px;
border-radius: 12px;
}
.card a:focus-visible { outline: none; } /* dời outline ra card */
(Với trình duyệt cũ chưa hỗ trợ :has(), họ dùng JS thêm/xóa class .is-focused trên card qua sự kiện focusin/focusout.)
Bài học: Focus indicator nên bao quanh vùng tương tác mà người dùng cảm nhận, không nhất thiết là phần tử DOM kỹ thuật. Thiết kế focus phải khớp với mô hình tư duy (mental model) của người dùng.
Hướng dẫn từng bước
Đây là quy trình bạn có thể áp dụng ngay cho một dự án bất kỳ:
Bước 1 — Kiểm tra hiện trạng. Mở trang web, cất chuột đi, và nhấn Tab liên tục từ đầu trang. Với mỗi lần Tab, tự hỏi: "Tôi có thấy rõ mình đang ở đâu không?" Nếu có bất kỳ điểm nào focus biến mất, bạn đã tìm ra lỗi.
Bước 2 — Truy tìm thủ phạm outline: none. Tìm trong toàn bộ CSS các dòng như outline: none, outline: 0, hoặc *:focus {}. Đây thường là nguyên nhân gốc.
Bước 3 — Thay bằng chiến lược :focus-visible. Thiết lập một style focus dùng chung, ví dụ:
:focus-visible {
outline: 3px solid #1a73e8;
outline-offset: 2px;
border-radius: 2px;
}
/* Tắt outline khi dùng chuột nhưng vẫn giữ cho bàn phím */
:focus:not(:focus-visible) {
outline: none;
}
Bước 4 — Kiểm tra độ tương phản. Đặt màu outline sao cho đạt tối thiểu 3:1 so với nền. Nếu nền của bạn có cả vùng sáng lẫn tối, cân nhắc kỹ thuật "viền kép": một outline cộng với box-shadow màu tương phản ngược để nổi bật trên mọi nền:
:focus-visible {
outline: 2px solid #ffffff;
box-shadow: 0 0 0 4px #1a73e8;
}
Bước 5 — Xử lý header/footer dính. Thêm scroll-margin-top (và scroll-margin-bottom nếu có footer dính) cho các phần tử tương tác, để focus không bị che (SC 2.4.11).
Bước 6 — Kiểm tra clipping. Rà các phần tử cha có overflow: hidden. Nếu outline bị cắt, dùng outline-offset âm nhỏ, hoặc chuyển sang box-shadow (không bị overflow cắt theo cùng cách).
Bước 7 — Test lại với bàn phím thật, trên ít nhất 2 trình duyệt, và ở cả chế độ High Contrast của hệ điều hành (Windows High Contrast Mode dùng forced-colors, khi đó nên để outline giữ nguyên vì hệ thống sẽ tự vẽ).
Lỗi thường gặp & mẹo
Lỗi 1 — Xóa outline mà không thay thế. Kinh điển và tai hại nhất. Nếu bạn phải xóa outline mặc định, luôn thay bằng một chỉ báo khác. Không có ngoại lệ.
Lỗi 2 — Chỉ đổi màu nền/màu chữ để báo focus. Vi phạm nguyên tắc "không dựa duy nhất vào màu". Người khiếm màu (color blindness) hoặc màn hình ám màu có thể không phân biệt được. Hãy dùng cả viền hoặc thay đổi hình khối.
Lỗi 3 — Focus indicator quá nhạt. Viền 1px solid #ccc gần như vô hình trên nền trắng. Dùng tối thiểu 2px và tương phản ≥ 3:1.
Lỗi 4 — Quên outline-offset. Không có offset, viền dính sát cạnh phần tử và khó thấy, đặc biệt trên nút có nền đậm. Thêm outline-offset: 2px là nâng cấp lớn với chi phí bằng không.
Lỗi 5 — Chỉ dùng :focus (không :focus-visible) rồi lại xóa. Đây là cái bẫy khiến developer nản và xóa hết. Hãy dùng :focus-visible ngay từ đầu.
Lỗi 6 — outline-color bị mất trong forced-colors mode. Trong Windows High Contrast, đừng dùng box-shadow làm chỉ báo duy nhất (box-shadow bị bỏ qua ở chế độ này). outline được tôn trọng — hãy luôn có một outline thật.
Mẹo vàng: Dùng biến CSS để quản lý focus tập trung — đổi một chỗ, áp cả site:
:root { --focus-ring: 3px solid #1a73e8; }
:focus-visible { outline: var(--focus-ring); outline-offset: 2px; }
Mẹo test nhanh: Bật DevTools → tab Elements → chọn một phần tử → mục ":hov" (Force element state) → tick :focus-visible để xem style focus mà không cần Tab thủ công.
Bài tập thực hành
- Săn lỗi: Chọn 3 website bạn hay dùng (một sàn TMĐT Việt Nam, một trang tin tức, một app ngân hàng). Cất chuột, chỉ dùng Tab đi hết trang chủ. Ghi lại: ở đâu focus biến mất, ở đâu bị header che, ở đâu chỉ báo quá nhạt. Viết một bản ghi ngắn 5–7 dòng cho mỗi site.
- Sửa CSS: Tạo một trang HTML nhỏ có 1 form (input, textarea, nút submit) và 3 link. Cố tình thêm
*:focus { outline: none; }. Sau đó viết lại đúng bằng:focus-visible, cóoutline-offset, và kiểm chứng bằng cách Tab qua toàn bộ. Bổ sung kỹ thuật "viền kép" (outline + box-shadow) cho nút submit.
- Xử lý sticky header: Thêm một header
position: sticky; top: 0cao 64px vào trang ở bài 2. Cuộn xuống, Tab ngược lên và quan sát focus bị che. Sửa bằngscroll-margin-top. Chụp màn hình trước và sau.
- Kiểm tra tương phản: Dùng một công cụ kiểm tra tương phản (như trình kiểm tra trong DevTools) để xác nhận màu outline của bạn đạt ≥ 3:1 trên nền trang. Nếu chưa đạt, điều chỉnh cho tới khi đạt.
Tóm tắt
Focus indicator là "la bàn" cho mọi người dùng điều hướng bằng bàn phím — không phải chi tiết trang trí có thể bỏ đi cho đẹp. WCAG 2.1 SC 2.4.7 (AA) yêu cầu tiêu điểm bàn phím luôn nhìn thấy được, và WCAG 2.2 bổ sung SC 2.4.11 (không bị che khuất) cùng SC 2.4.13 (độ dày và tương phản của chỉ báo).
Chìa khóa kỹ thuật để vừa đẹp vừa đạt chuẩn là :focus-visible: chỉ hiện outline khi người dùng đến bằng bàn phím, ẩn khi bấm chuột. Một focus indicator tốt cần đủ tương phản (≥ 3:1), đủ dày (≥ 2px), có outline-offset, không dựa duy nhất vào màu, và không bị header dính che (dùng scroll-margin-top) hay bị overflow: hidden cắt cụt.
Ba tình huống thực tế — sàn TMĐT xóa outline gây mất đơn hàng, ngân hàng số có header che focus, và card "cả khối bấm được" — cho thấy cùng một bài học: chỉ báo tiêu điểm phải nhìn thấy được và khớp với vùng người dùng thực sự thao tác. Sửa những lỗi này thường chỉ tốn vài dòng CSS, nhưng tác động tới trải nghiệm là rất lớn. Ở bài tiếp theo, bạn sẽ bước sang thế giới của trình đọc màn hình (screen reader) — nơi accessibility được "nghe" thay vì "nhìn".