Có một kiểu lỗi mình gặp khá thường khi review giao diện: nút vẫn có focus ring, designer nhìn qua bảo “ổn rồi”, nhưng tới lúc bấm Tab trên nền tối thì chiếc ring gần như tan vào background. Focus indicator WCAG 2.2 không chỉ là câu hỏi “có thấy hay không”. Diện tích, cặp màu đem đi đo và phần tử có bị sticky bar che hay không là ba chuyện khác nhau.

Mình dựng một fixture local gồm button, link, input và action cuối trang, rồi chạy bằng bàn phím trên Edge 151 và Chrome 151 ở Windows. Bài này kể lại đúng phép đo đó. Nó không phải giấy chứng nhận accessibility cho nghiane.com hay cho mọi component dùng chung đoạn CSS.

Focus ring “có thấy” vẫn có thể chưa đủ

Ở vòng Tab đầu tiên, cả focus mặc định, ring xanh một màu và ring trắng–than hai màu đều xuất hiện. Nếu dừng ở ảnh chụp màn hình, cả ba có vẻ dùng được. Vấn đề lộ ra khi đổi nền: màu xanh #005FCC nổi rõ trên giấy sáng #F6F3ED, nhưng yếu hẳn trên nền than #1F2933.

Chỗ này cần tách “người dùng biết focus đang ở đâu” khỏi “indicator có kích thước và tương phản đạt ngưỡng”. Tương tự bài Material 3 motion physics và reduced motion, một state đẹp mắt chưa tự động trở thành state dễ tiếp cận.

Ba tiêu chí WCAG thường bị gộp làm một

Tiêu chíMứcCâu hỏi mình kiểm tra
2.4.7 Focus VisibleAAKhi dùng bàn phím, có chế độ nào cho thấy focus đang ở đâu?
2.4.11 Focus Not Obscured (Minimum)AAComponent có bị nội dung do tác giả tạo che kín hoàn toàn không?
2.4.13 Focus AppearanceAAAIndicator có đủ diện tích và thay đổi tương phản ít nhất 3:1 không?

Đây là điểm dễ ghi sai nhất: trong WCAG 2.2 hiện hành, 2.4.13 là AAA, không phải AA. Còn 2.4.11 chỉ yêu cầu component không bị che toàn bộ; phiên bản Enhanced ở 2.4.12 mới đi xa hơn. Một component có thể đạt hai tiêu chí AA phía trên mà vẫn chưa chứng minh được phép đo AAA.

Bảng này cũng giúp mình viết kết quả audit cụ thể hơn. Thay vì một nhãn “focus fail”, mình có thể ghi: đường Tab vẫn nhìn thấy nên chưa fail 2.4.7; sticky footer che kín control nên fail 2.4.11; hoặc ring có diện tích đủ nhưng change-of-contrast dưới 3:1 nên chưa đạt 2.4.13. Người sửa UI lúc đó biết phải đụng vào focus style, hành vi cuộn hay lớp overlay, không mò cả component.

Mình dựng ba focus style để so

Fixture giữ cùng kích thước và nội dung, chỉ đổi cách vẽ focus: style mặc định của browser, outline xanh 2 px offset 2 px, và hai dải trắng–than mỗi dải 2 px. Mình bấm Tab qua button → link → input, Shift+Tab quay lại, rồi click button bằng chuột.

Edge và Chrome đều đưa focus theo đúng thứ tự. Sau thao tác bàn phím, phần tử khớp :focus-visible; sau click chuột trên Edge, button vẫn có focus nhưng không khớp pseudo-class này. Focus bằng script kế thừa modality hiện tại, vì vậy đừng giả định gọi element.focus() luôn làm ring hiện hoặc luôn làm ring mất.

Mình chạy ở viewport 1280×720 trước, sau đó thu không gian layout còn 640×360 để tái tạo áp lực reflow tương đương 200%. Mỗi lần đều đi hết vòng bằng Tab, quay một bước bằng Shift+Tab và kiểm tra phần tử active trong DOM. Firefox stable không có trên máy nên mình để trống cột đó, không lấy kết quả Chromium gắn sang mọi trình duyệt.

Ba focus style trên nền sáng, nền tối và nền chia đôi sáng tối
Cùng một vòng Tab: focus mặc định, outline xanh một màu và ring trắng–than hai màu được chụp trực tiếp từ fixture local.

Diện tích chu vi 2 CSS px tính thế nào?

Mẫu hình chữ nhật của mình rộng 112 và cao 44 CSS px. Lấy một vành ngoài dày 2 px, diện tích tham chiếu là:

(112 + 4) × (44 + 4) − 112 × 44 = 640 CSS px²

Số 4 xuất hiện vì vành mở rộng 2 px ở cả hai phía của mỗi chiều. W3C lưu ý rằng indicator đặt sâu vào bên trong component có thể phải dày hơn 2 px mới đủ diện tích. Đây là lý do mình thích chọn ring rộng rãi hơn ngưỡng thay vì tối ưu sát một công thức phức tạp theo border-radius.

Ảnh pixel bên dưới được chụp từ chính box 112×44 trong fixture. Nó minh họa vùng hatch cần tính, không phải phép đo bằng mắt. Khi audit component co giãn như input trong bài CSS field-sizing, mình sẽ lấy kích thước thật ở từng breakpoint chứ không dùng một con số cố định.

Phép đo component 112 nhân 44 CSS pixel và hai cặp màu tương phản focus
Box 112×44 cho diện tích tham chiếu 640 CSS px²; hai ô bên dưới nhắc rõ same-pixel và adjacent contrast là hai phép đo khác nhau.

Hai phép đo tương phản dễ nhầm

Với 2.4.13, cặp cần so là cùng các pixel giữa trạng thái focused và unfocused. Ring xanh trên nền giấy đổi từ #F6F3ED sang #005FCC, tỷ lệ mình tính được là 5,40:1. Trên nền than, xanh so với #1F2933 chỉ còn 2,47:1, nên cùng một token không vượt ngưỡng 3:1 ở case này.

Phép đo adjacent contrast của 1.4.11 lại nhìn indicator và màu nằm cạnh nó trong cùng trạng thái khi đường biên đó cần để nhận biết component hoặc state. Hai phép đo có thể dùng cùng bảng màu nhưng không phải cùng câu hỏi. Ghi rõ “pixel nào so với pixel nào” giúp audit tránh một con số contrast vô nghĩa.

Technique C40 của W3C đưa ra mẹo hai màu: nếu hai dải tương phản với nhau ít nhất 9:1 và mỗi dải tự đủ diện tích, ít nhất một dải sẽ đạt 3:1 trên một nền đặc bất kỳ. Trắng #FFFFFF và than #17212B trong fixture đạt 16,29:1. Trên nền thay đổi sáng–tối, một dải có thể chìm nhưng dải còn lại vẫn đứng ra làm việc.

:focus-visible giúp gì và không giúp gì?

:focus-visible để trình duyệt áp dụng heuristic theo phương thức nhập, nhờ vậy ring nổi bật cho keyboard mà không nhất thiết xuất hiện sau mọi click. Nó không thay thế focus order, không sửa component thiếu semantics và không biến outline: none thành an toàn.

.focusable:focus {
  outline: 2px solid #fff;
  outline-offset: 2px;
  box-shadow: 0 0 0 4px #17212b;
}

@supports selector(:focus-visible) {
  .focusable:focus:not(:focus-visible) {
    outline-color: transparent;
    box-shadow: none;
  }
}

@media (forced-colors: active) {
  .focusable:focus-visible {
    outline: 2px solid CanvasText;
    box-shadow: none;
  }
}

Mình giữ outline làm lớp thật, không dựa riêng vào box-shadow, vì forced-colors có thể bỏ shadow. Technique C45 cũng yêu cầu kiểm từng component bằng bàn phím; selector đúng cú pháp chưa nói gì về trải nghiệm cuối.

Sticky footer có đang che mất focus?

Ở viewport desktop 1280×720 và viewport hiệu dụng 640×360 tương đương không gian layout khi phóng 200%, vòng Tab tự nhiên của fixture giữ button cuối trang phía trên footer: overlap bằng 0. Mình không bịa một lỗi chỉ để ảnh trước/sau trông kịch tính.

Case fail xuất hiện khi code của trang gọi scrollIntoView({ block: 'end' }). Button bị đặt sát đáy viewport, nằm hoàn toàn sau footer cố định cao 96 px. Thêm scroll-margin-bottom: 120px vào đúng target đưa button lên trên footer và overlap trở lại 0. Đây là một cách sửa cho fixture, không phải thuốc chữa mọi modal hay overlay.

So sánh button bị sticky footer che kín và button được đưa lên bằng scroll margin
Trái: scrollIntoView đặt button sau footer 96 px. Phải: scroll-margin-bottom 120 px đưa toàn bộ button và ring trở lại vùng nhìn thấy.

Pattern nào nên đưa vào design system?

Mình sẽ lưu focus như một nhóm token: độ dày từng dải, offset, màu sáng, màu tối và khoảng tránh sticky content. Mỗi component mới phải có acceptance test trên nền sáng, nền tối, nền ảnh, forced-colors và ở viewport hẹp. Cách làm này giống phần acceptance test khi mình chuyển Polaris Web Components: token chỉ có giá trị khi consumer thật dùng đúng.

Trong file component, mình còn muốn ghi rõ ownership: design chịu trách nhiệm chọn token và minh họa các background hợp lệ; frontend giữ semantics, selector và forced-colors; QA kiểm đường Tab, zoom và overlay. Nếu chỉ giao một mã hex, consumer rất dễ dùng màu đó trên nền ngoài phạm vi đã đo. Nếu chỉ giao ảnh Figma, developer lại không biết dải nào là outline thật và dải nào chỉ là hiệu ứng shadow.

Ring một màu vẫn hợp với sản phẩm có background kiểm soát chặt. Với card, canvas hoặc theme thay đổi liên tục, ring hai màu đáng tin hơn. Còn browser default là fallback tốt, nhưng mình không gắn nhãn “AAA” chung chung vì W3C có ngoại lệ riêng cho indicator do user agent quyết định.

Checklist audit bằng bàn phím

  • Đi hết Tab và Shift+Tab; thử Enter, Space và click chuột.
  • Ghi riêng kết quả 2.4.7 AA, 2.4.11 AA và 2.4.13 AAA.
  • Đo diện tích bằng kích thước thật của component, không chụp mắt.
  • Ghi hai cặp màu: focused/unfocused cùng pixel và indicator/màu liền kề.
  • Thử nền sáng, tối, forced-colors, viewport hẹp và sticky/overlay.
  • Không xóa outline nếu chưa có fallback nhìn thấy được.

Kết quả của fixture là ring xanh dùng tốt trên nền sáng nhưng hụt 3:1 trên nền than; ring trắng–than chịu nền thay đổi tốt hơn; sticky footer chỉ gây fail khi kết hợp với hành vi cuộn do tác giả tạo. Anh em có thể lấy checklist này audit một component trước, rồi mới nhân token ra cả design system.

Nguồn và giới hạn kiểm tra

Mình đối chiếu W3C Understanding 2.4.13, W3C Understanding 2.4.11, W3C technique C40, W3C technique C45, MDN :focus-visibleWCAG 2.2 Recommendation. Bản Recommendation hiện tại ghi ngày 12/12/2024; các trang Understanding/Technique được W3C cập nhật 02/07/2026; kiểm tra lại ngày 11/08/2026.

Lab dùng Windows, Edge 151 và Chrome 151. Máy không có Firefox stable nên mình không tuyên bố kết quả Firefox. “200%” trong bài là viewport CSS hiệu dụng 640×360 so với khung 1280×720 để kiểm reflow/che khuất, không phải thao tác zoom UI trình duyệt. Mình cũng chưa chạy screen reader hoặc thiết bị di động thật.