Skip to content

Split View: 웹 접근성(a11y) 완전 가이드 2025: WCAG 2.2, ARIA, 키보드 내비게이션 — 모두를 위한 웹

✨ Learn with Quiz
|

웹 접근성(a11y) 완전 가이드 2025: WCAG 2.2, ARIA, 키보드 내비게이션 — 모두를 위한 웹

1. 왜 접근성인가

10억 명의 사용자

전 세계 인구의 약 15%, 약 10억 명이 어떤 형태로든 장애를 가지고 있습니다. 웹 접근성은 단순히 "좋은 일"이 아니라 비즈니스 필수 사항입니다.

장애 유형전 세계 인구웹에서의 영향
시각 장애2.2억 명스크린 리더, 확대기 사용
청각 장애4.66억 명자막, 수화 필요
운동 장애수억 명키보드, 음성 입력
인지 장애다양단순한 UI, 명확한 언어

법적 요구사항

  • 미국 ADA: 웹사이트도 "공공 편의시설"로 간주. 소송 급증
  • EU EAA (European Accessibility Act): 2025년 6월 시행. 디지털 서비스 필수
  • 한국 장애인차별금지법: 웹 접근성 의무. 공공기관 + 민간 확대
  • WCAG 2.2: 국제 표준. 대부분의 법률이 AA 레벨을 요구

비즈니스 가치

접근성은 비용이 아니라 투자입니다.

  • SEO 개선: 시맨틱 HTML과 alt 텍스트는 검색 엔진이 좋아합니다
  • 사용자 확대: 전체 인구의 15%에게 도달
  • 법적 리스크 감소: 소송 비용 대비 사전 투자가 훨씬 저렴
  • 일반 사용자 경험 향상: 키보드 단축키, 명확한 레이블은 모든 사용자에게 유익

2. WCAG 2.2: 4가지 원칙

POUR 원칙

WCAG는 4가지 핵심 원칙 POUR(Perceivable, Operable, Understandable, Robust)을 기반으로 합니다.

원칙의미예시
Perceivable (인지 가능)정보를 인지할 수 있어야 함alt 텍스트, 자막, 색상 대비
Operable (운용 가능)UI를 조작할 수 있어야 함키보드, 충분한 시간, 발작 방지
Understandable (이해 가능)콘텐츠와 UI를 이해할 수 있어야 함명확한 언어, 일관된 내비게이션
Robust (견고)다양한 기술에서 작동해야 함유효한 HTML, ARIA 호환

적합성 레벨

  • Level A: 최소 요구사항 (필수)
  • Level AA: 대부분의 법률이 요구하는 수준 (권장 목표)
  • Level AAA: 최고 수준 (전체 사이트에 적용하기 어려움)

WCAG 2.2의 새로운 성공 기준

WCAG 2.2는 2023년 10월에 발표되었으며, 다음과 같은 새로운 기준이 추가되었습니다.

성공 기준레벨설명
2.4.11 Focus Not Obscured (Minimum)AA포커스된 요소가 다른 콘텐츠에 완전히 가려지면 안 됨
2.4.12 Focus Not Obscured (Enhanced)AAA포커스된 요소가 부분적으로도 가려지면 안 됨
2.4.13 Focus AppearanceAAA포커스 인디케이터 크기와 대비 요구사항
2.5.7 Dragging MovementsAA드래그가 필요한 기능에 대안 제공
2.5.8 Target Size (Minimum)AA터치 대상 최소 24x24 CSS 픽셀
3.2.6 Consistent HelpA도움말 메커니즘이 일관된 위치에
3.3.7 Redundant EntryA이전에 입력한 정보를 다시 요구하지 않음
3.3.8 Accessible AuthenticationAA인지 기능 테스트 없는 인증
3.3.9 Accessible Authentication (Enhanced)AAA더 엄격한 인증 접근성

3. 시맨틱 HTML: 접근성의 기초

올바른 요소 사용

시맨틱 HTML은 접근성의 80%를 해결합니다. 스크린 리더는 HTML 요소의 의미를 이해합니다.

<!-- 나쁨: div로 모든 것을 만들기 -->
<div class="button" onclick="submit()">제출</div>
<div class="header">사이트 제목</div>
<div class="nav">
  <div class="link" onclick="goto('/')"></div>
</div>

<!-- 좋음: 시맨틱 요소 사용 -->
<button type="submit">제출</button>
<header><h1>사이트 제목</h1></header>
<nav>
  <a href="/"></a>
</nav>

<button>은 자동으로 키보드 포커스를 받고, Enter/Space로 활성화되며, 스크린 리더가 "버튼"으로 인식합니다. <div>는 이 모든 것을 수동으로 구현해야 합니다.

랜드마크(Landmarks)

<body>
  <header>
    <nav aria-label="메인 내비게이션">...</nav>
  </header>

  <main>
    <article>
      <h1>기사 제목</h1>
      <section aria-labelledby="section-1">
        <h2 id="section-1">섹션 1</h2>
        ...
      </section>
    </article>

    <aside aria-label="관련 링크">...</aside>
  </main>

  <footer>...</footer>
</body>

스크린 리더 사용자는 랜드마크 간에 빠르게 이동할 수 있습니다. VoiceOver에서 Rotor를 사용하면 header, nav, main, footer로 즉시 점프합니다.

제목 계층 구조

<!-- 나쁨: 제목 레벨 건너뛰기 -->
<h1>페이지 제목</h1>
<h3>하위 섹션</h3>  <!-- h2를 건너뜀! -->
<h5>세부 항목</h5>   <!-- h4를 건너뜀! -->

<!-- 좋음: 순차적 제목 구조 -->
<h1>페이지 제목</h1>
  <h2>섹션 A</h2>
    <h3>하위 섹션 A-1</h3>
    <h3>하위 섹션 A-2</h3>
  <h2>섹션 B</h2>
    <h3>하위 섹션 B-1</h3>

스크린 리더 사용자의 67%가 제목으로 페이지를 탐색합니다. 제목 레벨을 건너뛰면 문서 구조를 파악하기 어렵습니다.


4. ARIA: 접근성 의미 추가

ARIA의 첫 번째 규칙

ARIA의 첫 번째 규칙: ARIA를 사용하지 마세요. 네이티브 HTML 요소로 충분하다면 ARIA가 필요 없습니다.

<!-- ARIA 불필요: 네이티브 요소로 충분 -->
<button>삭제</button>                    <!-- role="button" 불필요 -->
<input type="checkbox" />                <!-- role="checkbox" 불필요 -->
<nav>                                    <!-- role="navigation" 불필요 -->

<!-- ARIA 필요: 네이티브 요소가 없는 경우 -->
<div role="tablist">
  <button role="tab" aria-selected="true">탭 1</button>
  <button role="tab" aria-selected="false">탭 2</button>
</div>
<div role="tabpanel">탭 1 내용</div>

핵심 ARIA 속성

<!-- aria-label: 시각적 텍스트가 없는 요소에 레이블 제공 -->
<button aria-label="메뉴 닫기">
  <svg><!-- X 아이콘 --></svg>
</button>

<!-- aria-labelledby: 다른 요소의 텍스트를 레이블로 참조 -->
<h2 id="cart-heading">장바구니</h2>
<ul aria-labelledby="cart-heading">
  <li>상품 1</li>
  <li>상품 2</li>
</ul>

<!-- aria-describedby: 추가 설명 연결 -->
<input
  type="password"
  aria-describedby="pw-hint"
/>
<p id="pw-hint">8자 이상, 특수문자 포함</p>

<!-- aria-live: 동적 콘텐츠 변경 알림 -->
<div aria-live="polite">
  장바구니에 3개 상품이 있습니다.
</div>

<!-- aria-expanded: 확장/축소 상태 -->
<button aria-expanded="false" aria-controls="menu">
  메뉴
</button>
<ul id="menu" hidden>...</ul>

<!-- aria-hidden: 스크린 리더에서 숨기기 -->
<span aria-hidden="true">🔥</span>
<span class="sr-only">인기</span>

Live Regions

<!-- aria-live="polite": 현재 읽기를 마친 후 알림 -->
<div aria-live="polite" aria-atomic="true">
  검색 결과: 42건
</div>

<!-- aria-live="assertive": 즉시 알림 (에러 등) -->
<div role="alert" aria-live="assertive">
  세션이 만료되었습니다. 다시 로그인해 주세요.
</div>

<!-- role="status": 상태 메시지 (polite와 유사) -->
<div role="status">
  파일 업로드 완료
</div>

<!-- role="log": 채팅 메시지 등 -->
<div role="log" aria-live="polite">
  <!-- 새 메시지가 추가됨 -->
</div>

5. 키보드 접근성

포커스 관리의 기본

모든 대화형 요소는 키보드로 접근 가능해야 합니다.

동작
Tab다음 포커스 가능한 요소로 이동
Shift + Tab이전 포커스 가능한 요소로 이동
Enter링크 활성화, 버튼 클릭
Space버튼 클릭, 체크박스 토글
Escape모달/팝업 닫기
화살표 키메뉴, 탭, 라디오 그룹 내 이동
<!-- 페이지 최상단에 배치 -->
<a href="#main-content" class="skip-link">
  본문으로 건너뛰기
</a>

<nav>
  <!-- 긴 내비게이션 메뉴 -->
</nav>

<main id="main-content" tabindex="-1">
  <!-- 메인 콘텐츠 -->
</main>
.skip-link {
  position: absolute;
  left: -9999px;
  top: auto;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.skip-link:focus {
  position: fixed;
  top: 10px;
  left: 10px;
  width: auto;
  height: auto;
  padding: 12px 24px;
  background: #000;
  color: #fff;
  z-index: 9999;
  font-size: 1rem;
}

Focus Trap (포커스 트랩)

모달이 열렸을 때 포커스가 모달 안에서만 순환해야 합니다.

function useFocusTrap(containerRef: React.RefObject<HTMLElement>) {
  useEffect(() => {
    const container = containerRef.current
    if (!container) return

    const focusableSelector = [
      'a[href]',
      'button:not([disabled])',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])',
    ].join(', ')

    const focusableElements = container.querySelectorAll(focusableSelector)
    const firstElement = focusableElements[0] as HTMLElement
    const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key !== 'Tab') return

      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault()
          lastElement.focus()
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault()
          firstElement.focus()
        }
      }
    }

    container.addEventListener('keydown', handleKeyDown)
    firstElement?.focus()

    return () => container.removeEventListener('keydown', handleKeyDown)
  }, [containerRef])
}

Tab 순서 관리

<!-- tabindex 값 -->
<!-- tabindex="0": 자연스러운 순서에 포함 -->
<div role="button" tabindex="0">커스텀 버튼</div>

<!-- tabindex="-1": 프로그래밍 방식으로만 포커스 가능 -->
<div id="error-message" tabindex="-1">에러 발생!</div>

<!-- tabindex 양수 사용 금지! 순서가 꼬입니다 -->
<!-- 나쁨: tabindex="1", tabindex="2", tabindex="3" -->

6. 색상과 대비

대비율 요구사항

텍스트 종류AA 레벨AAA 레벨
일반 텍스트 (14px 미만)4.5:17:1
큰 텍스트 (18px 이상 또는 14px 볼드)3:14.5:1
UI 컴포넌트, 그래픽3:1-

CSS로 대비 확보

/* 좋은 대비: #333 on #fff = 12.63:1 */
body {
  color: #333333;
  background-color: #ffffff;
}

/* 링크: 주변 텍스트와 3:1 대비 + 밑줄 또는 다른 시각적 단서 */
a {
  color: #0066cc;          /* #333과 3:1 이상 대비 */
  text-decoration: underline;
}

/* 포커스 인디케이터: 3:1 대비 필수 */
:focus-visible {
  outline: 3px solid #1a73e8;
  outline-offset: 2px;
}

/* 다크 모드에서도 대비 유지 */
@media (prefers-color-scheme: dark) {
  body {
    color: #e0e0e0;        /* #121212와 13.28:1 */
    background-color: #121212;
  }

  a {
    color: #8ab4f8;        /* #e0e0e0와 3:1 이상 */
  }
}

색맹 대응

/* 색상만으로 정보를 전달하지 않기 */

/* 나쁨: 색상만으로 에러 표시 */
.error-field {
  border-color: red;
}

/* 좋음: 색상 + 아이콘 + 텍스트 */
.error-field {
  border-color: #d32f2f;
  border-width: 2px;
}

.error-field::before {
  content: "⚠ ";
}

.error-message {
  color: #d32f2f;
  font-weight: bold;
}

대비 확인 도구

  • Chrome DevTools: 요소 검사 시 대비 비율 표시
  • axe DevTools: 전체 페이지 대비 검사
  • Colour Contrast Analyser (CCA): 독립 실행형 도구
  • Stark: Figma/Sketch 플러그인

7. 이미지와 미디어

alt 텍스트 가이드

<!-- 정보성 이미지: 내용을 설명 -->
<img src="chart.png" alt="2025년 매출 추이: 1분기 100만, 2분기 150만, 3분기 200만" />

<!-- 장식성 이미지: 빈 alt -->
<img src="decorative-line.png" alt="" />

<!-- 기능성 이미지 (링크/버튼): 동작을 설명 -->
<a href="/home">
  <img src="logo.png" alt="홈으로 이동" />
</a>

<!-- 복잡한 이미지: 긴 설명 제공 -->
<figure>
  <img src="infographic.png" alt="접근성 통계 인포그래픽" aria-describedby="info-desc" />
  <figcaption id="info-desc">
    전 세계 10억 명이 장애를 가지고 있으며,
    웹사이트의 97%가 접근성 오류를 포함합니다.
    가장 흔한 오류는 낮은 색상 대비(83%)입니다.
  </figcaption>
</figure>

<!-- SVG 접근성 -->
<svg role="img" aria-labelledby="svg-title">
  <title id="svg-title">다운로드 아이콘</title>
  <path d="..." />
</svg>

비디오 접근성

<video controls>
  <source src="tutorial.mp4" type="video/mp4" />
  <!-- 자막 (캡션) -->
  <track kind="captions" src="captions-ko.vtt" srclang="ko" label="한국어" default />
  <track kind="captions" src="captions-en.vtt" srclang="en" label="English" />
  <!-- 음성 해설 -->
  <track kind="descriptions" src="descriptions-ko.vtt" srclang="ko" label="음성 해설" />
</video>

오디오 콘텐츠

모든 오디오 콘텐츠에는 텍스트 대안(트랜스크립트)이 필요합니다.


8. 폼 접근성

레이블과 입력 연결

<!-- 방법 1: for/id 연결 (권장) -->
<label for="email">이메일 주소</label>
<input type="email" id="email" name="email" autocomplete="email" />

<!-- 방법 2: label로 감싸기 -->
<label>
  이메일 주소
  <input type="email" name="email" autocomplete="email" />
</label>

<!-- 방법 3: aria-labelledby -->
<span id="email-label">이메일 주소</span>
<input type="email" aria-labelledby="email-label" autocomplete="email" />

에러 메시지와 유효성 검사

<div class="form-group">
  <label for="password">비밀번호</label>
  <input
    type="password"
    id="password"
    aria-describedby="pw-requirements pw-error"
    aria-invalid="true"
    autocomplete="new-password"
  />
  <p id="pw-requirements" class="hint">
    8자 이상, 대소문자, 숫자, 특수문자 포함
  </p>
  <p id="pw-error" class="error" role="alert">
    비밀번호가 요구사항을 충족하지 않습니다.
  </p>
</div>

필수 필드

<!-- aria-required + 시각적 표시 -->
<label for="name">
  이름 <span aria-hidden="true" class="required">*</span>
</label>
<input
  type="text"
  id="name"
  required
  aria-required="true"
  autocomplete="name"
/>
<p class="form-note">* 표시는 필수 항목입니다</p>

자동 완성 (autocomplete)

<!-- WCAG 1.3.5: autocomplete 속성 사용 -->
<input type="text" autocomplete="given-name" />  <!-- 이름 -->
<input type="text" autocomplete="family-name" />  <!-- 성 -->
<input type="email" autocomplete="email" />        <!-- 이메일 -->
<input type="tel" autocomplete="tel" />            <!-- 전화번호 -->
<input type="text" autocomplete="street-address" /> <!-- 주소 -->

9. React/Next.js 접근성 패턴

SPA에서의 포커스 관리

SPA(Single Page Application)에서는 페이지 전환 시 포커스가 자동으로 이동하지 않습니다.

// 라우트 변경 시 포커스 이동
function useRouteAnnounce() {
  const pathname = usePathname()
  const announceRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    // 메인 콘텐츠로 포커스 이동
    const main = document.querySelector('main')
    if (main) {
      main.setAttribute('tabindex', '-1')
      main.focus()
    }
  }, [pathname])

  return (
    <div
      ref={announceRef}
      role="status"
      aria-live="polite"
      className="sr-only"
    >
      페이지가 로드되었습니다
    </div>
  )
}

라우트 변경 알림

// Next.js App Router: 라우트 변경 알림
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'

function RouteAnnouncer() {
  const pathname = usePathname()
  const [announcement, setAnnouncement] = useState('')

  useEffect(() => {
    const pageTitle = document.title
    setAnnouncement(`${pageTitle} 페이지로 이동했습니다`)
  }, [pathname])

  return (
    <div
      role="status"
      aria-live="assertive"
      aria-atomic="true"
      className="sr-only"
    >
      {announcement}
    </div>
  )
}

접근성 있는 모달 (Dialog)

'use client'
import { useEffect, useRef } from 'react'

interface DialogProps {
  isOpen: boolean
  onClose: () => void
  title: string
  children: React.ReactNode
}

function AccessibleDialog({ isOpen, onClose, title, children }: DialogProps) {
  const dialogRef = useRef<HTMLDialogElement>(null)
  const previousFocusRef = useRef<HTMLElement | null>(null)

  useEffect(() => {
    const dialog = dialogRef.current
    if (!dialog) return

    if (isOpen) {
      previousFocusRef.current = document.activeElement as HTMLElement
      dialog.showModal()
    } else {
      dialog.close()
      previousFocusRef.current?.focus()
    }
  }, [isOpen])

  return (
    <dialog
      ref={dialogRef}
      aria-labelledby="dialog-title"
      onClose={onClose}
    >
      <h2 id="dialog-title">{title}</h2>
      {children}
      <button onClick={onClose}>닫기</button>
    </dialog>
  )
}

Radix UI / Headless UI 활용

import * as Dialog from '@radix-ui/react-dialog'

// Radix UI는 접근성을 자동으로 처리합니다
function MyDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <button>프로필 편집</button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="overlay" />
        <Dialog.Content className="content">
          <Dialog.Title>프로필 편집</Dialog.Title>
          <Dialog.Description>
            프로필 정보를 변경하세요.
          </Dialog.Description>
          {/* 폼 필드 */}
          <Dialog.Close asChild>
            <button aria-label="닫기">X</button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

스크린 리더 전용 텍스트

/* sr-only 유틸리티 클래스 */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}
// 사용 예시
<button>
  <TrashIcon />
  <span className="sr-only">항목 삭제</span>
</button>

<a href="/cart">
  <CartIcon />
  <span className="sr-only">장바구니 (3개 상품)</span>
</a>

10. 테스팅과 자동화

axe-core로 자동 테스트

// jest + axe-core
import { render } from '@testing-library/react'
import { axe, toHaveNoViolations } from 'jest-axe'

expect.extend(toHaveNoViolations)

describe('Button', () => {
  it('접근성 위반 없음', async () => {
    const { container } = render(<Button>클릭</Button>)
    const results = await axe(container)
    expect(results).toHaveNoViolations()
  })
})

Playwright + axe 통합 테스트

import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'

test('홈페이지 접근성', async ({ page }) => {
  await page.goto('/')

  const accessibilityScanResults = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
    .analyze()

  expect(accessibilityScanResults.violations).toEqual([])
})

// 키보드 내비게이션 테스트
test('키보드로 메뉴 탐색', async ({ page }) => {
  await page.goto('/')

  // Tab으로 Skip Link로 이동
  await page.keyboard.press('Tab')
  const skipLink = page.getByText('본문으로 건너뛰기')
  await expect(skipLink).toBeFocused()

  // Enter로 Skip Link 활성화
  await page.keyboard.press('Enter')
  const main = page.locator('main')
  await expect(main).toBeFocused()
})

Lighthouse 접근성 점수

# CI에서 Lighthouse 실행
npx lighthouse http://localhost:3000 \
  --only-categories=accessibility \
  --output=json \
  --output-path=./lighthouse-report.json
// CI 파이프라인에서 접근성 점수 확인
const report = JSON.parse(fs.readFileSync('./lighthouse-report.json', 'utf-8'))
const accessibilityScore = report.categories.accessibility.score * 100

if (accessibilityScore < 90) {
  console.error(`접근성 점수 ${accessibilityScore}점 - 90점 이상 필요`)
  process.exit(1)
}

스크린 리더 수동 테스트 체크리스트

항목VoiceOver (Mac)NVDA (Windows)TalkBack (Android)
페이지 제목 읽기Cmd + F5Insert + T자동
랜드마크 탐색Rotor (VO + U)D/Shift+D스와이프
제목 탐색VO + Cmd + HH/Shift+H스와이프
폼 필드VO + TabTab터치 탐색
링크 목록RotorInsert + F7메뉴

11. 법적 요구사항과 규정

미국: ADA와 Section 508

  • ADA Title III: 웹사이트는 "공공 편의시설" 적용. WCAG 2.1 AA 요구
  • Section 508: 연방 정부 웹사이트 의무. WCAG 2.0 AA 기준
  • 소송 현황: 2023년 웹 접근성 소송 4,600건 이상

EU: European Accessibility Act (EAA)

  • 2025년 6월 28일 시행
  • 디지털 서비스 제공 기업 대상
  • WCAG 2.1 AA 이상 요구
  • 위반 시 벌금 부과

한국: 장애인차별금지법

  • 장애인차별금지 및 권리구제 등에 관한 법률 (2008년)
  • 웹 접근성 인증마크: 한국웹접근성인증평가원
  • 공공기관 의무, 민간으로 확대 적용
  • 한국형 웹 콘텐츠 접근성 지침 (KWCAG) 2.2: WCAG 2.2 기반

접근성 성명서(Accessibility Statement)

<!-- 웹사이트에 접근성 성명서 포함 권장 -->
<h1>접근성 성명서</h1>
<p>
  저희는 모든 사용자에게 접근 가능한 웹 경험을 제공하기 위해
  WCAG 2.2 AA 기준을 준수하고 있습니다.
</p>
<p>
  접근성 관련 문제를 발견하시면
  <a href="mailto:a11y@example.com">a11y@example.com</a>으로
  연락해 주세요.
</p>

12. 면접 퀴즈

Q1. WCAG의 4가지 원칙(POUR)을 설명하고, 각각의 예시를 들어주세요.

Perceivable(인지 가능): 모든 정보를 인지할 수 있어야 합니다. 이미지에 alt 텍스트 제공, 비디오에 자막 추가가 예시입니다. Operable(운용 가능): 모든 기능을 조작할 수 있어야 합니다. 키보드만으로 모든 기능 사용 가능, 충분한 시간 제공이 예시입니다. Understandable(이해 가능): 콘텐츠와 UI를 이해할 수 있어야 합니다. 명확한 에러 메시지, 일관된 내비게이션이 예시입니다. Robust(견고): 다양한 기술에서 작동해야 합니다. 유효한 HTML, 보조 기술 호환이 예시입니다.

Q2. ARIA의 첫 번째 규칙은 무엇이며, 왜 중요한가요?

ARIA의 첫 번째 규칙은 "네이티브 HTML 요소로 충분하다면 ARIA를 사용하지 마세요"입니다. 예를 들어 버튼에 role="button"을 추가하는 것은 불필요합니다. 네이티브 HTML 요소는 이미 접근성 의미, 키보드 동작, 포커스 관리가 내장되어 있기 때문입니다. ARIA를 잘못 사용하면 오히려 접근성을 해칠 수 있습니다.

Q3. 색상 대비 비율 4.5:1과 3:1은 각각 어떤 상황에 적용되나요?

4.5:1은 일반 크기 텍스트(18px 미만)에 대한 AA 레벨 요구사항입니다. 3:1은 큰 텍스트(18px 이상 또는 14px 볼드)와 UI 컴포넌트(버튼 테두리, 입력 필드 등)에 대한 AA 레벨 요구사항입니다. AAA 레벨은 일반 텍스트 7:1, 큰 텍스트 4.5:1을 요구합니다.

Q4. SPA에서 라우트 변경 시 접근성을 어떻게 보장하나요?

SPA에서 클라이언트 사이드 라우팅은 브라우저의 기본 페이지 로드와 달리 스크린 리더에 자동 알림을 제공하지 않습니다. 해결 방법으로는 라우트 변경 시 메인 콘텐츠로 포커스 이동, aria-live 리전으로 새 페이지 제목 알림, 문서 title 업데이트, Skip Link 제공 등이 있습니다. Next.js의 App Router는 내장 라우트 알림 기능을 제공합니다.

Q5. axe-core를 CI/CD 파이프라인에 통합하는 방법과 한계는?

axe-core는 jest-axe(유닛 테스트)나 @axe-core/playwright(E2E 테스트)로 CI에 통합할 수 있습니다. WCAG 태그로 필터링하여 특정 기준만 검사하고, 위반이 있으면 빌드를 실패시킵니다. 한계로는 자동 도구가 접근성 문제의 약 30-40%만 감지할 수 있다는 점입니다. 키보드 사용성, 스크린 리더 호환성, 인지적 접근성 등은 수동 테스트가 필수입니다.


References

  1. WCAG 2.2 - W3C Recommendation
  2. WAI-ARIA 1.2 Specification
  3. MDN Web Accessibility Guide
  4. A11y Project Checklist
  5. Deque axe-core
  6. WebAIM Million Report
  7. Radix UI Accessibility
  8. React Accessibility Docs
  9. Next.js Accessibility
  10. Inclusive Components by Heydon Pickering
  11. EU European Accessibility Act
  12. 한국웹접근성인증평가원
  13. Chrome DevTools Accessibility
  14. Stark Accessibility Tools

Web Accessibility (a11y) Complete Guide 2025: WCAG 2.2, ARIA, Keyboard Navigation

1. Why Accessibility Matters

1 Billion Users

Approximately 15% of the world's population -- roughly 1 billion people -- live with some form of disability. Web accessibility is not just "the right thing to do"; it is a business imperative.

Disability TypeGlobal PopulationImpact on Web
Visual220 millionScreen readers, magnifiers
Hearing466 millionCaptions, sign language
MotorHundreds of millionsKeyboard, voice input
CognitiveVariesSimple UI, clear language
  • US ADA: Websites are considered "places of public accommodation." Lawsuits surging
  • EU EAA (European Accessibility Act): Enforcement began June 2025. Digital services mandatory
  • Korea Disability Discrimination Act: Web accessibility mandatory for public and expanding to private sector
  • WCAG 2.2: The international standard. Most laws require AA level compliance

Business Value

Accessibility is not a cost -- it is an investment.

  • SEO improvement: Semantic HTML and alt text are loved by search engines
  • User base expansion: Reach 15% of the global population
  • Legal risk reduction: Proactive investment is far cheaper than lawsuits
  • Better UX for everyone: Keyboard shortcuts, clear labels benefit all users

2. WCAG 2.2: The 4 Principles

POUR Principles

WCAG is built on 4 core principles: POUR (Perceivable, Operable, Understandable, Robust).

PrincipleMeaningExamples
PerceivableInformation must be presentable to usersAlt text, captions, color contrast
OperableUI must be operableKeyboard access, enough time, seizure prevention
UnderstandableContent and UI must be understandableClear language, consistent navigation
RobustMust work with diverse technologiesValid HTML, ARIA compatibility

Conformance Levels

  • Level A: Minimum requirements (essential)
  • Level AA: What most laws require (recommended target)
  • Level AAA: Highest level (difficult to apply site-wide)

New Success Criteria in WCAG 2.2

WCAG 2.2 was published in October 2023 with the following new criteria:

Success CriterionLevelDescription
2.4.11 Focus Not Obscured (Minimum)AAFocused element must not be fully hidden by other content
2.4.12 Focus Not Obscured (Enhanced)AAAFocused element must not be even partially hidden
2.4.13 Focus AppearanceAAASize and contrast requirements for focus indicators
2.5.7 Dragging MovementsAAAlternatives must be provided for drag-based functionality
2.5.8 Target Size (Minimum)AATouch targets must be at least 24x24 CSS pixels
3.2.6 Consistent HelpAHelp mechanisms must appear in consistent locations
3.3.7 Redundant EntryAPreviously entered information must not be re-requested
3.3.8 Accessible AuthenticationAAAuthentication without cognitive function tests
3.3.9 Accessible Authentication (Enhanced)AAAStricter authentication accessibility

3. Semantic HTML: The Foundation of Accessibility

Using the Right Elements

Semantic HTML solves 80% of accessibility. Screen readers understand the meaning of HTML elements.

<!-- Bad: Making everything with divs -->
<div class="button" onclick="submit()">Submit</div>
<div class="header">Site Title</div>
<div class="nav">
  <div class="link" onclick="goto('/')">Home</div>
</div>

<!-- Good: Using semantic elements -->
<button type="submit">Submit</button>
<header><h1>Site Title</h1></header>
<nav>
  <a href="/">Home</a>
</nav>

A <button> automatically receives keyboard focus, is activated with Enter/Space, and is recognized as "button" by screen readers. A <div> requires manually implementing all of this.

Landmarks

<body>
  <header>
    <nav aria-label="Main navigation">...</nav>
  </header>

  <main>
    <article>
      <h1>Article Title</h1>
      <section aria-labelledby="section-1">
        <h2 id="section-1">Section 1</h2>
        ...
      </section>
    </article>

    <aside aria-label="Related links">...</aside>
  </main>

  <footer>...</footer>
</body>

Screen reader users can quickly navigate between landmarks. In VoiceOver, using the Rotor lets them jump directly to header, nav, main, and footer.

Heading Hierarchy

<!-- Bad: Skipping heading levels -->
<h1>Page Title</h1>
<h3>Subsection</h3>   <!-- Skipped h2! -->
<h5>Detail</h5>        <!-- Skipped h4! -->

<!-- Good: Sequential heading structure -->
<h1>Page Title</h1>
  <h2>Section A</h2>
    <h3>Subsection A-1</h3>
    <h3>Subsection A-2</h3>
  <h2>Section B</h2>
    <h3>Subsection B-1</h3>

67% of screen reader users navigate pages by headings. Skipping heading levels makes document structure difficult to understand.


4. ARIA: Adding Accessible Semantics

The First Rule of ARIA

The first rule of ARIA: Don't use ARIA. If a native HTML element suffices, ARIA is unnecessary.

<!-- ARIA unnecessary: Native elements are sufficient -->
<button>Delete</button>                   <!-- role="button" not needed -->
<input type="checkbox" />                 <!-- role="checkbox" not needed -->
<nav>                                     <!-- role="navigation" not needed -->

<!-- ARIA needed: No native element exists -->
<div role="tablist">
  <button role="tab" aria-selected="true">Tab 1</button>
  <button role="tab" aria-selected="false">Tab 2</button>
</div>
<div role="tabpanel">Tab 1 content</div>

Essential ARIA Attributes

<!-- aria-label: Provide a label for elements without visible text -->
<button aria-label="Close menu">
  <svg><!-- X icon --></svg>
</button>

<!-- aria-labelledby: Reference another element's text as a label -->
<h2 id="cart-heading">Shopping Cart</h2>
<ul aria-labelledby="cart-heading">
  <li>Product 1</li>
  <li>Product 2</li>
</ul>

<!-- aria-describedby: Link additional description -->
<input
  type="password"
  aria-describedby="pw-hint"
/>
<p id="pw-hint">At least 8 characters, including special characters</p>

<!-- aria-live: Announce dynamic content changes -->
<div aria-live="polite">
  3 items in your cart.
</div>

<!-- aria-expanded: Expansion/collapse state -->
<button aria-expanded="false" aria-controls="menu">
  Menu
</button>
<ul id="menu" hidden>...</ul>

<!-- aria-hidden: Hide from screen readers -->
<span aria-hidden="true">🔥</span>
<span class="sr-only">Popular</span>

Live Regions

<!-- aria-live="polite": Announce after current reading finishes -->
<div aria-live="polite" aria-atomic="true">
  Search results: 42 items
</div>

<!-- aria-live="assertive": Announce immediately (for errors) -->
<div role="alert" aria-live="assertive">
  Your session has expired. Please log in again.
</div>

<!-- role="status": Status messages (similar to polite) -->
<div role="status">
  File upload complete
</div>

<!-- role="log": Chat messages etc. -->
<div role="log" aria-live="polite">
  <!-- New messages appended -->
</div>

5. Keyboard Accessibility

Focus Management Basics

Every interactive element must be accessible via keyboard.

KeyAction
TabMove to next focusable element
Shift + TabMove to previous focusable element
EnterActivate links, click buttons
SpaceClick buttons, toggle checkboxes
EscapeClose modals/popups
Arrow keysNavigate within menus, tabs, radio groups
<!-- Place at the very top of the page -->
<a href="#main-content" class="skip-link">
  Skip to main content
</a>

<nav>
  <!-- Long navigation menu -->
</nav>

<main id="main-content" tabindex="-1">
  <!-- Main content -->
</main>
.skip-link {
  position: absolute;
  left: -9999px;
  top: auto;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.skip-link:focus {
  position: fixed;
  top: 10px;
  left: 10px;
  width: auto;
  height: auto;
  padding: 12px 24px;
  background: #000;
  color: #fff;
  z-index: 9999;
  font-size: 1rem;
}

Focus Trap

When a modal is open, focus must cycle only within the modal.

function useFocusTrap(containerRef: React.RefObject<HTMLElement>) {
  useEffect(() => {
    const container = containerRef.current
    if (!container) return

    const focusableSelector = [
      'a[href]',
      'button:not([disabled])',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])',
    ].join(', ')

    const focusableElements = container.querySelectorAll(focusableSelector)
    const firstElement = focusableElements[0] as HTMLElement
    const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key !== 'Tab') return

      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault()
          lastElement.focus()
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault()
          firstElement.focus()
        }
      }
    }

    container.addEventListener('keydown', handleKeyDown)
    firstElement?.focus()

    return () => container.removeEventListener('keydown', handleKeyDown)
  }, [containerRef])
}

Tab Order Management

<!-- tabindex values -->
<!-- tabindex="0": Include in natural order -->
<div role="button" tabindex="0">Custom button</div>

<!-- tabindex="-1": Only focusable programmatically -->
<div id="error-message" tabindex="-1">An error occurred!</div>

<!-- Never use positive tabindex! It breaks the order -->
<!-- Bad: tabindex="1", tabindex="2", tabindex="3" -->

6. Color and Contrast

Contrast Ratio Requirements

Text TypeAA LevelAAA Level
Normal text (under 14px)4.5:17:1
Large text (18px+ or 14px bold)3:14.5:1
UI components, graphics3:1-

Ensuring Contrast with CSS

/* Good contrast: #333 on #fff = 12.63:1 */
body {
  color: #333333;
  background-color: #ffffff;
}

/* Links: 3:1 contrast with surrounding text + underline or other visual cue */
a {
  color: #0066cc;
  text-decoration: underline;
}

/* Focus indicator: 3:1 contrast required */
:focus-visible {
  outline: 3px solid #1a73e8;
  outline-offset: 2px;
}

/* Maintain contrast in dark mode too */
@media (prefers-color-scheme: dark) {
  body {
    color: #e0e0e0;
    background-color: #121212;
  }

  a {
    color: #8ab4f8;
  }
}

Color Blindness Considerations

/* Never convey information through color alone */

/* Bad: Error indicated only by color */
.error-field {
  border-color: red;
}

/* Good: Color + icon + text */
.error-field {
  border-color: #d32f2f;
  border-width: 2px;
}

.error-field::before {
  content: "⚠ ";
}

.error-message {
  color: #d32f2f;
  font-weight: bold;
}

Contrast Checking Tools

  • Chrome DevTools: Shows contrast ratio when inspecting elements
  • axe DevTools: Full-page contrast audit
  • Colour Contrast Analyser (CCA): Standalone tool
  • Stark: Figma/Sketch plugin

7. Images and Media

Alt Text Guide

<!-- Informative image: Describe the content -->
<img src="chart.png" alt="2025 revenue trend: Q1 1M, Q2 1.5M, Q3 2M" />

<!-- Decorative image: Empty alt -->
<img src="decorative-line.png" alt="" />

<!-- Functional image (link/button): Describe the action -->
<a href="/home">
  <img src="logo.png" alt="Go to homepage" />
</a>

<!-- Complex image: Provide long description -->
<figure>
  <img src="infographic.png" alt="Accessibility statistics infographic" aria-describedby="info-desc" />
  <figcaption id="info-desc">
    1 billion people worldwide have disabilities,
    and 97% of websites contain accessibility errors.
    The most common error is low color contrast (83%).
  </figcaption>
</figure>

<!-- SVG accessibility -->
<svg role="img" aria-labelledby="svg-title">
  <title id="svg-title">Download icon</title>
  <path d="..." />
</svg>

Video Accessibility

<video controls>
  <source src="tutorial.mp4" type="video/mp4" />
  <!-- Captions -->
  <track kind="captions" src="captions-en.vtt" srclang="en" label="English" default />
  <track kind="captions" src="captions-ko.vtt" srclang="ko" label="Korean" />
  <!-- Audio descriptions -->
  <track kind="descriptions" src="descriptions-en.vtt" srclang="en" label="Audio descriptions" />
</video>

Audio Content

All audio content requires a text alternative (transcript).


8. Form Accessibility

Connecting Labels and Inputs

<!-- Method 1: for/id association (recommended) -->
<label for="email">Email address</label>
<input type="email" id="email" name="email" autocomplete="email" />

<!-- Method 2: Wrapping with label -->
<label>
  Email address
  <input type="email" name="email" autocomplete="email" />
</label>

<!-- Method 3: aria-labelledby -->
<span id="email-label">Email address</span>
<input type="email" aria-labelledby="email-label" autocomplete="email" />

Error Messages and Validation

<div class="form-group">
  <label for="password">Password</label>
  <input
    type="password"
    id="password"
    aria-describedby="pw-requirements pw-error"
    aria-invalid="true"
    autocomplete="new-password"
  />
  <p id="pw-requirements" class="hint">
    At least 8 characters, including uppercase, lowercase, numbers, and special characters
  </p>
  <p id="pw-error" class="error" role="alert">
    Password does not meet the requirements.
  </p>
</div>

Required Fields

<!-- aria-required + visual indicator -->
<label for="name">
  Name <span aria-hidden="true" class="required">*</span>
</label>
<input
  type="text"
  id="name"
  required
  aria-required="true"
  autocomplete="name"
/>
<p class="form-note">* indicates required fields</p>

Autocomplete

<!-- WCAG 1.3.5: Use autocomplete attributes -->
<input type="text" autocomplete="given-name" />   <!-- First name -->
<input type="text" autocomplete="family-name" />   <!-- Last name -->
<input type="email" autocomplete="email" />         <!-- Email -->
<input type="tel" autocomplete="tel" />             <!-- Phone -->
<input type="text" autocomplete="street-address" /> <!-- Address -->

9. React/Next.js Accessibility Patterns

Focus Management in SPAs

In SPAs (Single Page Applications), focus does not automatically move during page transitions.

// Move focus on route change
function useRouteAnnounce() {
  const pathname = usePathname()

  useEffect(() => {
    // Move focus to main content
    const main = document.querySelector('main')
    if (main) {
      main.setAttribute('tabindex', '-1')
      main.focus()
    }
  }, [pathname])

  return (
    <div
      role="status"
      aria-live="polite"
      className="sr-only"
    >
      Page has loaded
    </div>
  )
}

Route Change Announcements

// Next.js App Router: Route change announcements
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'

function RouteAnnouncer() {
  const pathname = usePathname()
  const [announcement, setAnnouncement] = useState('')

  useEffect(() => {
    const pageTitle = document.title
    setAnnouncement(`Navigated to ${pageTitle}`)
  }, [pathname])

  return (
    <div
      role="status"
      aria-live="assertive"
      aria-atomic="true"
      className="sr-only"
    >
      {announcement}
    </div>
  )
}

Accessible Modal (Dialog)

'use client'
import { useEffect, useRef } from 'react'

interface DialogProps {
  isOpen: boolean
  onClose: () => void
  title: string
  children: React.ReactNode
}

function AccessibleDialog({ isOpen, onClose, title, children }: DialogProps) {
  const dialogRef = useRef<HTMLDialogElement>(null)
  const previousFocusRef = useRef<HTMLElement | null>(null)

  useEffect(() => {
    const dialog = dialogRef.current
    if (!dialog) return

    if (isOpen) {
      previousFocusRef.current = document.activeElement as HTMLElement
      dialog.showModal()
    } else {
      dialog.close()
      previousFocusRef.current?.focus()
    }
  }, [isOpen])

  return (
    <dialog
      ref={dialogRef}
      aria-labelledby="dialog-title"
      onClose={onClose}
    >
      <h2 id="dialog-title">{title}</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </dialog>
  )
}

Leveraging Radix UI / Headless UI

import * as Dialog from '@radix-ui/react-dialog'

// Radix UI handles accessibility automatically
function MyDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <button>Edit Profile</button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="overlay" />
        <Dialog.Content className="content">
          <Dialog.Title>Edit Profile</Dialog.Title>
          <Dialog.Description>
            Make changes to your profile information.
          </Dialog.Description>
          {/* Form fields */}
          <Dialog.Close asChild>
            <button aria-label="Close">X</button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Screen Reader Only Text

/* sr-only utility class */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}
// Usage examples
<button>
  <TrashIcon />
  <span className="sr-only">Delete item</span>
</button>

<a href="/cart">
  <CartIcon />
  <span className="sr-only">Shopping cart (3 items)</span>
</a>

10. Testing and Automation

Automated Testing with axe-core

// jest + axe-core
import { render } from '@testing-library/react'
import { axe, toHaveNoViolations } from 'jest-axe'

expect.extend(toHaveNoViolations)

describe('Button', () => {
  it('has no accessibility violations', async () => {
    const { container } = render(<Button>Click</Button>)
    const results = await axe(container)
    expect(results).toHaveNoViolations()
  })
})

Playwright + axe Integration Tests

import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'

test('homepage accessibility', async ({ page }) => {
  await page.goto('/')

  const accessibilityScanResults = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
    .analyze()

  expect(accessibilityScanResults.violations).toEqual([])
})

// Keyboard navigation test
test('navigate menu with keyboard', async ({ page }) => {
  await page.goto('/')

  // Tab to Skip Link
  await page.keyboard.press('Tab')
  const skipLink = page.getByText('Skip to main content')
  await expect(skipLink).toBeFocused()

  // Enter to activate Skip Link
  await page.keyboard.press('Enter')
  const main = page.locator('main')
  await expect(main).toBeFocused()
})

Lighthouse Accessibility Score

# Run Lighthouse in CI
npx lighthouse http://localhost:3000 \
  --only-categories=accessibility \
  --output=json \
  --output-path=./lighthouse-report.json
// Check accessibility score in CI pipeline
const report = JSON.parse(fs.readFileSync('./lighthouse-report.json', 'utf-8'))
const accessibilityScore = report.categories.accessibility.score * 100

if (accessibilityScore < 90) {
  console.error(`Accessibility score ${accessibilityScore} - must be 90 or above`)
  process.exit(1)
}

Manual Screen Reader Testing Checklist

TaskVoiceOver (Mac)NVDA (Windows)TalkBack (Android)
Read page titleCmd + F5Insert + TAutomatic
Navigate landmarksRotor (VO + U)D/Shift+DSwipe
Navigate headingsVO + Cmd + HH/Shift+HSwipe
Form fieldsVO + TabTabTouch explore
Link listRotorInsert + F7Menu

US: ADA and Section 508

  • ADA Title III: Websites are "places of public accommodation." WCAG 2.1 AA required
  • Section 508: Federal government websites mandatory. WCAG 2.0 AA standard
  • Lawsuit trends: Over 4,600 web accessibility lawsuits in 2023

EU: European Accessibility Act (EAA)

  • Enforcement date: June 28, 2025
  • Targets businesses providing digital services
  • Requires WCAG 2.1 AA or higher
  • Fines for violations

Korea: Disability Discrimination Act

  • Anti-Discrimination Against and Remedies for Persons with Disabilities Act (2008)
  • Web Accessibility Certification Mark by the Korea Web Accessibility Certification Center
  • Mandatory for public institutions, expanding to private sector
  • KWCAG 2.2: Based on WCAG 2.2

Accessibility Statement

<!-- Recommended to include on your website -->
<h1>Accessibility Statement</h1>
<p>
  We are committed to ensuring our website is accessible
  to everyone by adhering to WCAG 2.2 AA standards.
</p>
<p>
  If you encounter any accessibility issues, please contact us at
  <a href="mailto:a11y@example.com">a11y@example.com</a>.
</p>

12. Quiz

Q1. Explain WCAG's 4 principles (POUR) and provide an example for each.

Perceivable: All information must be presentable to users. Examples include providing alt text for images and captions for videos. Operable: All functionality must be operable. Examples include full keyboard access and providing enough time. Understandable: Content and UI must be understandable. Examples include clear error messages and consistent navigation. Robust: Must work with diverse technologies. Examples include valid HTML and assistive technology compatibility.

Q2. What is the first rule of ARIA, and why is it important?

The first rule of ARIA is "if a native HTML element can be used, do not use ARIA." For example, adding role="button" to a button element is unnecessary. Native HTML elements already have built-in accessible semantics, keyboard behavior, and focus management. Incorrectly used ARIA can actually harm accessibility rather than improve it.

Q3. When do the 4.5:1 and 3:1 contrast ratios apply?

4.5:1 is the AA-level requirement for normal-sized text (under 18px). 3:1 is the AA-level requirement for large text (18px or larger, or 14px bold) and UI components (button borders, input fields, etc.). AAA level requires 7:1 for normal text and 4.5:1 for large text.

Q4. How do you ensure accessibility during route changes in a SPA?

Client-side routing in SPAs does not provide automatic screen reader notifications like traditional page loads. Solutions include moving focus to main content on route change, announcing the new page title via aria-live region, updating the document title, and providing skip links. Next.js App Router provides built-in route announcement functionality.

Q5. How do you integrate axe-core into a CI/CD pipeline, and what are its limitations?

axe-core can be integrated into CI via jest-axe (unit tests) or @axe-core/playwright (E2E tests). You can filter by WCAG tags to test specific criteria and fail the build on violations. The limitation is that automated tools can only detect approximately 30-40% of accessibility issues. Keyboard usability, screen reader compatibility, and cognitive accessibility require manual testing.


References

  1. WCAG 2.2 - W3C Recommendation
  2. WAI-ARIA 1.2 Specification
  3. MDN Web Accessibility Guide
  4. A11y Project Checklist
  5. Deque axe-core
  6. WebAIM Million Report
  7. Radix UI Accessibility
  8. React Accessibility Docs
  9. Next.js Accessibility
  10. Inclusive Components by Heydon Pickering
  11. EU European Accessibility Act
  12. Korea Web Accessibility Certification Center
  13. Chrome DevTools Accessibility
  14. Stark Accessibility Tools