Tối ưu khả năng truy cập trong React: HTML ngữ nghĩa, ARIA và kiểm thử tự động

07 tháng 4, 2026·4 phút đọc

Khả năng truy cập (Accessibility) không phải là tính năng thêm vào sau cùng, mà là tín hiệu chất lượng phản ánh cấu trúc mã tốt hơn. Bài viết này hướng dẫn cách triển khai thực tế các kỹ thuật Semantic HTML, ARIA, quản lý Focus và kiểm thử tự động với axe trong React.

Tối ưu khả năng truy cập trong React: HTML ngữ nghĩa, ARIA và kiểm thử tự động

Khả năng truy cập web (Web Accessibility) không đơn thuần là một tính năng bạn thêm vào sau khi đã hoàn thành ứng dụng. Thực tế, nó là một tín hiệu chất lượng cho thấy code của bạn được tổ chức tốt hơn. Những ứng dụng có cấu trúc chặt chẽ và dễ tiếp cận thường sở hữu ngữ nghĩa rõ ràng, hoạt động bàn phím mượt mà và hiệu suất cao hơn. Dưới đây là hướng dẫn triển khai thực tế trong React.

Ưu tiên HTML ngữ nghĩa (Semantic HTML First)

Hầu hết các vấn đề về khả năng truy cập nhìn có vẻ phức tạp thực chất thường chỉ là vấn đề về việc sử dụng đúng HTML ngữ nghĩa:

// Tệ -- lạm dụng thẻ div, thiếu ngữ nghĩa
<div className='header'>
  <div className='nav'>
    <div onClick={handleClick}>Home</div>
    <div onClick={handleClick}>About</div>
  </div>
</div>

// Tốt -- sử dụng HTML ngữ nghĩa, tự động hỗ trợ bàn phím
<header>
  <nav aria-label='Main navigation'>
    <ul>
      <li><a href='/'>Home</a></li>
      <li><a href='/about'>About</a></li>
    </ul>
  </nav>
</header>

Các phần tử mang tính biểu tượng (landmark elements) như <header>, <nav>, <main>, <aside>, và <footer> cung cấp cách thức để người dùng sử dụng bộ đọc màn hình (screen reader) có thể nhảy nhanh giữa các phần khác nhau của trang web.

ARIA: Khi nào nên và không nên dùng

Các thuộc tính ARIA giúp bổ sung ý nghĩa ngữ nghĩa khi HTML đơn thuần là chưa đủ. Tuy nhiên, cần lưu ý rằng ARIA không tự động thêm hành vi — bạn phải tự viết code xử lý tương tác bàn phím:

// Dropdown tùy chỉnh -- yêu cầu cả ARIA và xử lý bàn phím
function Dropdown({ label, options, value, onChange }) {
  const [isOpen, setIsOpen] = useState(false)
  const listboxId = useId()

  return (
    <div>
      <button
        aria-haspopup='listbox'
        aria-expanded={isOpen}
        aria-controls={listboxId}
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={(e) => {
          if (e.key === 'Escape') setIsOpen(false)
          if (e.key === 'ArrowDown') { setIsOpen(true); /* focus vào mục đầu tiên */ }
        }}
      >
        {value || label}
      </button>
      {isOpen && (
        <ul id={listboxId} role='listbox' aria-label={label}>
          {options.map(opt => (
            <li
              key={opt.value}
              role='option'
              aria-selected={value === opt.value}
              onClick={() => { onChange(opt.value); setIsOpen(false) }}
              tabIndex={0}
            >
              {opt.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

Quy tắc vàng là: nếu một phần tử HTML gốc (native element) như <select>, <button>, hay <a> làm được việc bạn cần, hãy dùng nó. ARIA chỉ nên dùng cho các widget tùy chỉnh mà HTML không hỗ trợ sẵn.

Quản lý Focus (Focus Management)

Đối với các thành phần như hộp thoại (dialog/modal), việc quản lý focus là cực kỳ quan trọng để người dùng không bị lạc hướng khi điều hướng bằng bàn phím.

// Dialog -- giữ focus bên trong khi mở
import { useEffect, useRef } from 'react'

function Dialog({ isOpen, onClose, children }) {
  const dialogRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!isOpen) return

    // Focus vào dialog khi mở
    dialogRef.current?.focus()

    // Bẫy focus (Trap focus) bên trong dialog
    const focusable = dialogRef.current?.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
    const first = focusable?.[0] as HTMLElement
    const last = focusable?.[focusable.length - 1] as HTMLElement

    const trap = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return
      if (e.shiftKey ? document.activeElement === first : document.activeElement === last) {
        e.preventDefault()
        ;(e.shiftKey ? last : first)?.focus()
      }
    }

    document.addEventListener('keydown', trap)
    return () => document.removeEventListener('keydown', trap)
  }, [isOpen])

  return (
    <div
      ref={dialogRef}
      role='dialog'
      aria-modal='true'
      tabIndex={-1}
      onKeyDown={(e) => e.key === 'Escape' && onClose()}
    >
      {children}
    </div>
  )
}

Tương phản màu sắc và Thiết kế trực quan

Tiêu chuẩn WCAG AA yêu cầu tỷ lệ tương phản ít nhất là 4.5:1 cho văn bản thông thường và 3:1 cho văn bản to:

// Kiểm tra tương phản trong cấu hình Tailwind của bạn
// Gray-500 trên nền trắng: 3.95:1 -- KHÔNG ĐẠT tiêu chuẩn AA cho văn bản thường
// Gray-700 trên nền trắng: 8.59:1 -- ĐẠT tiêu chuẩn AAA

// Đừng chỉ dựa vào màu sắc để truyền tải thông tin
// Tệ: màu đỏ báo lỗi (người mù màu không thấy được)
// Tốt: màu đỏ + biểu tượng + đoạn văn thông báo lỗi
<div className='flex items-center gap-2 text-red-600'>
  <AlertCircle className='w-4 h-4' aria-hidden='true' />
  <span role='alert'>Email không hợp lệ</span>
</div>

Kiểm thử tự động với axe

Tự động hóa việc kiểm tra khả năng truy cập giúp bạn bắt lỗi sớm trong quy trình phát triển.

npm install -D @axe-core/react jest-axe
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)

it('không có vi phạm về khả năng truy cập', async () => {
  const { container } = render(<LoginForm />)
  const results = await axe(container)
  expect(results).toHaveNoViolations()
})

Hãy chạy axe trong quy trình CI (Continuous Integration) để bắt các lỗi hồi quy (regressions) trước khi chúng đến tay người dùng.

Ghi chú: Các bộ khởi động (starter kit) SaaS AI chất lượng cao hiện nay thường tích hợp sẵn cấu trúc HTML ngữ nghĩa, nhãn ARIA chuẩn xác cho các yếu tố tương tác và tích hợp axe-core vào bộ kiểm thử, giúp các nhà phát triển tiết kiệm thời gian xây dựng nền tảng bền vững.

Bài viết được tổng hợp và biên soạn bằng AI từ các nguồn tin tức công nghệ. Nội dung mang tính tham khảo. Xem bài gốc ↗