Accessibility không phải tính năng thêm, mà là cách build mặc định.
- Semantic HTML trước tiên: dùng
<button>,<nav>,<main>,<label>thay vì<div onClick>. Native element có sẵn role, keyboard handling, focus — ARIA chỉ để vá khi không có element phù hợp. "No ARIA is better than bad ARIA." - ARIA khi cần:
aria-label,aria-expanded,role="dialog",aria-livecho nội dung động (toast, validation). ARIA chỉ đổi cách screen reader đọc, không thêm hành vi. - Focus management: khi mở modal phải move focus vào trong và trap nó; khi đóng phải trả focus về trigger. Route change nên focus heading. Đừng để focus rơi vào
<body>. - useId cho label: tạo id ổn định, duy nhất, khớp giữa server và client (SSR) để nối
<label htmlFor>với<input id>hayaria-describedby. Đừng dùng cho key trong list.
tsx
function Field({ label }) {
const id = useId()
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} aria-describedby={`${id}-hint`} />
<p id={`${id}-hint`}>Hint text</p>
</>
)
}Accessibility isn't an add-on; it's the default way you build.
- Semantic HTML first: use
<button>,<nav>,<main>,<label>instead of<div onClick>. Native elements ship roles, keyboard handling and focus — ARIA only patches when no element fits. "No ARIA is better than bad ARIA." - ARIA when needed:
aria-label,aria-expanded,role="dialog",aria-livefor dynamic content (toasts, validation). ARIA only changes how a screen reader announces — it adds no behavior. - Focus management: opening a modal must move focus inside and trap it; closing must return focus to the trigger. Route changes should focus the heading. Don't let focus fall to
<body>. - useId for labels: generates a stable, unique id matching across server and client (SSR) to wire
<label htmlFor>to<input id>oraria-describedby. Don't use it for list keys.
tsx
function Field({ label }) {
const id = useId()
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} aria-describedby={`${id}-hint`} />
<p id={`${id}-hint`}>Hint text</p>
</>
)
}