Mở đầu — vì sao bài này quan trọng
Nếu phải chọn ra một nơi mà accessibility (khả năng tiếp cận) tạo ra khác biệt lớn nhất giữa "dùng được" và "hoàn toàn bế tắc", thì đó chính là biểu mẫu — form. Form là điểm mà người dùng phải hành động: đăng nhập, thanh toán, đặt lịch khám, khai báo thông tin, gửi đơn xin việc. Một trang tin tức có lỗi accessibility thì người dùng khiếm thị vẫn đọc được phần lớn nội dung. Nhưng một form đăng ký lỗi accessibility có thể khiến họ không thể hoàn thành giao dịch — nghĩa là bạn vừa mất một khách hàng, và tệ hơn, đẩy một con người vào cảm giác bất lực.
Con số minh họa mức độ nghiêm trọng: báo cáo thường niên WebAIM Million (khảo sát 1 triệu trang chủ phổ biến nhất) liên tục cho thấy các lỗi liên quan đến form — thiếu label cho input, thiếu nhãn cho nút — nằm trong nhóm lỗi accessibility phổ biến nhất, xuất hiện trên hàng chục phần trăm số trang. Đây không phải lỗi hiếm gặp của những trang cẩu thả; nó là lỗi mặc định của gần như mọi trang không được làm cẩn thận.
Trong bài này, chúng ta tập trung đúng vào phần accessibility của form: label, mối liên kết giữa label và input, nhóm các trường liên quan, hướng dẫn nhập liệu, và đặc biệt là xử lý lỗi — phần mà đa số lập trình viên làm sai nhất. Chúng ta sẽ không đi sâu vào touch target (Bài 28), tooltip (Bài 27), hay combobox/dropdown (Bài 26) — những thành phần đó có bài riêng. Ở đây, mục tiêu là: làm sao để một người dùng screen reader hoặc chỉ dùng bàn phím có thể điền và gửi form của bạn một cách trơn tru, tự tin, không đoán mò.
Khái niệm cốt lõi
Label luôn hiển thị — và luôn được liên kết đúng cách
Nguyên tắc nền tảng số một: mỗi input phải có một label nhìn thấy được, và không bao giờ chỉ dùng placeholder thay label.
Placeholder (chữ mờ bên trong ô nhập) có ba vấn đề nghiêm trọng. Thứ nhất, nó biến mất ngay khi người dùng bắt đầu gõ — nên nếu họ quên trường này là gì, họ phải xóa hết để xem lại. Thứ hai, độ tương phản của placeholder thường rất thấp (xám nhạt), không đạt chuẩn WCAG về contrast. Thứ ba, nhiều screen reader không đọc placeholder một cách đáng tin cậy, hoặc đọc nó như thể trường đã có giá trị.
Có hai cách liên kết label với input:
<!-- Cách 1: dùng for + id (khuyên dùng) -->
<label for="email">Email</label>
<input type="email" id="email" name="email">
<!-- Cách 2: bọc input trong label -->
<label>
Email
<input type="email" name="email">
</label>
Thuộc tính for của <label> phải trùng khớp với id của <input>. Khi liên kết đúng, người dùng bấm vào chữ "Email" sẽ focus thẳng vào ô nhập (vùng bấm lớn hơn, tốt cho cả người dùng bình thường), và screen reader sẽ đọc "Email, edit text" khi focus vào ô.
Hướng dẫn nhập liệu phải nằm trong "accessible name" hoặc "accessible description"
Nhiều trường cần hướng dẫn thêm: "Mật khẩu tối thiểu 8 ký tự", "Số điện thoại gồm 10 chữ số". Đừng nhét hướng dẫn này chỉ vào placeholder. Hãy đặt nó thành một phần tử riêng và liên kết bằng aria-describedby:
<label for="pw">Mật khẩu</label>
<input type="password" id="pw" aria-describedby="pw-hint">
<p id="pw-hint">Tối thiểu 8 ký tự, gồm cả chữ và số.</p>
Khi đó screen reader đọc: "Mật khẩu, edit text, Tối thiểu 8 ký tự, gồm cả chữ và số". Người dùng có đầy đủ thông tin trước khi gõ.
Nhóm các trường liên quan bằng fieldset và legend
Với các nhóm radio button hoặc checkbox, mỗi ô có label riêng, nhưng cả nhóm cần một "tiêu đề nhóm". Đó là vai trò của <fieldset> và <legend>:
<fieldset>
<legend>Hình thức nhận hàng</legend>
<label><input type="radio" name="ship" value="home"> Giao tận nhà</label>
<label><input type="radio" name="ship" value="store"> Nhận tại cửa hàng</label>
</fieldset>
Không có legend, screen reader chỉ đọc "Giao tận nhà, radio button" mà người dùng không biết đang chọn cái gì. Có legend, họ nghe "Hình thức nhận hàng, Giao tận nhà, radio button 1 of 2".
Trường bắt buộc và trạng thái phải được truyền tải bằng nhiều kênh
Đừng chỉ dùng dấu sao đỏ (*) để báo trường bắt buộc — người khiếm thị không thấy màu, người khiếm thị hoàn toàn không thấy dấu sao. Hãy dùng cả required (thuộc tính HTML) để screen reader thông báo "required", và giải thích ở đầu form ý nghĩa của dấu sao:
<label for="name">Họ tên <span aria-hidden="true">*</span></label>
<input id="name" required aria-required="true">
Nếu form dài, hãy nói rõ ngay đầu form: "Các trường có dấu * là bắt buộc."
Xử lý lỗi — phần quan trọng nhất và hay sai nhất
Khi form báo lỗi, có ba câu hỏi người dùng screen reader cần được trả lời: (1) Có lỗi không? (2) Trường nào lỗi? (3) Lỗi gì và sửa thế nào?
Nguyên tắc:
- Thông báo lỗi phải được liên kết với đúng input bằng
aria-describedby, để khi người dùng focus lại vào ô, họ nghe được lỗi. - Đánh dấu ô lỗi bằng
aria-invalid="true". - Thông báo tổng hợp nên đặt trong vùng
role="alert"hoặc một khu vực cóaria-live, để screen reader tự đọc ngay khi lỗi xuất hiện mà không cần người dùng đi tìm. - Đừng chỉ dùng màu đỏ để báo lỗi — phải có cả chữ (icon + text).
<label for="phone">Số điện thoại</label>
<input id="phone" aria-invalid="true" aria-describedby="phone-err">
<p id="phone-err" role="alert">Số điện thoại phải gồm 10 chữ số.</p>
Tình huống thực tế
Ví dụ 1 — Form thanh toán của một sàn TMĐT: placeholder giết chết conversion
Một đội sản phẩm tại một sàn thương mại điện tử lớn ở Việt Nam (giả định gọi là "ChợViệt") thiết kế lại form thanh toán theo phong cách tối giản: bỏ hết label, chỉ để placeholder xám nhạt bên trong mỗi ô — "Họ tên", "Địa chỉ", "Số nhà", "Ghi chú". Nhìn rất "sạch" trên Figma.
Sau khi lên production, tổng đài chăm sóc khách hàng nhận được một loạt phàn nàn kỳ lạ: khách điền sai địa chỉ vào ô ghi chú, điền số điện thoại vào ô số nhà. Điều tra kỹ hơn, đội UX phát hiện: khi người dùng đã gõ vài ký tự vào một ô rồi chuyển sang ô khác và quay lại, họ không còn nhìn thấy ô nào là ô nào — placeholder đã biến mất, không còn label. Người lớn tuổi và người dùng screen reader chịu ảnh hưởng nặng nhất. Một khách khiếm thị dùng VoiceOver phản hồi rằng ô "Ghi chú" không được đọc rõ ràng nên anh tưởng đó là ô địa chỉ.
Đội sửa lại: đưa label hiển thị cố định lên trên mỗi ô, gắn for/id đầy đủ, chuyển các gợi ý định dạng (ví dụ "gồm cả số nhà và tên đường") sang phần tử aria-describedby. Tỷ lệ điền sai trường giảm rõ rệt, và số cuộc gọi khiếu nại về "đơn hàng sai địa chỉ" giảm hẳn trong tháng tiếp theo.
Bài học: Placeholder-only trông đẹp trong ảnh tĩnh nhưng phá vỡ trải nghiệm ngay khi người dùng tương tác thật. Label hiển thị cố định là khoản đầu tư rẻ nhất cho conversion.
Ví dụ 2 — Form đăng ký khám bệnh: lỗi báo mà screen reader không hề biết
Một phòng khám tư ở TP.HCM triển khai trang đặt lịch khám online. Khi người dùng nhấn "Xác nhận" mà thiếu ngày sinh, form hiển thị dòng chữ đỏ nhỏ dưới ô ngày sinh và cuộn trang xuống cuối. Với người dùng thị lực bình thường, ổn. Nhưng một bệnh nhân lớn tuổi dùng screen reader nhấn "Xác nhận", nghe thấy... không gì cả. Màn hình có đổi, nhưng screen reader không đọc bất cứ thông báo nào. Anh tưởng đã đặt lịch thành công, đóng trình duyệt, và hôm sau đến phòng khám mà không có lịch hẹn.
Nguyên nhân kỹ thuật: thông báo lỗi được chèn vào DOM bằng JavaScript nhưng không nằm trong vùng live region, không có role="alert", và không được liên kết với ô ngày sinh qua aria-describedby. Screen reader không có cách nào biết rằng có nội dung mới xuất hiện.
Đội kỹ thuật sửa lại: đặt một vùng tổng hợp lỗi ở đầu form với role="alert" ("Có 1 lỗi cần sửa: Vui lòng nhập ngày sinh"), gắn aria-invalid="true" và aria-describedby cho ô ngày sinh, và tự động chuyển focus vào ô lỗi đầu tiên sau khi submit. Lần thử nghiệm sau, người dùng screen reader nghe ngay thông báo lỗi và được đưa thẳng đến ô cần sửa.
Bài học: Lỗi mà screen reader không đọc được cũng nguy hiểm như không báo lỗi. Xử lý lỗi accessible không phải "tính năng phụ" — nó là điều kiện để form dùng được.
Ví dụ 3 — Form khảo sát nội bộ: nhóm radio thiếu legend
Một công ty fintech ở Singapore làm khảo sát nội bộ về mức độ hài lòng nhân viên. Form có nhiều nhóm radio: "Mức độ hài lòng với môi trường làm việc" (5 mức từ Rất không hài lòng đến Rất hài lòng), lặp lại cho nhiều khía cạnh. Về mặt hình ảnh, tiêu đề mỗi nhóm được để bằng thẻ <div> in đậm phía trên các nút radio.
Một nhân viên khiếm thị dùng NVDA phản hồi: khi tab qua từng nút radio, anh chỉ nghe "Rất hài lòng, radio button", "Hài lòng, radio button"... mà không biết đang đánh giá khía cạnh nào, vì tiêu đề <div> không được screen reader gắn với nhóm. Anh phải điều hướng ngược lên đọc tiêu đề rồi nhớ trong đầu — cực kỳ mệt với một form dài.
Đội sửa bằng cách bọc mỗi nhóm trong <fieldset> và đổi tiêu đề <div> thành <legend>. Sau đó screen reader đọc "Mức độ hài lòng với môi trường làm việc, Rất hài lòng, radio button 5 of 5" — ngữ cảnh đầy đủ ở mỗi lựa chọn.
Bài học: fieldset + legend là công cụ HTML gốc, miễn phí, giải quyết chính xác vấn đề ngữ cảnh nhóm mà không cần một dòng ARIA nào.
Hướng dẫn từng bước
Đây là quy trình xây dựng một form accessible từ đầu:
- Bắt đầu bằng HTML ngữ nghĩa đúng. Dùng đúng
typecho input (email,tel,number,date...). Nó giúp bàn phím ảo trên mobile hiện đúng layout và hỗ trợ autofill.
- Gắn một
<label>hiển thị cho mỗi input, liên kết bằngfor/id. Kiểm tra: bấm vào chữ label có focus vào ô không? Nếu có, liên kết đúng.
- Thêm hướng dẫn bằng
aria-describedbycho các trường cần giải thích định dạng hoặc quy tắc. Không nhồi hết vào placeholder.
- Nhóm các trường liên quan (radio, checkbox, hoặc các cụm như "Địa chỉ") bằng
<fieldset>và<legend>.
- Đánh dấu trường bắt buộc bằng thuộc tính
required, và giải thích ý nghĩa dấu * ở đầu form. Đừng dựa vào màu sắc đơn thuần.
- Thiết kế xử lý lỗi accessible:
role="alert", liệt kê số lỗi và link tới từng trường.
- Mỗi ô lỗi có aria-invalid="true" và aria-describedby trỏ tới message.
- Chuyển focus vào ô lỗi đầu tiên sau khi submit thất bại.
- Message lỗi có cả icon/text, không chỉ màu đỏ.- Đảm bảo thứ tự tab hợp lý — người dùng bàn phím phải đi qua các trường theo đúng trình tự trực quan (chi tiết về keyboard navigation ở Bài 11).
- Kiểm thử thật: tab qua toàn bộ form chỉ bằng bàn phím; bật screen reader (VoiceOver trên macOS, NVDA trên Windows) và điền form; cố tình submit sai để nghe cách lỗi được đọc.
Lỗi thường gặp & mẹo
- Chỉ dùng placeholder làm label. Lỗi phổ biến nhất. Luôn có label hiển thị cố định.
- Dùng
<div>hoặc<span>làm nhãn thay<label>. Trông giống nhau nhưng screen reader không liên kết được. Luôn dùng thẻ<label>thật vớifor/id. - Đặt
forsaiid, hoặc trùngidtrên nhiều phần tử. Mỗiidphải là duy nhất trong trang. Sai một ký tự là mất liên kết. - Báo lỗi chỉ bằng màu đỏ. Người mù màu và người khiếm thị không nhận ra. Luôn kèm chữ + icon.
- Lỗi hiển thị bằng JS nhưng không có live region. Screen reader không đọc. Dùng
role="alert"hoặcaria-live. - Disable nút submit cho tới khi form "hợp lệ". Nghe có vẻ thân thiện nhưng người dùng screen reader không hiểu vì sao nút bị khóa. Tốt hơn: để nút luôn bấm được, và báo lỗi rõ ràng khi họ submit.
- Placeholder có contrast quá thấp. Nếu bạn vẫn dùng placeholder làm gợi ý phụ (bên cạnh label), đảm bảo nó đủ tương phản.
Tab, Shift+Tab, Space, Enter, mũi tên. Nếu bạn làm được trơn tru, người dùng bàn phím cũng vậy. Sau đó bật screen reader và làm lại — bạn sẽ nghe ngay những trường "câm" không có tên.Bài tập thực hành
- Sửa một form thật. Lấy một form đăng ký/đăng nhập bất kỳ (của bạn hoặc một trang bạn hay dùng), mở DevTools, và kiểm tra: mỗi input có
<label>liên kếtfor/idkhông? Nếu thiếu, viết lại HTML cho đúng.
- Xây form địa chỉ giao hàng gồm: Họ tên (bắt buộc), Số điện thoại (bắt buộc, có hint định dạng qua
aria-describedby), Địa chỉ (bọc trongfieldset/legend), và một nhóm radio "Hình thức nhận hàng". Yêu cầu: không dùng placeholder thay label.
- Thiết kế luồng xử lý lỗi. Với form ở bài 2, viết JavaScript giả lập: khi submit thiếu trường bắt buộc, hiển thị vùng
role="alert"ở đầu form liệt kê lỗi, gắnaria-invalidvàaria-describedbycho từng ô lỗi, và chuyển focus vào ô lỗi đầu tiên.
- Kiểm thử bằng screen reader. Bật VoiceOver (Cmd+F5 trên macOS) hoặc NVDA, điền form của bạn từ đầu đến cuối. Ghi lại: có trường nào bị đọc "câm" (không có tên) không? Có lỗi nào không được đọc không? Sửa cho tới khi mọi trường và mọi lỗi đều được thông báo rõ.
Tóm tắt
Form là nơi accessibility trở thành ranh giới giữa "hoàn thành được việc" và "hoàn toàn bế tắc". Những điểm cốt lõi cần nhớ:
- Mỗi input có một
<label>hiển thị cố định, liên kết bằngfor/id. Không bao giờ dùng placeholder thay label. - Hướng dẫn nhập liệu đặt trong phần tử riêng, liên kết bằng
aria-describedby. - Nhóm trường liên quan bằng
<fieldset>và<legend>để cung cấp ngữ cảnh. - Trường bắt buộc dùng
required; đừng chỉ dựa vào dấu * màu đỏ. - Xử lý lỗi accessible là phần quan trọng nhất: liên kết message với input qua
aria-describedby, đánh dấuaria-invalid, thông báo tổng hợp trongrole="alert", chuyển focus vào ô lỗi đầu tiên, và không bao giờ báo lỗi chỉ bằng màu sắc.