Bỏ qua đến nội dung chính
accessibilitya11yARIAkeyboardfrontend

Accessibility (a11y): ARIA, Keyboard Navigation Cho Dev

Accessibility (a11y) cơ bản: ARIA attribute, keyboard navigation, focus management, color contrast. Code React example, audit với axe-core.

Xuất bản 8 phút đọc

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/accordion
  • aria-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ímAction
TabMove focus tới element interactive tiếp theo
Shift+TabMove focus lùi
EnterActivate button, link, submit form
SpaceActivate button, toggle checkbox
EscClose modal, dropdown
ArrowNavigate 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.

Zalo