Accessibility (a11y) không phải nice-to-have. 15% user có khuyết tật, EU/US có luật bắt buộc, và Google đang tăng signal a11y trong ranking. Bài này code React thực tế: ARIA, keyboard, focus, contrast — đủ để build app inclusive.
Semantic HTML — luôn ưu tiên
<!-- ❌ Sai — div role button -->
<div role="button" onClick={save} tabIndex={0} onKeyDown={handleKey}>
Save
</div>
<!-- ✓ Đúng — semantic button có sẵn role, keyboard, focus -->
<button onClick={save}>Save</button>
Semantic element có:
- Role tự động (button, link, heading, list)
- Keyboard support tự động (Enter/Space cho button, Enter cho link)
- Focus indicator built-in
- Screen reader announce đúng
Đừng tự build <div> với role="button" trừ khi không có cách khác.
ARIA khi nào cần?
ARIA là phụ trợ — fill gap khi semantic HTML không cover:
// Custom dropdown
<button
aria-haspopup="listbox"
aria-expanded={open}
aria-controls="listbox-1"
>
{selected ?? 'Chọn'}
</button>
<ul id="listbox-1" role="listbox" hidden={!open}>
{items.map(it => (
<li key={it.id} role="option" aria-selected={it.id === selected}>
{it.name}
</li>
))}
</ul>
Quy tắc đơn giản:
aria-label: text alt cho element không có text visible (icon button)aria-describedby: ID của element mô tả thêm (helper text)aria-expanded: trạng thái dropdown/accordionaria-live="polite": vùng announce thay đổi (toast, error message)
Label cho form input
// ❌ placeholder thay label
<input type="email" placeholder="Email" />
// ✓ Visible label
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// ✓ Hoặc wrap
<label>
Email
<input type="email" />
</label>
// ✓ Icon button — aria-label
<button aria-label="Đóng modal" onClick={close}>
<XIcon />
</button>
Keyboard navigation
Mọi tương tác chuột phải có equivalent keyboard:
| Phím | Action |
|---|---|
| Tab | Move focus tới element interactive tiếp theo |
| Shift+Tab | Move focus lùi |
| Enter | Activate button, link, submit form |
| Space | Activate button, toggle checkbox |
| Esc | Close modal, dropdown |
| Arrow | Navigate trong list, menu, slider |
Test: rút chuột, dùng Tab navigate toàn site. Nếu không reach element nào → bug.
Focus management
import { useEffect, useRef } from 'react'
function Modal({ isOpen, onClose, children }) {
const closeBtnRef = useRef<HTMLButtonElement>(null)
const triggerRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
triggerRef.current = document.activeElement as HTMLElement
closeBtnRef.current?.focus() // focus first element in modal
} else {
triggerRef.current?.focus() // return focus to trigger
}
}, [isOpen])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
if (isOpen) document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div role="dialog" aria-modal="true" aria-labelledby="title">
<h2 id="title">Confirm</h2>
<button ref={closeBtnRef} onClick={onClose} aria-label="Đóng">×</button>
{children}
</div>
)
}
Production dùng library: react-focus-lock hoặc Radix UI Dialog — focus trap built-in.
Color contrast
/* ❌ Quá nhạt, contrast 2.5:1 */
.subtle { color: #ccc; background: white; }
/* ✓ AA pass: 4.5:1+ */
.subtle { color: #6b7280; background: white; } /* 4.7:1 */
Tool check: Chrome DevTools → Inspect → Styles → click color square → "Contrast" panel. Hoặc Lighthouse audit.
Screen reader-only text
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
// Icon button có visible icon nhưng screen reader cần text
<button>
<TrashIcon aria-hidden="true" />
<span className="sr-only">Xoá item</span>
</button>
Live region cho dynamic update
function Toast({ message }) {
return (
<div role="status" aria-live="polite">
{message}
</div>
)
}
// Form error
<p role="alert" aria-live="assertive">
Email không hợp lệ
</p>
polite = đợi user dừng nói rồi announce. assertive = cắt ngang ngay (cho error nghiêm trọng).
Audit toolchain
npm install -D @axe-core/react eslint-plugin-jsx-a11y
// main.tsx — auto check ở dev
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then(axe => axe.default(React, ReactDOM, 1000))
}
ESLint plugin catch lỗi thường gặp ở compile-time (alt missing, htmlFor missing).
Kết luận
Accessibility không phức tạp — semantic HTML + label đúng + keyboard + contrast cover 90%. Setup axe-core + a11y ESLint từ đầu, audit mỗi component lớn. Build inclusive là nghề chuyên nghiệp, không phải làm thêm. Đọc thêm UI/UX là gì để hiểu vai trò designer trong a11y.