β€œπŸ“’ 슀크린 리더: μ ‘κ·Όμ„±, 상세보기, λ²„νŠΌ, λˆŒλ¦Όβ€

λ°λΈŒν˜„Β·2025λ…„ 9μ›” 1일
17
post-thumbnail

"싀무에 λ°”λ‘œ μ μš©ν•˜λŠ” μ›Ή μ ‘κ·Όμ„± κ°€μ΄λ“œλΆ" 책을 읽고 μ•Œκ²Œ 된 λ‚΄μš©λ“€, 도움이 λ˜λŠ” λ‚΄μš©λ“€, 싀무에 λ°”λ‘œ μ μš©ν•  수 μžˆλŠ” λ‚΄μš©λ“€, λ‚˜μ€‘μ— 생각 μ•ˆ λ‚  λ•Œ λ³Ό λ‚΄μš©λ“€μ„ 정리 해보렀고 ν•œλ‹€.

πŸ™‚β€β†”οΈ (λ‚˜λŠ”) 접근성을 μ•Œκ³  μžˆλŠ”κ°€?

ν”„λ‘ νŠΈμ—”λ“œ μ±„μš© 곡고듀을 μ‚΄νŽ΄λ³΄λ©΄ "μ ‘κ·Όμ„±"μ΄λΌλŠ” ν‚€μ›Œλ“œκ°€ λ“€μ–΄μžˆλŠ” 것듀을 자주 봀을 것이닀. 보톡은 접근성에 λŒ€ν•œ ν‚€μ›Œλ“œμ— 크게 μ€‘μš”ν•˜λ‹€ μƒκ°ν•˜μ§€ μ•Šκ³ , λ‹€λ₯Έ ν‚€μ›Œλ“œλ“€μ— 더 μ§‘μ€‘ν•˜λŠ” κ²½ν–₯이 μžˆλ‹€. (적어도 λ‚˜λŠ” κ·Έλž¬λ‹€.)

이 책을 읽기 전에 접근성을 μƒκ°ν•˜λ©΄μ„œ κ°œλ°œμ— μ μš©ν•œ 것듀은 meta νƒœκ·Έ μ‚¬μš©ν•˜κΈ°, aria-label 달기, img νƒœκ·Έμ— alt μ“°κΈ° 와 같이 λ‹¨μˆœν•œ κ²ƒλ“€μ΄μ—ˆλ‹€.

μ§€κΈˆμ™€μ„œ 생각해보면 접근성을 μ•Œκ³  κ°œλ°œμ— μ‚¬μš©ν•œ 것이 μ•„λ‹ˆλΌ 'κ·Έλƒ₯' 개발 ν–ˆλ˜ 것 κ°™λ‹€. 접근성에 λŒ€ν•œ 정리와 μ‹€μ œ 슀크린 리더가 μ–΄λ–»κ²Œ μ½λŠ”μ§€ μ˜ˆμ‹œμ™€ ν•¨κ»˜ μ •λ¦¬ν•˜λ €κ³  ν•œλ‹€.

πŸš₯ μ›Ή μ ‘κ·Όμ„±μ΄λž€?

λ‹€μ–‘ν•œ 신체적/ν™˜κ²½μ  쑰건과 관계없이, μ›Ή 접근성은 λͺ¨λ“  μ‚¬μš©μžκ°€ μ›Ήμ—μ„œ μ œκ³΅ν•˜λŠ” μ½˜ν…μΈ (정보)에 어렀움 없이 μ ‘κ·Ό κ°€λŠ₯ν•˜λ„λ‘ 함을 μ˜λ―Έν•œλ‹€.

접근성을 μ§€μΌœμ•Ό ν•˜λŠ” 이유?
접근성을 μ§€μΌœμ•Ό ν•˜λŠ” μ΄μœ κ°€ 크게 와닿지 μ•Šμ„ 수 μžˆλŠ”λ° 이 글을 읽어보면 μ‰½κ²Œ 이해할 수 μžˆλ‹€.

πŸ›– 의미있게(μ‹œλ§¨ν‹±ν•˜κ²Œ) νƒœκ·Έ μ‚¬μš©ν•˜κΈ°

μ‹œλ©˜ν‹± νƒœκ·Έλ₯Ό μ§€ν‚€λŠ” κ²ƒλ§ŒμœΌλ‘œλ„ 접근성을 μ§€ν‚€λŠ”λ° 도움이 λœλ‹€. λ‹¨μˆœνžˆ λ””μžμΈμƒμœΌλ‘œ λ™μΌν•˜κ²Œ 화면을 κ΅¬μ„±ν•˜λŠ” 것이 μ•„λ‹ˆλΌ "μ˜λ―Έμ— λ§žλŠ” νƒœκ·Έ"λ₯Ό μ“°λŠ” 것을 κ³ λ―Όν•΄μ•Ό ν•œλ‹€.

이 그림은 μ˜ˆμ „μ— 처음 HTML κ³΅λΆ€ν• λ•Œ λ΄μ™”λ˜ 사진이닀. 이 νƒœκ·Έλ“€μ„ κ°œλ°œν• λ•Œ λͺ¨λ‘ 의미둠적으둜 "μ •ν™•νžˆ" μ•Œλ©΄μ„œ μ“°λŠ”κ°€? (일단 λ‚˜λŠ” μ•„λ‹ˆμ˜€λ‹€.)

1. <article> vs <section>

article νƒœκ·Έμ™€ section νƒœκ·ΈλŠ” μ½˜ν…μΈ  μ˜μ—­μ„ κ΅¬λΆ„ν•˜λŠ” 데 μ‚¬μš©λœλ‹€. 두 νƒœκ·Έμ˜ κ°€μž₯ 큰 차이점은 article은 μ™„μ „νžˆ 독립적인 μ˜μ—­μœΌλ‘œ μ‚¬μš©λ˜κ³ , seciton은 μ›ΉνŽ˜μ΄μ§€ λ‚΄ μ£Όμš” 주제의 일뢀 μ˜μ—­μ΄λΌλŠ” 점이닀.

<!-- article: 독립적인 κ²Œμ‹œκΈ€ -->
<article>
  <h2>React 19 μΆœμ‹œ!</h2>
  <p>React νŒ€μ—μ„œ React 19의 정식 버전을 κ³΅κ°œν–ˆμŠ΅λ‹ˆλ‹€. λ§Žμ€ μƒˆλ‘œμš΄ κΈ°λŠ₯이 μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.</p>
</article>

<!-- section: νŽ˜μ΄μ§€ λ‚΄ νŠΉμ • 주제λ₯Ό λ¬ΆλŠ” ꡬ간 -->
<section>
  <h2>νšŒμ› κ°€μž… 방법</h2>
  <p>이 μ„œλΉ„μŠ€μ— κ°€μž…ν•˜λ €λ©΄ 이름과 이메일이 ν•„μš”ν•©λ‹ˆλ‹€.</p>
</section>

2. <p> vs <span> vs <div>

이 νƒœκ·Έλ“€μ€ 맀우 자주 μ“°μ΄λŠ” νƒœκ·Έλ“€μΈλ°, 각 νƒœκ·Έλ“€μ˜ μš©λ„λ₯Ό κΈ°μ–΅ν•˜λ©΄ ꡬ뢄이 쉽닀.

  • p: ParagraphλΌλŠ” 의미둜 κΈ€μ˜ λ‚΄μš©μƒ λŠμ–΄μ„œ κ΅¬λΆ„ν•˜λŠ” 토막을 λ‚˜νƒ€λ‚Έλ‹€.
  • span: μ•„λ¬΄λŸ° μ˜λ―Έκ°€ μ—†λŠ” νƒœκ·Έλ‘œ, μŠ€νƒ€μΌμ„ μ§€μ •ν•˜κ³  싢은 ν…μŠ€νŠΈλ₯Ό λ¬ΆλŠ” μš©λ„λ‘œ μ‚¬μš©ν•œλ‹€.
  • div: Division을 μ˜λ―Έν•˜λŠ” νƒœκ·Έλ‘œ, μ•„λ¬΄λŸ° μ˜λ―Έκ°€ μ—†μ§€λ§Œ μ˜μ—­μ„ κ΅¬λΆ„ν•˜λŠ” μ»¨ν…Œμ΄λ„ˆ μ—­ν• λ‘œ μ‚¬μš©λœλ‹€.

3. <strong> vs <b/>

strong νƒœκ·Έμ™€ b νƒœκ·Έ λ‘˜λ‹€ ꡡ은 ν…μŠ€νŠΈλ‘œ 화면에 κ·Έλ¦¬μ§€λ§Œ λ‹€λ₯Έ 의미둜 μ‚¬μš©ν•œλ‹€.

  • strong νƒœκ·Έ: ν™”λ©΄μ—μ„œ μ€‘μš”ν•œ 의미λ₯Ό κ°€μ§€λŠ” ν…μŠ€νŠΈμ— μ‚¬μš©λ˜λŠ” νƒœκ·Έ
  • b νƒœκ·Έ: λ‹¨μˆœνžˆ λ…μžμ˜ 주의λ₯Ό 끌기 μœ„ν•œ ν…μŠ€νŠΈμ— μ‚¬μš©λ˜λ©° μ€‘μš”λ„λ₯Ό λΆ€μ—¬ν•˜λŠ” 것이 μ•„λ‹˜!
<p>
  κ²°μ œλŠ” <strong>λ³΄μ•ˆ μ—°κ²°(HTTPS)</strong>을 톡해 이루어져야 ν•©λ‹ˆλ‹€.
</p>

<!-- b: λ‹¨μˆœ μ‹œκ°μ  κ°•μ‘° -->
<p>
  였늘의 μΆ”μ²œ 메뉴: <b>μΉ˜μ¦ˆλ²„κ±° μ„ΈνŠΈ</b>
</p>

4. <em> vs <i/>

em, i νƒœκ·Έ λͺ¨λ‘ italic 속성이 μΆ”κ°€λœ ν…μŠ€νŠΈκ°€ ν‘œμ‹œλœλ‹€. 이 λ‘˜μ˜ νƒœκ·Έλ„ 역할이 λ‹€λ₯΄λ‹€.

  • em: strong νƒœκ·Έμ™€ λΉ„μŠ·ν•˜κ²Œ μ€‘μš”ν•œ 의미λ₯Ό κ°€μ§€λŠ” ν…μŠ€νŠΈμ— μ‚¬μš©λ˜λŠ” νƒœκ·Έ
  • i: κ΄€μš©μ  λ˜λŠ” 기술적 ν‘œν˜„μ˜ ν…μŠ€νŠΈλ₯Ό λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©λ˜λŠ” νƒœκ·Έ
<!-- em: λ¬Έλ§₯상 κ°•μ‘° -->
<p>
  μ €λŠ” <em>μ •λ§λ‘œ</em> ν”Όμžλ₯Ό μ’‹μ•„ν•©λ‹ˆλ‹€.
</p>

<!-- i: κ΄€μš©μ /기술적 ν‘œν˜„ -->
<p>
  μ΄νƒˆλ¦¬μ•„μ–΄λ‘œ "μ‚¬λž‘"은 <i>amore</i> μž…λ‹ˆλ‹€.
</p>

5. <br> vs <hr>

br은 Line Breakλ₯Ό μ˜λ―Έν•˜λ©° ν…μŠ€νŠΈλ₯Ό μ€„λ°”κΏˆν•  λ•Œ μ‚¬μš©λœλ‹€. μ΄λŠ” λ‹¨μˆœνžˆ μ€„λ°”κΏˆμ—λ§Œ 영ν–₯을 μ£ΌλŠ” 것이 μ•„λ‹ˆλΌ 슀크린 리더에도 영ν–₯을 μ€€λ‹€.(슀크린 리더가 br을 λ§Œλ‚˜λ©΄ 'μ€„λ°”κΏˆ', 'λΉ„μ–΄μžˆμŒ' 이라고 μ•ˆλ‚΄ν•˜λŠ” κ²½μš°λ„ μžˆλ‹€.) μŠ€νƒ€μΌμ μΈ 간격을 μ£ΌκΈ° μœ„ν•œ μš©λ„λ‘œ μ‚¬μš©ν•˜λ©΄ 슀크린 리더가 잘λͺ» 읽게 λœλ‹€.

<p>
  이것은 μ€„λ°”κΏˆ
  <br/>
  ν…ŒμŠ€νŠΈμ΄λ‹€.
</p>

// πŸ“’ 슀크린 리더: 이것은 μ€„λ°”κΏˆ (멈좀) 그룹이 λΉ„μ–΄ 있음 (멈좀) ν…ŒμŠ€νŠΈμ΄λ‹€.
// => 슀크린 리더가 쀑간에 ν•œλ²ˆ λŠμ–΄μ„œ 읽게 됨

hr은 μ£Όμ œκ°€ μ „ν™˜λ˜λŠ” μ˜μ—­ μ‚¬μ΄μ˜ ꡬ뢄을 λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©λœλ‹€. 슀크린 리더가 hr νƒœκ·Έλ₯Ό λ§Œλ‚˜κ²Œ 되면 'μˆ˜ν‰ λΆ„ν• μ„ ', 'ꡬ뢄선'둜 μ•ˆλ‚΄ν•˜λŠ” κ²½μš°λ„ μžˆμ–΄μ„œ μŠ€νƒ€μΌμ μœΌλ‘œλ§Œ μ‚¬μš©ν•˜λ©΄ μ•ˆλœλ‹€.

6. <table>

  • ν‘œμ˜ 제λͺ©μ€ λ°˜λ“œμ‹œ <caption>으둜 μž‘μ„±ν•œλ‹€.
  • ν‘œμ˜ μ˜μ—­μ€ <thead>, <tbody>, <tfoot> 으둜 κ΅¬λΆ„ν•œλ‹€.
  • 데이터 μ…€(<td>)κ³Ό κ΅¬λΆ„λ˜λŠ” 헀더 μ…€(<th>) 을 μ˜¬λ°”λ₯΄κ²Œ μ‚¬μš©ν•΄μ•Ό ν•œλ‹€.
  • <th>μ—λŠ” scope="col"(μ—΄ 헀더), scope="row"(ν–‰ 헀더)λ₯Ό μ§€μ •ν•œλ‹€.
  • λ ˆμ΄μ•„μ›ƒ μš©λ„λ‘œ <table>을 μ‚¬μš©ν•˜μ§€ μ•ŠλŠ”λ‹€. (CSS Flex/Grid ν™œμš© ꢌμž₯)
  • 슀크린 λ¦¬λ”λŠ” <table>을 데이터 ν‘œλ‘œ μΈμ‹ν•˜κΈ° λ•Œλ¬Έμ— 의미 μ—†λŠ” μ‚¬μš©μ€ 였히렀 접근성을 ν•΄μΉœλ‹€.
<table>
  <caption>2025λ…„ 9μ›” 사내 ꡐ윑 일정</caption>
  <thead>
    <tr>
      <th scope="col">λ‚ μ§œ</th>
      <th scope="col">주제</th>
      <th scope="col">강사</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">9μ›” 3일</th>
      <td>React κ³ κΈ‰ νŒ¨ν„΄</td>
      <td>κΉ€κ°œλ°œ</td>
    </tr>
    <tr>
      <th scope="row">9μ›” 10일</th>
      <td>TypeScript 싀무</td>
      <td>μ΄ν”„λ‘ νŠΈ</td>
    </tr>
  </tbody>
</table>

7. <button>

button νƒœκ·ΈλŠ” 기본적으둜 Space, Enter둜 ν™œμ„±ν™”κ°€ κ°€λŠ₯ν•˜λ‹€. button μ—λŠ” type 속성을 μ§€μ •ν•  수 μžˆλŠ”λ° 기본적으둜 'submit'이 μ§€μ •λœλ‹€. (이렇기 λ•Œλ¬Έμ— λ²„νŠΌ μ‚¬μš©μ‹œ type="button"이라고 λͺ…μ‹œν•˜λŠ” 것)

  • type="submit": μ‚¬μš©μžλ‘œλΆ€ν„° μž…λ ₯받은 데이터λ₯Ό μ œμΆœν•  수 μžˆλŠ” κΈ°λŠ₯
  • type="reset": μ‚¬μš©μžλ‘œλΆ€ν„° μž…λ ₯받은 데이터λ₯Ό μ΄ˆκΈ°ν™” ν•˜λŠ” κΈ°λŠ₯
  • type="button": 일반적인 λ²„νŠΌ κΈ°λŠ₯

πŸ•ΈοΈ WAI-ARIA λ“€

μ‹œλ§¨ν‹± νƒœκ·Έλ“€λ‘œλ§Œ λͺ¨λ“ κ²ƒμ„ μ •μ˜ν•˜κΈ°μ—λŠ” 웹이 점점 λ³΅μž‘ν•΄μ‘Œκ³ , 이λ₯Ό λ³΄μ™„ν•˜κΈ° μœ„ν•΄μ„œ 속성 κ°’μœΌλ‘œ μƒνƒœλ₯Ό λ‚˜νƒ€λ‚΄λŠ”κ²Œ ν•„μš”ν–ˆλ‹€. 그것이 λ°”λ‘œ W3Cμ—μ„œ μž‘μ„±ν•œ 기술 λ¬Έμ„œμΈ, WAI-ARIA(web accessibility initiavtie - accessible rich internet application)이닀.

각 νƒœκ·Έλ“€μ—κ²Œ 역할에 λ§žλŠ” μƒνƒœλ‚˜ 속성을 μ§€μ •ν•˜λ©΄, μ›ΉλΈŒλΌμš°μ €λŠ” μ ‘κ·Όμ„± 트리둜 λ³€ν™˜ν•˜κ²Œ λœλ‹€. 속성듀을 λΆ€μ—¬ν•  경우 μ‹€μ œ λ™μž‘μ—λŠ” μ•„λ¬΄λŸ° 영ν–₯을 μ£Όμ§€ μ•ŠκΈ° λ•Œλ¬Έμ— 이λ₯Ό μœ μ˜ν•΄μ•Ό ν•œλ‹€.

개발자 λ„κ΅¬μ—μ„œ μ ‘κ·Όμ„± 트리λ₯Ό 확인할 수 μžˆλ‹€.

πŸ€”: aria-xx λŒ€ν•΄μ„œ κ³΅λΆ€ν•˜κ³  μ‹Άμ—ˆλŠ”λ°, κΉ”λ”ν•˜κ²Œ μ •λ¦¬λœ λ¬Έμ„œλ₯Ό λͺ» μ°Ύμ•˜μ—ˆλŠ”λ° 책을 μ½μœΌλ©΄μ„œ 많이 μ•Œκ²Œ 된 것 κ°™λ‹€.

W3Cμ—μ„œ ꢌμž₯ν•˜λŠ” ARIA μ‚¬μš© κ·œμΉ™

1. ARIAλ₯Ό μ‚¬μš©ν•˜κΈ° 전에 κΈ°λ³Έ HTML μš”μ†Œλ₯Ό μš°μ„ μ μœΌλ‘œ κ³ λ €ν•œλ‹€.

❌ λ‚˜μœ μ˜ˆμ‹œ (λΆˆν•„μš”ν•œ ARIA)

<!-- role="button" 으둜 span을 λ²„νŠΌμ²˜λŸΌ μ“°λŠ” 경우 -->
<span role="button" aria-pressed="false">클릭</span>

βœ… 쒋은 μ˜ˆμ‹œ (κΈ°λ³Έ HTML μš”μ†Œ μ‚¬μš©)

<!-- κΈ°λ³Έ button μš”μ†Œ μ‚¬μš© -->
<button>클릭</button>

2. κΌ­ ν•„μš”ν•œ κ²½μš°κ°€ μ•„λ‹ˆλΌλ©΄ μš”μ†Œμ˜ κΈ°λ³Έ 의미λ₯Ό λ³€κ²½ν•˜μ§€ μ•ŠλŠ”λ‹€.

❌ λ‚˜μœ μ˜ˆμ‹œ

<!-- h1을 role="presentation" 으둜 의미 제거 -->
<h1 role="presentation">곡지사항</h1>

βœ… 쒋은 μ˜ˆμ‹œ

<h1>곡지사항</h1>

3. μƒν˜Έμž‘μš©μ΄ κ°€λŠ₯ν•œ ARIA μ—­ν• μ˜ 경우 λ°˜λ“œμ‹œ ν‚€λ³΄λ“œ μ‚¬μš©μ„±μ„ 보μž₯ν•΄μ•Ό ν•œλ‹€.

❌ λ‚˜μœ μ˜ˆμ‹œ (ν‚€λ³΄λ“œ μ ‘κ·Ό λΆˆκ°€)

<div role="button">제좜</div>

βœ… 쒋은 μ˜ˆμ‹œ (ν‚€λ³΄λ“œ 이벀트 μΆ”κ°€)
// λ²„νŠΌμ€ space와 Enterν‚€λ‘œ λ™μž‘μ΄ κ°€λŠ₯ν•΄μ•Ό ν•œλ‹€.
<div 
  role="button" 
  tabindex="0" 
  onclick="alert('제좜됨')" 
  onkeydown="if(event.key === 'Enter' || event.key === ' ' || event.key === 'Space') alert('제좜됨')"
>
  제좜
</div>

βœ… ν•˜μ§€λ§Œ 더 쒋은 방법은 κ·Έλƒ₯ button을 μ‚¬μš©ν•˜λŠ” 것!

<button onclick="alert('제좜됨')">제좜</button>

4. 초점(focus) κ°€λŠ₯ν•œ μš”μ†Œμ— role=presentation λ˜λŠ” aria-hidden="true"속성을 μ‚¬μš©ν•˜μ§€ μ•ŠλŠ”λ‹€.

❌ λ‚˜μœ μ˜ˆμ‹œ

<a href="/home" aria-hidden="true">ν™ˆμœΌλ‘œ</a>

β†’ μ‹œκ°μ μœΌλ‘œλŠ” λ³΄μ΄λŠ”λ° μŠ€ν¬λ¦°λ¦¬λ”μ—μ„œ μ½νžˆμ§€ μ•Šμ•„ μ ‘κ·Όμ„± 문제 λ°œμƒ

βœ… 쒋은 μ˜ˆμ‹œ

<a href="/home">ν™ˆμœΌλ‘œ</a>

5. μƒν˜Έμž‘μš©μ΄ κ°€λŠ₯ν•œ λŒ€ν™”ν˜• μš”μ†Œμ—λŠ” λ°˜λ“œμ‹œ μ ‘κ·Όμ„± APIκ°€ μ ‘κ·Όν•  수 μžˆλŠ” 이름이 μžˆμ–΄μ•Ό ν•œλ‹€.

❌ λ‚˜μœ μ˜ˆμ‹œ (λ ˆμ΄λΈ” μ—†μŒ)

<button></button>

βœ… 쒋은 μ˜ˆμ‹œ (ν…μŠ€νŠΈ 제곡)

<button>검색</button>

βœ… μ•„μ΄μ½˜ λ²„νŠΌμΈ 경우 aria-label 제곡

<button aria-label="검색">
  <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
    <circle cx="6" cy="6" r="5" stroke="black" fill="none"/>
    <line x1="10" y1="10" x2="15" y2="15" stroke="black"/>
  </svg>
</button>

자주 μ‚¬μš©λ˜λŠ” ARIA μƒνƒœ 및 속성

λŒ€ν™”ν˜• μš”μ†Œμ— 슀크린 리더가 μ½λŠ” μˆœμ„œλŠ” "μΈν„°λž™ν‹°λΈŒ μš”μ†Œμ— 이름 뢙이기" 글을 μΆ”μ²œν•œλ‹€.

aria-activedescendant

ν™œμ„±ν™” 된 ν•˜μœ„ ν•­λͺ©μ„ 보쑰 기술(슀크린 리더)을 톡해 μ „λ‹¬ν•˜κΈ° μœ„ν•΄ μ‚¬μš©λœλ‹€.

  • μ‹€μ œ 초점이 μƒμœ„ ν•­λͺ©μ— μœ μ§€λ˜μ–΄ μžˆμ–΄λ„ 슀크린 리더λ₯Ό μ‚¬μš©ν•˜λ©΄ ν™œμ„±ν™”λœ ν•˜μœ„ ν•­λͺ©μ— μ΄ˆμ μ„ 받은 μƒνƒœμΈ 것 처럼 슀크린 리더 초점 ν‘œμ‹œκΈ°κ°€ λ…ΈμΆœλœλ‹€.

βœ… 쒋은 μ˜ˆμ‹œ
<div role="listbox" tabindex="0" aria-activedescendant="item-2">
  <div id="item-1" role="option">μ˜΅μ…˜ 1</div>
  <div id="item-2" role="option" aria-selected="true">μ˜΅μ…˜ 2</div>
  <div id="item-3" role="option">μ˜΅μ…˜ 3</div>
</div>

❌ λ‚˜μœ μ˜ˆμ‹œ
<div role="listbox" tabindex="0" aria-activedescendant="item-99">
  <!-- id="item-99" μš”μ†Œκ°€ μ‘΄μž¬ν•˜μ§€ μ•ŠμŒ -->
</div>

aria-atomic

aria-live μ˜μ—­μ˜ λ‚΄μš©μ΄ 변경될 λ•Œ 슀크린 리더가 읽어쀄 μ˜μ—­μ˜ λ²”μœ„λ₯Ό κ²°μ •ν•˜κΈ° μœ„ν•΄ μ‚¬μš©λœλ‹€.

  • 라이브 μ˜μ—­ 전체 λ‚΄μš©μ„ μ•ˆλ‚΄ν•˜κΈ°λ³΄λ‹€ νŠΉμ • μ˜μ—­μ˜ λ³€κ²½λœ λ‚΄μš©λ§Œ 읽어주길 μ›ν• λ•Œ μ‚¬μš©ν•œλ‹€.
  • λ„ˆλ¬΄ 큰 μ˜μ—­μ„ atomic으둜 μ§€μ •ν•˜λ©΄ λΆˆν•„μš”ν•˜κ²Œ λͺ¨λ“  λ‚΄μš©μ„ 반볡 낭독할 수 μžˆλ‹€.
<div aria-live="polite" aria-atomic="true">
  <span>ν˜„μž¬ μƒνƒœ: </span><span>λŒ€κΈ° 쀑</span>
</div>

aria-autocomplete

μ‚¬μš©μž μž…λ ₯값에 따라 μžλ™ μ™„μ„± 지원 여뢀와 μžλ™ μ™„μ„±λ˜λŠ” 단어듀을 μ–΄λ–€ ν˜•νƒœλ‘œ μ œκ³΅ν• μ§€λ₯Ό λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©λœλ‹€.

  • none: μžλ™μ™„μ„± κΈ°λŠ₯을 μ œκ³΅ν•˜μ§€ μ•ŠμŒ
  • inline: μ‚¬μš©μžκ°€ μž…λ ₯ν•œ κ°’ λ’€μͺ½μœΌλ‘œ μžλ™ μ™„μ„±λœ 값을 제곡
  • list: μ‚¬μš©μžκ°€ μž…λ ₯ν•œ 값에 λ§žλŠ” μžλ™ 완성이 κ°€λŠ₯ν•œ 값듀을 λͺ©λ‘ ν˜•νƒœλ‘œ 제곡
  • both: inline + list λ™μ‹œ 제곡
<input type="text" role="combobox" aria-autocomplete="list" aria-owns="suggestions" />
<ul id="suggestions" role="listbox">
  <li role="option">사과</li>
  <li role="option">λ°”λ‚˜λ‚˜</li>
</ul>

aria-checked

ν˜„μž¬ μš”μ†Œμ˜ '체크된 μƒνƒœ'λ₯Ό λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©λœλ‹€. (checkbox, radio λ“±μ˜ μš”μ†Œμ— 자주 μ‚¬μš©λœλ‹€.)

<div role="checkbox" aria-checked="true" tabindex="0">λ™μ˜ν•©λ‹ˆλ‹€</div>
  • κ°€λŠ₯ν•˜λ©΄ κΈ°λ³Έcheckbox, radio μš”μ†Œλ₯Ό μ‚¬μš©ν•˜λŠ” 게 μ’‹λ‹€.

aria-controls

ν˜„μž¬ μš”μ†Œμ— μ˜ν•΄ μ œμ–΄λ˜λŠ” μš”μ†Œλ₯Ό μ‹λ³„ν•˜κΈ° μœ„ν•΄ μ‚¬μš©λœλ‹€. νŠΉμ • μš”μ†Œμ— μ œμ–΄λ˜λŠ” μš”μ†Œλ₯Ό μ‹λ³„ν•˜μ—¬ μš”μ†Œ κ°„ 관계λ₯Ό λ‚˜νƒ€λ‚Έλ‹€.

⚠️ λŒ€λΆ€λΆ„μ˜ 슀크린 λ¦¬λ”μ—μ„œ 이 속성을 μ§€μ›ν•˜μ§€ μ•Šμ•„ λ…Όμ˜μ€‘μœΌλ‘œ 보인닀.

<button aria-controls="menu" aria-expanded="false">메뉴 μ—΄κΈ°</button>
<ul id="menu" hidden>
  <li>ν•­λͺ©1</li>
  <li>ν•­λͺ©2</li>
</ul>

aria-current

μ—°κ΄€λœ μš”μ†Œ κ·Έλ£Ή λ‚΄μ—μ„œ ν˜„μž¬ ν•­λͺ©μ— ν•΄λ‹Ήν•˜λŠ” μš”μ†Œλ₯Ό λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©λœλ‹€. 같은 κ·Έλ£Ή λ‚΄ β€œν•˜λ‚˜μ˜ μš”μ†Œβ€μ—λ§Œ aria-current="true"κ°€ μžˆμ–΄μ•Ό ν•œλ‹€.

  • page: λ„€λΉ„κ²Œμ΄μ…˜ 링크 λ©”λ‰΄μ—μ„œ ν˜„μž¬ νŽ˜μ΄μ§€λ₯Ό λ‚˜νƒ€λ‚Ό λ•Œ μ‚¬μš©ν•œλ‹€.
  • step: 단계가 μžˆλŠ” UIμ—μ„œ ν˜„μž¬ 단계λ₯Ό λ‚˜νƒ€λ‚Ό λ•Œ μ‚¬μš©ν•œλ‹€.
  • location: 계측 ꡬ쑰의 UIμ—μ„œ ν˜„μž¬ μœ„μΉ˜λ₯Ό λ‚˜νƒ€λ‚Ό λ•Œ μ‚¬μš©ν•œλ‹€.
  • date: μ—¬λŸ¬ λ‚ μ§œ μ€‘μ—μ„œ ν˜„μž¬ λ‚ μ§œλ₯Ό λ‚˜νƒ€λ‚Ό λ•Œ μ‚¬μš©ν•œλ‹€.
  • time: μ—¬λŸ¬ μ‹œκ°„ μ€‘μ—μ„œ ν˜„μž¬ μ‹œκ°„μ„ λ‚˜νƒ€λ‚Ό λ•Œ μ‚¬μš©ν•œλ‹€.
  • true|false: λ‹€λ₯Έ κ°’λ“€λ‘œ νŠΉμ •ν•  수 μ—†λŠ” ν˜„μž¬ ν•­λͺ©μ„ λ‚˜νƒ€λ‚Ό λ•Œ μ‚¬μš© | ν˜„μž¬ ν•­λͺ©μ„ λ‚˜νƒ€λ‚΄μ§€ μ•ŠλŠ”λ‹€.
<nav>
  <a href="/home">ν™ˆ</a>
  <a href="/about" aria-current="page">μ†Œκ°œ</a>
  <a href="/contact">문의</a>
</nav>

aria-describedby

ν˜„μž¬ μš”μ†Œμ— λŒ€ν•œ μƒμ„Έν•œ μ„€λͺ…을 λ‹€λ₯Έ μš”μ†Œλ₯Ό 톡해 μ œκ³΅ν•˜κΈ° μœ„ν•΄ μ‚¬μš©λœλ‹€.

  • μƒμ„Έν•œ μ„€λͺ…이 ν•„μš”ν•œ μš”μ†Œλ₯Ό νŽ˜μ΄μ§€μ— λžœλ”λ§λ˜μ–΄ μžˆλŠ” id값을 찾게 λœλ‹€.
  • μ΄λ•ŒλŠ” μ‹œκ°μ μœΌλ‘œλŠ” 숨겨져 μžˆμ–΄λ„ λ˜μ§€λ§Œ, display:none, aria-hidden="true"와 같이 슀크린 리더가 인지할 수 μ—†μœΌλ©΄ μ„€λͺ…을 μ œκ³΅ν•˜μ§€ λͺ»ν•œλ‹€.
<button aria-describedby="tip1">μ‚­μ œ</button>
<p id="tip1" class="sr-only">이 λ²„νŠΌμ€ μ„ νƒν•œ ν•­λͺ©μ„ μ‚­μ œν•©λ‹ˆλ‹€.</p>

aria-disabled

ν˜„μž¬ μš”μ†Œκ°€ μ ‘κ·Ό κ°€λŠ₯ν•˜μ§€λ§Œ 의미적으둜 λΉ„ν™œμ„±ν™”λœ μƒνƒœμž„μ„ μ•Œλ¦¬κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

μ—¬κΈ°μ„œ μ€‘μš”ν•œ 점은 μš”μ†Œκ°€ μ ‘κ·Ό κ°€λŠ₯ν•˜κΈ° λ•Œλ¬Έμ— μ‹œκ°μ μœΌλ‘œ 보이고, λͺ¨λ“  κΈ°λŠ₯이 μ •μƒμ μœΌλ‘œ λ™μž‘ν•œλ‹€. κ·ΈλŸ¬λ―€λ‘œ μ‹€μ œ κΈ°λŠ₯을 끄기 μœ„ν•΄μ„œλŠ” disabled 속성을 μ‚¬μš©ν•΄μ•Ό ν•œλ‹€.

  • κΈ°λŠ₯은 λΉ„ν™œμ„±ν™”λ˜μ§€λ§Œ μ‚¬μš©μžκ°€ ν‚€λ³΄λ“œ Tabν‚€λ‘œ ν•΄λ‹Ή μš”μ†Œλ₯Ό 탐색할 수 있기λ₯Ό μ›ν•˜λŠ” 경우
<!-- μ €μž₯ λ²„νŠΌμ€ 아직 λ™μž‘ν•˜μ§€ μ•Šμ§€λ§Œ Tab으둜 탐색 κ°€λŠ₯ -->
<button aria-disabled="true" tabindex="0" onclick="alert('μ‹€ν–‰λ˜μ§€ μ•ŠμŒ')">
  μ €μž₯
</button>
  • μš”μ†Œμ˜ μ›λž˜ κΈ°λŠ₯은 λΉ„ν™œμ„±ν™”λ˜μ§€λ§Œ λ‹€λ₯Έ κΈ°λŠ₯의 이벀트λ₯Ό μ›ν•˜λŠ” 경우
<!-- κ΅¬λ§€ν•˜κΈ° λ²„νŠΌμ€ 아직 λ§‰μ•„λ‘μ§€λ§Œ, ν΄λ¦­ν•˜λ©΄ μ•ˆλ‚΄ λ©”μ‹œμ§€ ν‘œμ‹œ -->
<button aria-disabled="true" onclick="alert('κ΅¬λ§€λŠ” 둜그인 ν›„ κ°€λŠ₯ν•©λ‹ˆλ‹€.')">
  κ΅¬λ§€ν•˜κΈ°
</button>
  • HTML disabled 속성을 μ§€μ›ν•˜μ§€ μ•ŠλŠ” μš”μ†Œμ— ARIA역할을 μΆ”κ°€ν•΄ λŒ€ν™”ν˜• μš”μ†Œλ‘œ λ§Œλ“  경우
<!-- role="button" 으둜 λŒ€ν™”ν˜• μš”μ†Œλ₯Ό λ§Œλ“€κ³ , aria-disabled둜 λΉ„ν™œμ„±ν™” ν‘œμ‹œ -->
<div role="button" aria-disabled="true" tabindex="0" onclick="alert('μ‹€ν–‰λ˜μ§€ μ•ŠμŒ')">
  μ»€μŠ€ν…€ λ²„νŠΌ
</div>

aria-expanded

ν˜„μž¬ μš”μ†Œμ˜ ν™•μž₯/μΆ•μ†Œ μ—¬λΆ€λ₯Ό λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€. μ΄λ•Œ μ€‘μš”ν•œ 점은 이미 ν…μŠ€νŠΈλ‘œ 'ν™•μž₯/μΆ•μ†Œ μ—¬λΆ€'λ₯Ό λ‚˜νƒ€λ‚΄κ³  μžˆλ‹€λ©΄ μ€‘λ³΅μœΌλ‘œ 속성을 λΆ€μ—¬ν•˜μ§€ μ•Šμ•„λ„ λœλ‹€.

<button type="button" aria-expanded={isExpanded} aria-controls="detail">
  상세 보기
  // ν™”λ©΄μƒμ—λŠ” ν‘œκΈ°ν•˜μ§€ μ•ŠμŒ
  <span className="visually-hidden">{isExpanded ? "ν™•μž₯됨" : "μΆ•μ†Œλ¨"}</span>
</button>
<div id="detail" hidden>상세 λ‚΄μš©</div>

// isExpanded="true"인 경우 πŸ“’ 슀크린 리더: "상세보기: ν™•μž₯됨,ν™•μž₯됨, λ²„νŠΌ"
// isExpanded="false"인 경우 πŸ“’ 슀크린 리더: "상세보기: μΆ•μ†Œλ¨,μΆ•μ†Œλ¨, λ²„νŠΌ"

μ΄λ ‡κ²Œ 슀크린 λ¦¬λ”μ—μ„œ λ‘λ²ˆ λ°˜λ³΅λ˜μ–΄ λ‚˜μ˜€κΈ° λ•Œλ¬Έμ— λ³„λ„λ‘œ ν…μŠ€νŠΈλ₯Ό 넣을 ν•„μš”κ°€ μ—†λ‹€.

또 λ‹€λ₯Έ μ˜ˆμ‹œλ‘œλŠ”

<ul>
  <li>질문</li>
  ...
  {isExpanded && <li></li>}
</ul>
<button type="button" onClick={() => setIsExpanded(!isExpanded)}>
  {isExpanded ? "μ ‘κΈ°" : "더보기"}
</button>

이미 λ²„νŠΌμ˜ ν…μŠ€νŠΈλ‘œ "μ ‘κΈ°/더보기"λ₯Ό μ œκ³΅ν•΄μ£ΌκΈ° λ•Œλ¬Έμ— aria 속성을 넣을 ν•„μš”κ°€ μ—†λ‹€.

aria-haspopup

ν˜„μž¬ μš”μ†Œμ— μ˜ν•΄ μ–΄λ–€ μœ ν˜•μ˜ λŒ€ν™”ν˜• νŒμ—… μš”μ†Œκ°€ λ…ΈμΆœλ  것인지λ₯Ό λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©λœλ‹€.
슀크린 리더 μ‚¬μš©μžμ—κ²Œ μ–΄λ–€ νŒμ—… μš”μ†Œκ°€ λ…ΈμΆœλ  것인지 미리 μ•Œλ €μ£ΌλŠ” 역할을 ν•˜λŠ” 것이닀.

-menu, listbox, tree, grid, dialog, true|false 와 같은 속성듀이 μžˆλ‹€.

aria-hidden

슀크린 리더λ₯Ό μ‚¬μš©μ€‘μΈ μ‚¬μš©μžλ“€μ—κ²Œ ν˜„μž¬ μš”μ†Œλ₯Ό 숨기기 μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

  • css둜 μ½˜ν…μΈ λ₯Ό 숨길 κ²½μš°μ—λŠ” 슀크린 리더가 읽지 λͺ»ν•˜κΈ° λ•Œλ¬Έμ— 속성을 λΆ€μ—¬ν•˜μ§€ μ•Šμ•„λ„ λœλ‹€.
  • λΆ€λͺ¨ μš”μ†Œκ°€ aria-hidden="true"둜 λ˜μ–΄ μžˆμ„ 경우 μžμ‹ μš”μ†ŒλŠ” λͺ¨λ‘ 읽을 수 μ—†λ‹€.

μƒμœ„ μš”μ†Œμ— aria-hidden을 넣어도 초점 κ°€λŠ₯ν•œ μš”μ†Œκ°€ μžμ‹μ— μžˆλ‹€λ©΄ Tabν‚€λ‘œ 접근이 κ°€λŠ₯ν•˜λ‹€.

❌ λ‚˜μœ μ˜ˆμ‹œ

// λ²„νŠΌ νƒœκ·ΈλŠ” focusκ°€ κ°€λŠ₯
<div aria-hidden="true">
  <button type="button">...</button>
</div>

// tabIndexκ°€ 0이면 focusκ°€ κ°€λŠ₯
<div aria-hidden="true">
  <a tabIndex={0}>...</a>
</div>

κ·ΈλŸ¬λ―€λ‘œ ν•˜μœ„ μš”μ†Œμ— 초점 κ°€λŠ₯ν•œ μš”μ†Œκ°€ μžˆλ‹€λ©΄ μ œκ±°ν•˜κ±°λ‚˜ 초점 κ°€λŠ₯ν•œ μš”μ†Œλ₯Ό 초점 λΆˆκ°€ν•˜κ²Œ λ§Œλ“€μ–΄μ•Ό ν•œλ‹€.

// 초점이 λΆˆκ°€ν•˜λ„λ‘ -1둜 μ§€μ •
<div aria-hidden="true">
  <a tabIndex={-1}>...</a>
</div>

// css둜 숨기기
<div aria-hidden="true">
  <button type="button" style={{ display: "none" }}>...</button>
</div>

// disabled 속성 μ§€μ •
<div aria-hidden="true">
  <button type="button" disabled>...</button>
</div>

aria-label

ν˜„μž¬ μš”μ†Œμ˜ 이름을 λ¬Έμžμ—΄ ν˜•νƒœλ‘œ μ œκ³΅ν•˜μ—¬ 슀크린 리더λ₯Ό 톡해 λͺ©μ μ„ 인식할 수 μžˆλ„λ‘ ν•˜κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

  • μ½˜ν…μΈ μ— μ ‘κ·Ό κ°€λŠ₯ν•œ λ ˆμ΄λΈ”μ΄ μ—†κ³ , μ™ΈλΆ€μ—μ„œ ν•΄λ‹Ή μ½˜ν…μΈ μ˜ μ„€λͺ…이 κ°€λŠ₯ν•˜λ‹€λ©΄ aria-lablledby 속성을 μ‚¬μš©ν•˜λ©΄ λœλ‹€.

aria-label, aria-labelledbyλ₯Ό λ™μ‹œμ— μ‚¬μš©ν•˜λ©΄ labelledbyκ°€ μš°μ„ μœΌλ‘œ μ μš©λœλ‹€.

// 슀크린 리더: "μ—΄κΈ°, λ²„νŠΌ"
<button type="button" aria-label="λ‹«κΈ°" aria-labelledby="button-label">
  <img src="close.png" alt="" />
</button>
<span id="button-label">μ—΄κΈ°</span>

aria-labelledby

ν˜„μž¬ μš”μ†Œμ˜ λ ˆμ΄λΈ”μ„ λ‹€λ₯Έ μš”μ†Œλ₯Ό 톡해 μ •μ˜ν•˜κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

  • λ ˆμ΄λΈ”μ΄ ν•„μš”ν•œ μš”μ†Œμ— aria-labelledby 속성을 μΆ”κ°€ν•˜κ³ , μš”μ†Œμ˜ id값을 μ°Έμ‘°ν•˜μ—¬ μ„€λͺ…이 κ°€λŠ₯ν•˜λ‹€.
  • μ—¬λŸ¬ μš”μ†Œμ˜ id값을 μ°Έμ‘°ν•˜μ—¬, 참쑰된 μˆœμ„œμ— 따라 λ ˆμ΄λΈ”μ„ κ²°ν•©ν•˜μ—¬ 전달할 수 μžˆλ‹€.
// 슀크린 리더: "결제 λΉ„λ°€λ²ˆν˜Έ 숫자 6자리, λΉ„λ°€λ²ˆν˜Έ μž…λ ₯μ°½"
<span id="payment-label">결제 λΉ„λ°€λ²ˆν˜Έ</span>
<span id="payment-hint">(숫자 6자리)</span>

<input type="password" aria-labelledby="payment-label payment-hint" />

aria-level

계측 ꡬ쑰 λ‚΄μ—μ„œ ν˜„μž¬ μš”μ†Œμ˜ 계측적 μˆ˜μ€€μ„ λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

  • 계측 ꡬ쑰 λ‚΄μ—μ„œ μš”μ†Œ κ°„μ˜ 계측 μˆ˜μ€€μ„ λͺ…μ‹œμ μœΌλ‘œ ν‘œμ‹œν• λ•Œ μ‚¬μš©ν•œλ‹€.
  • 즉, λ§ˆν¬μ—… κ΅¬μ‘°μƒμœΌλ‘œ 계측 μˆ˜μ€€ νŒŒμ•…μ΄ κ°€λŠ₯ν•˜λ‹€λ©΄ 속성을 μΆ”κ°€ν•˜μ§€ μ•Šμ•„λ„ λœλ‹€.

aria-live

νŠΉμ • μš”μ†Œμ˜ λ‚΄μš©μ΄ λ™μ μœΌλ‘œ 변경될 λ•Œ, 슀크린 리더 μ‚¬μš©μžμ—κ²Œ μ•Œλ¦¬κΈ° μœ„ν•΄ μ‚¬μš©λœλ‹€.

  • νŽ˜μ΄μ§€κ°€ λ‘œλ“œ 된 이후 λ™μ μœΌλ‘œ λ³€κ²½λ˜λŠ” μ½˜ν…μΈ λ“€μ„ μ•Œλ €μ£ΌκΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.
  • polite: μ½˜ν…μΈ κ°€ μ—…λ°μ΄νŠΈλ˜λ©΄ 보쑰기술(μŠ€ν¬λ¦°λ¦¬λ”)이 ν˜„μž¬ μ „λ‹¬ν•˜κ³  μžˆλŠ” λ©”μ‹œμ§€λ₯Ό λͺ¨λ‘ μ „λ‹¬ν•œ ν›„ μ—…λ°μ΄νŠΈ 된 λ‚΄μš©μ„ μ•Œλ¦°λ‹€.
  • assertive: μ½˜ν…μΈ κ°€ μ—…λ°μ΄νŠΈλ˜λ©΄ 보쑰기술이 ν˜„μž¬ μ „λ‹¬ν•˜κ³  μžˆλŠ” λ©”μ‹œμ§€λ₯Ό μ€‘μ§€ν•˜κ³  μ¦‰μ‹œ μ—…λ°μ΄νŠΈλœ μ½˜ν…μΈ λ₯Ό μ•Œλ¦°λ‹€.
  • off: κΈ°λ³Έκ°’μœΌλ‘œ ν•΄λ‹Ή μš”μ†Œ λ‚΄λΆ€κ°€ aria-live μ˜μ—­μœΌλ‘œ μ§€μ •λ˜μ§€ μ•ŠλŠ”λ‹€.

aria-modal

ν˜„μž¬ μš”μ†Œμ˜ λͺ¨λ‹¬ μ—¬λΆ€λ₯Ό λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

  • λͺ¨λ‹¬μ΄ λ…ΈμΆœλ˜λ©΄ 초점(focus)κ°€ λͺ¨λ‹¬ λ‚΄λΆ€λ‘œ 이동해야 ν•œλ‹€.
  • λͺ¨λ‹¬μ΄ λ‹«νžˆκΈ° μ „ κΉŒμ§€ λͺ¨λ‹¬ λ‚΄λΆ€μ—μ„œλ§Œ 이동 κ°€λŠ₯ν•˜λ„λ‘ μ œν•œλ˜μ–΄μ•Ό ν•œλ‹€.
  • λͺ¨λ‹¬μ΄ λ‹«νžˆλ©΄ 이전에 λ…ΈμΆœμ‹œν‚¨ 트리거 μš”μ†Œλ‘œ 초점이 λ³΅κ·€λ˜μ–΄μ•Ό ν•œλ‹€.
<div role="dialog" aria-modal="true">
  <h2>둜그인</h2>
  <form>...</form>
</div>

aria-multiselectable

ν˜„μž¬ ν•­λͺ©μ˜ 선택 κ°€λŠ₯ν•œ ν•˜μœ„ ν•­λͺ©μ„ 2개 이상 λ™μ‹œμ— 선택할 수 μžˆλŠ”μ§€λ₯Ό λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

  • μ„ νƒλœ ν•­λͺ©μ—λŠ” aria-selected="true" 속성을 μΆ”κ°€ν•΄μ„œ μ„ νƒλ˜μ—ˆλŠ”μ§€ μ•Œλ €μ•Ό ν•œλ‹€.
<ul role="listbox" aria-multiselectable="true">
  <li role="option" aria-selected="true">사과</li>
  <li role="option" aria-selected="true">λ°”λ‚˜λ‚˜</li>
  <li role="option" aria-selected="false">체리</li>
</ul>

aria-orientation

ν˜„μž¬ μš”μ†Œμ˜ λ°©ν–₯이 κ°€λ‘œμΈμ§€ μ„Έλ‘œμΈμ§€λ₯Ό λ‚˜νƒ€λ‚΄λŠ” 데 μ‚¬μš©ν•œλ‹€.

  • horizontal(μˆ˜ν‰ λ°©ν–₯), vertical(수직 λ°©ν–₯)을 μ§€μ •ν•  수 μžˆλ‹€.
  • κΈ°λ³Έ λ°©ν–₯κ³Ό λ‹€λ₯Έ UIλ₯Ό κ΅¬ν˜„ν•  경우 속성을 μΆ”κ°€ν•΄ λ°©ν–₯을 λ‚˜νƒ€λ‚΄μ•Ό ν•œλ‹€.
<!-- μ„Έλ‘œ νƒ­ UI -->
<div role="tablist" aria-orientation="vertical">
  <button role="tab" aria-selected="true">νƒ­ 1</button>
  <button role="tab" aria-selected="false">νƒ­ 2</button>
  <button role="tab" aria-selected="false">νƒ­ 3</button>
</div>

aria-pressed

λ²„νŠΌμ„ ν† κΈ€ λ²„νŠΌ μ—­ν• λ‘œ λ³€κ²½μ‹œν‚€κ³ , ν† κΈ€ λ²„νŠΌμ΄ ν˜„μž¬ 눌린 μƒνƒœμΈμ§€ λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

  • aria-pressed 속성을 톡해 μƒνƒœ λ³€ν™”λ₯Ό λ‚˜νƒ€λ‚΄λŠ”λ°, μ΄λ•Œ λ ˆμ΄λΈ”λ„ λ³€ν™”λ₯Ό μ£Όλ©΄ ν˜Όλž€μ„ 쀄 수 μžˆλ‹€. λ ˆμ΄λΈ”μ„ 톡해 λ²„νŠΌμ˜ μƒνƒœκ°€ λ‚˜νƒ€λ‚œλ‹€λ©΄ ꡳ이 μ œκ³΅ν•˜μ§€ μ•Šμ•„λ„ λœλ‹€.
export const PlayPauseButton = () => {
  const [playing, setPlaying] = useState(false);

  const handleClick = () => {
    setPlaying(prev => !prev);
  };

  return (
    <button
      onClick={handleClick}
      aria-pressed={playing}         // ν˜„μž¬ μƒνƒœλ₯Ό 슀크린 리더에 전달
      aria-label={playing ? "μΌμ‹œμ •μ§€" : "μž¬μƒ"} // λ ˆμ΄λΈ”λ‘œ μƒνƒœ 제곡
    >
      {playing ? "⏸️ μΌμ‹œμ •μ§€" : "▢️ μž¬μƒ"}
    </button>
  );
};

// μž¬μƒ μ „ πŸ“’ 슀크린 리더: "μž¬μƒ, λ²„νŠΌ, λˆŒλ¦¬μ§€ μ•ŠμŒ"
// μž¬μƒ 쀑 πŸ“’ 슀크린 리더: "μΌμ‹œμ •μ§€, λ²„νŠΌ, 눌림"

λ ˆμ΄λΈ”κ³Ό μƒνƒœ λ³€ν™”λ₯Ό λ™μ‹œμ— μ£Όλ©΄ 슀크린 리더가 읽은 λ‚΄μš©μ΄ ν—·κ°ˆλ¦΄ 수 μžˆλ‹€.
즉, label을 κ³ μ •ν•΄μ„œ 슀크린 리더가 μ½μ„λ•Œ λ‚΄μš©μ΄ ν—·κ°ˆλ¦¬μ§€ μ•Šκ²Œ ν•΄μ•Ό ν•œλ‹€.

export const PlayPauseButton = () => {
  const [playing, setPlaying] = useState(false);

  const handleClick = () => {
    setPlaying(prev => !prev);
  };

  return (
    <button
      onClick={handleClick}
      aria-pressed={playing}       // μƒνƒœλŠ” aria-pressed둜만 전달
      aria-label="μž¬μƒ/μΌμ‹œμ •μ§€"   // λ ˆμ΄λΈ”μ€ κ³ μ •
    >
      {/* μ‹œκ°μ  ν‘œμ‹œ: 선택 사항 */}
      {playing ? "⏸️" : "▢️"}
    </button>
  );
};

// μž¬μƒ μ „ πŸ“’ 슀크린 리더: "μž¬μƒ/μΌμ‹œμ •μ§€, λ²„νŠΌ, λˆŒλ¦¬μ§€ μ•ŠμŒ"
// μž¬μƒ 쀑 πŸ“’ 슀크린 리더: "μž¬μƒ/μΌμ‹œμ •μ§€, λ²„νŠΌ, 눌림"

aria-readonly

ν˜„μž¬ μš”μ†Œκ°€ 읽기 μ „μš© μƒνƒœμž„(νŽΈμ§‘ λΆˆκ°€λŠ₯)을 λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

  • μ‹€μ œ μš”μ†Œκ°€ νŽΈμ§‘ λΆˆκ°€λŠ₯ν•˜λ„λ‘ μ§€μ •ν•˜λŠ”κ²Œ μ•„λ‹ˆκΈ° λ•Œλ¬Έμ—, νŽΈμ§‘ λΆˆκ°€λŠ₯ν•˜λ„λ‘ μ²˜λ¦¬ν•˜κΈ° μœ„ν•΄μ„œλŠ” μžλ°”μŠ€ν¬λ¦½νŠΈλ₯Ό 톡해 막아야 ν•œλ‹€.
  • readonly 속성을 μ‚¬μš©ν•  수 μžˆλ‹€λ©΄, ν•΄λ‹Ή μ†μ„±μœΌλ‘œλ„ μΆ©λΆ„ν•˜λ‹€.

λ‹€μŒκ³Ό 같이 div νƒœκ·Έλ₯Ό input처럼 μ‚¬μš©ν•˜κ³  readonly둜 μ“°κ³  μ‹Άμ„λ•ŒλŠ” μ•„λž˜ μ½”λ“œμ™€ 같이 써야 ν•œλ‹€.

<div role="textbox" aria-readonly="true" contenteditable="true" id="readonly-box">
  읽기 μ „μš© κ°’
</div>

<script>
  const readonlyBox = document.getElementById("readonly-box");

  // ν‚€ μž…λ ₯ 차단
  readonlyBox.addEventListener("keydown", (e) => {
    e.preventDefault();
  });

  // 마우슀둜 λΆ™μ—¬λ„£κΈ° 차단
  readonlyBox.addEventListener("paste", (e) => {
    e.preventDefault();
  });
</script>

aria-required

양식 제좜 전에 μ‚¬μš©μž μž…λ ₯이 ν•„μš”ν•œ μš”μ†Œμž„μ„ λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

  • input, select, textarea νƒœκ·Έμ˜ required 속성을 μ‚¬μš©ν•œλ‹€λ©΄ 속성을 λΆ€μ—¬ν•˜μ§€ μ•Šμ•„λ„ λœλ‹€.
  • aria-requiredλ₯Ό μ‚¬μš©ν•œλ‹€κ³  ν•΄μ„œ μœ νš¨μ„± κ²€μ‚¬λ‚˜, 제좜 λ°©μ§€λ₯Ό λ”°λ‘œ μ œκ³΅ν•˜μ§€ μ•ŠκΈ° λ•Œλ¬Έμ— 직접 κ΅¬ν˜„ν•΄μ•Ό ν•œλ‹€.
<form id="signup-form">
  <label for="email">이메일</label>
  <input type="email" id="email" aria-required="true" />

  <button type="submit">κ°€μž…</button>
</form>

<script>
  document.getElementById("signup-form").addEventListener("submit", (e) => {
    const email = document.getElementById("email");
    if (!email.value.trim()) {
      e.preventDefault();
      alert("이메일은 ν•„μˆ˜ μž…λ ₯ ν•­λͺ©μž…λ‹ˆλ‹€.");
      email.focus();
    }
  });
</script>

(μ΄λ ‡κ²Œ ν• κ±°λ©΄ required 속성을 μ“°λŠ”κ²Œ 더 μ’‹λ‹€.)

aria-selected

ν˜„μž¬ μš”μ†Œκ°€ μ„ νƒλ˜μ—ˆλŠ”μ§€ μ—¬λΆ€λ₯Ό λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

  • ν˜„μž¬ μ„ νƒλœ μš”μ†Œμ— true값을, μ•„λ‹Œ μš”μ†Œμ—λŠ” false λ₯Ό λΆ€μ—¬ν•˜μ—¬ μ„ νƒλ˜μ—ˆλŠ”μ§€λ₯Ό μ•Œλ €μ•Ό ν•œλ‹€.

aria-valuemax/aria-valuemin/aria-valuenow

λ²”μœ„λ₯Ό λ‚˜νƒ€λ‚΄λŠ” ν˜„μž¬ μš”μ†Œμ—μ„œ ν—ˆμš©λ˜λŠ” μ΅œλŒ“κ°’/μ΅œμ†Ÿκ°’/ν˜„μž¬κ°’μ„ λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

  • 숫자둜 μ΅œλŒ“κ°’/μ΅œμ†Ÿκ°’/ν˜„μž¬κ°’μ„ λ‚˜νƒ€λ‚΄μ•Ό ν•œλ‹€.

aria-valuetext

λ²”μœ„λ₯Ό λ‚˜νƒ€λ‚΄λŠ” ν˜„μž¬ μš”μ†Œμ—μ„œ ν˜„μž¬ 값을 μ›ν•˜λŠ” ν…μŠ€νŠΈ ν˜•μ‹μœΌλ‘œ λ‚˜νƒ€λ‚΄κΈ° μœ„ν•΄ μ‚¬μš©ν•œλ‹€.

  • 슀크린 리더에 따라 λ°±λΆ„μœ¨λ‘œ κ³„μ‚°ν•˜μ—¬ μ•ˆλ‚΄ν•˜λŠ” κ²½μš°λ„ 있고, μˆ«μžλ§ŒμœΌλ‘œλŠ” ν˜„μž¬κ°’μ΄ μ–΄λ–€ 값인지 λͺ¨λ₯Όμˆ˜ μžˆλ‹€.
  • κΈ°ν˜Έλ‚˜ λ‹¨μœ„λ₯Ό μΆ”κ°€ν•˜μ—¬ μ›ν•˜λŠ” ν…μŠ€νŠΈ ν˜•μ‹μœΌλ‘œ 값을 μ œκ³΅ν•  수 μžˆλ‹€.
<div role="slider" 
     aria-valuemin="0" 
     aria-valuemax="100" 
     aria-valuenow="30" 
     aria-valuetext="30% μ™„λ£Œ">
</div>

μ΄λ ‡κ²Œ μ •λ¦¬ν•˜κ³  보면 HTMLμ—μ„œ μ œκ³΅ν•΄μ£ΌλŠ” 기본적인 νƒœκ·Έλ“€μ΄ μžˆλ‹€λ©΄ 이λ₯Ό ν™œμš©ν•˜λŠ”κ²Œ κ°€μž₯ μ’‹κ³ , 슀크린 리더가 μ½ν˜”μ„ λ•Œλ₯Ό κ³ λ €ν•˜μ—¬ aria-xx 속성을 λΆ€μ—¬ν•˜λŠ”κ²Œ κ°€μž₯ μ€‘μš”ν•˜λ‹€.

🧱 접근성을 μ§€ν‚€λŠ” μ»΄ν¬λ„ŒνŠΈ λ§Œλ“€κΈ°

μ±…μ—μ„œλŠ” Accordion, Carousel, Dialog λ“± ν”„λ‘ νŠΈμ—”λ“œ κ°œλ°œμ„ ν•˜λ‹€λ³΄λ©΄ 자주 마주치게 λ˜λŠ” UI에 λŒ€ν•΄μ„œ 접근성을 μ§€ν‚€λ©΄μ„œ κ°œλ°œν•˜λŠ” 방법듀을 μ•Œλ €μ€€λ‹€.

이 쀑에 κ°€μž₯ 자주 μ“°μ΄μ§€λ§Œ 그만큼 κ΅¬ν˜„ 방식이 λ„ˆλ¬΄λ‚˜λ„ λ‹€μ–‘ν•œ Dialog μ»΄ν¬λ„ŒνŠΈλ₯Ό λœ―μ–΄λ³΄κ³ , Radix/ui의 Dialog μ»΄ν¬λ„ŒνŠΈλ₯Ό λœ―μ–΄λ³΄λ €κ³  ν•œλ‹€.

Dialog μ»΄ν¬λ„ŒνŠΈμ— μ§€μΌœμ•Όν•  μ ‘κ·Όμ„± κ·œμΉ™

  • μŠ€ν¬λ¦°λ¦¬λ” μ‚¬μš©μžκ°€ dialog 역할을 인지 ν•  수 μžˆλ„λ‘ ν•œλ‹€.(role & aria-modal)
  • 화면에 λ‚˜νƒ€λ‚˜κ²Œ ν•˜λŠ” 트리고 μš”μ†Œκ°€ dialog 역할을 인지 ν•  수 μžˆλ„λ‘ ν•œλ‹€. (aria-haspopup)
  • λ‹€μ΄μ–Όλ‘œκ·Έλ“€μ˜ 각 μš”μ†Œμ— μ„€λͺ…을 λ‚˜νƒ€λ‚Έλ‹€. (aria-labelledby, aria-describedby)
  • dialogκ°€ 열리면 초점이 λ‚΄λΆ€λ‘œ μ΄λ™λ˜μ–΄μ•Ό ν•˜κ³ , 초점 이동이 λ‹€μ΄μ–Όλ‘œκ·Έ μ•ˆμ—μ„œλ§Œ 이뀄져야 ν•œλ‹€.

μœ„μ˜ κ·œμΉ™λ“€μ„ ν•˜λ‚˜μ”© μ§€ν‚€λ©΄μ„œ κ΅¬ν˜„ν•΄λ³΄μž.

dialog 역할을 인지할 수 μžˆλ„λ‘ λͺ…μ‹œ

  • aria-haspopup을 μ‚¬μš©ν•˜μ—¬ dialogκ°€ 열릴 κ²ƒμ΄λΌλŠ” 것을 ν‘œκΈ°ν•œλ‹€.
  • role="dialog",aria-modal="true"λ₯Ό 톡해 dialog ν˜•νƒœμΈ 것을 λͺ…μ‹œν•œλ‹€. (μ΄λ•Œ aria-modalλŠ” λͺ¨λ‹¬ν˜•νƒœμΈ 것을 μ˜λ―Έν•œλ‹€.)
function Dialog() {
  const dialogRef = useRef(null);
  const contentRef = useRef(null);

  const [showDialog, setShowDialog] = useState(false);

  return (
    <>
      {/* Dialogλ₯Ό 화면에 λ‚˜νƒ€λ‚˜κ²Œ ν•˜λŠ” 트리거 μš”μ†Œ */}
      <button
        type="button"
        // ν˜„μž¬ μ—΄λ¦¬κ²Œ 될 것이 dialog인 것을 λͺ…μ‹œν•œλ‹€.
        aria-haspopup="dialog"
        onClick={() => setShowDialog(true)}
        className={cx("button")}
      >
        Dialog λ…ΈμΆœ
      </button>
      {showDialog && (
        <div
          ref={dialogRef}
          // dialog μ—­ν• κ³Ό modalμž„μ„ λͺ…μ‹œν•œλ‹€.
          role="dialog"
          aria-modal="true"
          className={cx("dialog")}
        >
          <div ref={contentRef} tabIndex={-1} className={cx("content")}>
            <button
              type="button"
              aria-label="λ‹«κΈ°"
              onClick={() => setShowDialog(false)}
              className={cx("button-close")}
            />
            <h2 className={cx("title")}>
              λ‹€μ΄μ–Όλ‘œκ·Έ 타이틀
            </h2>
            <p className={cx("desc")}>
              λ‹€μ΄μ–Όλ‘œκ·Έ μ„€λͺ…
            </p>
            <button
              type="button"
              onClick={() => setShowDialog(false)}
              className={cx("button-confirm")}
            >
              확인
            </button>
          </div>
        </div>
      )}
    </>
  );
}

λ‹€μ΄μ–Όλ‘œκ·Έλ“€μ˜ 각 μš”μ†Œμ— μ„€λͺ…을 λ‚˜νƒ€λ‚Έλ‹€.
aria-labelledby,aria-describedbyλ₯Ό μ‚¬μš©ν•˜μ—¬ 제λͺ©κ³Ό μ„€λͺ…을 id값을 μ°Έμ‘°ν•œλ‹€.

function Dialog() {
  const dialogRef = useRef(null);
  const contentRef = useRef(null);

  const [showDialog, setShowDialog] = useState(false);

  return (
    <>
      {/* Dialogλ₯Ό 화면에 λ‚˜νƒ€λ‚˜κ²Œ ν•˜λŠ” 트리거 μš”μ†Œ */}
      <button
        type="button"
        aria-haspopup="dialog"
        onClick={() => setShowDialog(true)}
        className={cx("button")}
      >
        Dialog λ…ΈμΆœ
      </button>
      {showDialog && (
        <div
          ref={dialogRef}
          role="dialog"
          aria-modal="true"
          // λͺ¨λ‹¬ μš”μ†Œμ˜ μ„€λͺ…을 μœ„ν•΄ idλ₯Ό μΆ”κ°€ν•œλ‹€.
          aria-labelledby="dialog-title-id"
          aria-describedby="dialog-description-id"
          className={cx("dialog")}
        >
          <div ref={contentRef} tabIndex={-1} className={cx("content")}>
            <button
              type="button"
              aria-label="λ‹«κΈ°"
              onClick={() => setShowDialog(false)}
              className={cx("button-close")}
            />
            // idλ₯Ό λ§€ν•‘ν•œλ‹€.
            <h2 id="dialog-title-id" className={cx("title")}>
              λ‹€μ΄μ–Όλ‘œκ·Έ 타이틀
            </h2>
            <p id="dialog-description-id" className={cx("desc")}>
              λ‹€μ΄μ–Όλ‘œκ·Έ μ„€λͺ…
            </p>
            <button
              type="button"
              onClick={() => setShowDialog(false)}
              className={cx("button-confirm")}
            >
              확인
            </button>
          </div>
        </div>
      )}
    </>
  );
}

dialog λ‚΄λΆ€μ—μ„œ 초점이 μ΄λ™λ˜μ–΄μ•Ό ν•œλ‹€.

const INTERACTIVE_ELEMENTS =
  "a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled])";

function Dialog() {
  ...
  
  useEffect(() => {
    if (!showDialog) {
      return;
    }

    const dialogContent = contentRef.current;

    /* 열릴 λ•Œ ν™œμ„±ν™”λœ μš”μ†Œ μ €μž₯ */
    const prevFocusRef = document.activeElement;

    /* 열릴 λ•Œ λ‚΄λΆ€λ‘œ 초점 이동 */
    dialogContent.focus();

    /* Dialog λ‚΄ 초점 κ°€λŠ₯ν•œ μš”μ†Œλ“€ */
    const focusableElements =
      dialogContent.querySelectorAll(INTERACTIVE_ELEMENTS);
    const firstFocusableElement = focusableElements[0];
    const lastFocusableElement =
      focusableElements[focusableElements.length - 1];

    /* λ‚΄λΆ€ 초점 μˆœν™˜ */
    const focusTrap = (event) => {
      const currentFocusElement = document.activeElement;
      const isFirstFocusableElementActive =
        currentFocusElement === firstFocusableElement;
      const isLastFocusableElementActive =
        currentFocusElement === lastFocusableElement;

      if (event.code === "Tab") {
        /* 첫번째 μš”μ†Œμ—μ„œ 'Shift + Tab'ν‚€ λ™μž‘ μ‹œ λ§ˆμ§€λ§‰ μš”μ†Œλ‘œ 초점 이동 */
        if (event.shiftKey && isFirstFocusableElementActive) {
          event.preventDefault();

          lastFocusableElement.focus();
        }

        /* λ§ˆμ§€λ§‰ μš”μ†Œμ—μ„œ 'Tab'ν‚€ λ™μž‘ μ‹œ 첫번째 μš”μ†Œλ‘œ 초점 이동 */
        if (isLastFocusableElementActive) {
          event.preventDefault();

          firstFocusableElement.focus();
        }
      }
    };
    
    dialogContent.addEventListener("keydown", focusTrap);

    return () => {
      /* λ‹«νž λ•Œ 초점 볡귀 */
      prevFocusRef.focus();

      dialogContent.removeEventListener("keydown", focusTrap);
    };
  }, [showDialog]);

  return (
    <>...</>
  );
}

λ‘œμ§μ„ μ‚΄νŽ΄λ³΄λ©΄ 초점이 κ°€λŠ₯ν•œ μš”μ†Œλ“€μ—μ„œ 첫번째/λ§ˆμ§€λ§‰ μš”μ†Œλ₯Ό μ°Ύμ•„ λ‚΄λΆ€μ—μ„œλ§Œ tabν‚€λ‘œ focus λ˜λ„λ‘ κ°•μ œν•˜κ³  μžˆλ‹€. 그리고 λ‹«νžˆλ©΄ 이전에 focusλ˜μ–΄ 있던 μš”μ†Œλ‘œ λ‹€μ‹œ focus μ‹œν‚€κ²Œ λœλ‹€.

이외에도 μΆ”κ°€μ μœΌλ‘œ λ‹€μ΄μ–Όλ‘œκ·Έκ°€ 열릴 λ•Œ μ™ΈλΆ€ μš”μ†Œλ“€μ—λŠ” aria-hidden="true"λ₯Ό μ μš©ν•΄μ•Ό 슀크린 리더가 λ‹€μ΄μ–Όλ‘œκ·Έλ₯Ό μ œλŒ€λ‘œ 좔적할 수 μžˆλ‹€.

const INTERACTIVE_ELEMENTS =
  "a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled])";

function Dialog() {
  ...
  
  useEffect(() => {
    if (!showDialog) {
      return;
    }
    
    // ...
    /* Dialog ν˜•μ œ μš”μ†Œλ“€ */
    
    const siblingNodes = dialogRef.current.parentNode.childNodes;

    Array.from(siblingNodes).forEach((child) => {
      if (child !== dialogRef.current) {
        child.setAttribute("aria-hidden", "true");
      }
    });
    
    //...
    
    dialogContent.addEventListener("keydown", focusTrap);

    return () => {
      /* λ‹«νž λ•Œ 초점 볡귀 */
      prevFocusRef.focus();

      dialogContent.removeEventListener("keydown", focusTrap);
    };
  }, [showDialog]);

  return (
    <>...</>
  );
}

λͺ¨λ“  ν˜•μ œλ“€μ„ μ°Ύμ•„μ„œ aria-hidden="true" 속성을 λΆ€μ—¬ν•˜λŠ” μ½”λ“œκ°€ μΆ”κ°€λ˜μ—ˆλ‹€.

λͺ¨λ“  μ½”λ“œλŠ” μ—¬κΈ°μ„œ 확인이 κ°€λŠ₯ν•˜λ‹€!
μŠ€ν† λ¦¬λΆλ„ μ œκ³΅ν•˜λŠ”λ°, μ—¬κΈ°μ„œ 확인이 κ°€λŠ₯ν•˜λ‹€.

Shadcn/ui > Dialog μ»΄ν¬λ„ŒνŠΈ μ‚΄νŽ΄λ³΄κΈ°

λ””μžμΈ λΌμ΄λΈŒλŸ¬λ¦¬λ“€λ„ 접근성을 μ—΄μ‹¬νžˆ μ§€ν‚€κ³  μžˆλ‚˜ ν•œλ²ˆ μ‚΄νŽ΄λ³΄μž

Shadcn/ui
shadcn/uiλŠ” Radix UIλ₯Ό 기반으둜 λ§Œλ“€μ–΄μ‘Œκ³ , λ””μžμΈκ³Ό μŠ€νƒ€μΌμ„ μΆ”κ°€ν•œ μ»΄ν¬λ„ŒνŠΈ λͺ¨μŒμ΄λ‹€. (Radix UIλŠ” μ»΄ν¬λ„ŒνŠΈμ˜ κΈ°λŠ₯λ§Œμ„ λ‹΄μ•„ μ œκ³΅ν•˜κ³ , λ””μžμΈμ μΈ μš”μ†ŒλŠ” λͺ¨λ‘ λΉ μ§„ ν—€λ“œλ¦¬μ‹œ UI μ»΄ν¬λ„ŒνŠΈ λΌμ΄λΈŒλŸ¬λ¦¬μ΄λ‹€.)

λ‹€μ΄μ–Όλ‘œκ·Έ μ»΄ν¬λ„ŒνŠΈ λ¬Έμ„œ:
https://ui.shadcn.com/docs/components/dialog

μ‹€μ œλ‘œ shadcn-ui μ½”λ“œμ—λŠ” radix-uiλ₯Ό μž„ν¬νŠΈν•΄ λ ˆν•‘ν•˜κ³  μžˆλ‹€.
=> https://github.com/shadcn-ui/ui/blob/main/apps/www/registry/default/ui/dialog.tsx

μœ„μ—μ„œ 직접 κ΅¬ν˜„ν•œ μ»΄ν¬λ„ŒνŠΈλ₯Ό λ””μžμΈ λΌμ΄λΈŒλŸ¬λ¦¬μ—μ„œλŠ” μΆ”μƒν™”ν•˜μ—¬ ν’€μ–΄λ‚˜κ°„ 것듀이 λͺ‡κ°œ μžˆλ‹€.

λ‹€μ΄μ–Όλ‘œκ·Έ 초점 이동 μ½”λ“œ λœ―μ–΄λ³΄κΈ°

μœ„μ—μ„œλŠ” λ‚΄λΆ€ 초점 이동을 반볡 μ‹œν‚€κΈ° μœ„ν•΄ focus λ˜λŠ” μš”μ†Œλ“€μ„ μ°Ύμ•„ loop μ‹œμΌ°μ—ˆλ‹€.
κ·ΈλŸ¬λ‚˜ μ—¬κΈ°μ„œλŠ” FocusScopeλΌλŠ” μΆ”μƒν™”λœ μ»΄ν¬λ„ŒνŠΈλ₯Ό μ‚¬μš©ν•˜μ—¬ μ„ μ–Έμ μœΌλ‘œ μ‚¬μš©μ€‘μ΄λ‹€.

// https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/dialog.tsx#L384C1-L424C3

const DialogContentImpl = React.forwardRef<DialogContentImplElement, DialogContentImplProps>(
  (props: ScopedProps<DialogContentImplProps>, forwardedRef) => {
    const { __scopeDialog, trapFocus, onOpenAutoFocus, onCloseAutoFocus, ...contentProps } = props;
    const context = useDialogContext(CONTENT_NAME, __scopeDialog);
    const contentRef = React.useRef<HTMLDivElement>(null);
    const composedRefs = useComposedRefs(forwardedRef, contentRef);

    // Make sure the whole tree has focus guards as our `Dialog` will be
    // the last element in the DOM (because of the `Portal`)
    useFocusGuards();

    return (
      <>
        <FocusScope
          asChild
          loop
          trapped={trapFocus}
          onMountAutoFocus={onOpenAutoFocus}
          onUnmountAutoFocus={onCloseAutoFocus}
        >
          <DismissableLayer
            role="dialog"
            id={context.contentId}
            aria-describedby={context.descriptionId}
            aria-labelledby={context.titleId}
            data-state={getState(context.open)}
            {...contentProps}
            ref={composedRefs}
            onDismiss={() => context.onOpenChange(false)}
          />
        </FocusScope>
        {process.env.NODE_ENV !== 'production' && (
          <>
            <TitleWarning titleId={context.titleId} />
            <DescriptionWarning contentRef={contentRef} descriptionId={context.descriptionId} />
          </>
        )}
      </>
    );
  }
);
const DialogContentImpl = React.forwardRef<DialogContentImplElement, DialogContentImplProps>(
  (props: ScopedProps<DialogContentImplProps>, forwardedRef) => {
    const { __scopeDialog, trapFocus, onOpenAutoFocus, onCloseAutoFocus, ...contentProps } = props;
    const context = useDialogContext(CONTENT_NAME, __scopeDialog);
    const contentRef = React.useRef<HTMLDivElement>(null);
    const composedRefs = useComposedRefs(forwardedRef, contentRef);

    // Make sure the whole tree has focus guards as our `Dialog` will be
    // the last element in the DOM (because of the `Portal`)
    useFocusGuards();

    return (
      <>
        <FocusScope
          asChild
          loop
          trapped={trapFocus}
          onMountAutoFocus={onOpenAutoFocus}
          onUnmountAutoFocus={onCloseAutoFocus}
        >
          <DismissableLayer
            role="dialog"
            id={context.contentId}
            aria-describedby={context.descriptionId}
            aria-labelledby={context.titleId}
            data-state={getState(context.open)}
            {...contentProps}
            ref={composedRefs}
            onDismiss={() => context.onOpenChange(false)}
          />
        </FocusScope>
      </>
    );
  }
);

그럼 FocusScope의 μ½”λ“œλŠ” μ–΄λ–»κ²Œ λ˜μ–΄ μžˆμ„κΉŒ?

// https://github.com/radix-ui/primitives/blob/main/packages/react/focus-scope/src/focus-scope.tsx

interface FocusScopeProps extends PrimitiveDivProps {
  loop?: boolean;                 // Tab μˆœν™˜ μ—¬λΆ€
  trapped?: boolean;              // μŠ€μ½”ν”„ μ™ΈλΆ€λ‘œ ν¬μ»€μŠ€κ°€ λ‚˜κ°€μ§€ μ•Šλ„λ‘ κ°•μ œ
  onMountAutoFocus?: (event: Event) => void; // 포컀싱 μ΄λ²€νŠΈμ— λŒ€ν•œ ν•Έλ“€λŸ¬
  onUnmountAutoFocus?: (event: Event) => void;
}

μ»΄ν¬λ„ŒνŠΈκ°€ 마운트/μ–Έλ§ˆμš΄νŠΈλ  λ•Œ μ²˜λ¦¬ν•˜λŠ” 포컀싱 이벀트 μ²˜λ¦¬λ„ μ§„ν–‰ν•œλ‹€.

// μžλ™ 포컀싱 둜직
// https://github.com/radix-ui/primitives/blob/main/packages/react/focus-scope/src/focus-scope.tsx#L132-L170

React.useEffect(() => {
    if (container) {
      focusScopesStack.add(focusScope);
      const previouslyFocusedElement = document.activeElement as HTMLElement | null;
      // ν¬μ»€μŠ€κ°€ κ°€λŠ₯ν•œ 후보듀
      const hasFocusedCandidate = container.contains(previouslyFocusedElement);

      // ν¬μ»€μŠ€κ°€ κ°€λŠ₯ν•˜λ‹€λ©΄ μ»€μŠ€ν…€ 이벀트둜 마운트 λ λ•Œ 포컀슀 μ‹œν‚΄
      if (!hasFocusedCandidate) {
        const mountEvent = new CustomEvent(AUTOFOCUS_ON_MOUNT, EVENT_OPTIONS);
        container.addEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
        container.dispatchEvent(mountEvent);
        if (!mountEvent.defaultPrevented) {
          focusFirst(removeLinks(getTabbableCandidates(container)), { select: true });
          if (document.activeElement === previouslyFocusedElement) {
            focus(container);
          }
        }
      }

      return () => {
        // 이벀트 제거 둜직
        container.removeEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
        // We hit a react bug (fixed in v17) with focusing in unmount.
        // We need to delay the focus a little to get around it for now.
        // See: https://github.com/facebook/react/issues/17894
        setTimeout(() => {
          const unmountEvent = new CustomEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS);
          container.addEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
          container.dispatchEvent(unmountEvent);
          if (!unmountEvent.defaultPrevented) {
            focus(previouslyFocusedElement ?? document.body, { select: true });
          }
          // we need to remove the listener after we `dispatchEvent`
          container.removeEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);

          focusScopesStack.remove(focusScope);
        }, 0);
      };
    }
  }, [container, onMountAutoFocus, onUnmountAutoFocus, focusScope]);

그리고 Tabν‚€λ‘œ λ‹€μ΄μ–Όλ‘œκ·Έ λ‚΄μ—μ„œ 이동할 λ•Œμ—λ„ focusκ°€ λ²—μ–΄λ‚˜μ§€ μ•Šκ²Œ μ²˜λ¦¬ν•˜λŠ” 것도 ν¬ν•¨λ˜μ–΄ μžˆλ‹€.

// https://github.com/radix-ui/primitives/blob/main/packages/react/focus-scope/src/focus-scope.tsx#L173C9-L201

  const handleKeyDown = React.useCallback(
    (event: React.KeyboardEvent) => {
      if (!loop && !trapped) return;
      if (focusScope.paused) return;

      // νƒ­ν‚€λ§Œ λˆŒλ €μ„ 경우
      const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey;
      // ν™œμ„±ν™”λœ μš”μ†Œ
      const focusedElement = document.activeElement as HTMLElement | null;

      if (isTabKey && focusedElement) {
        const container = event.currentTarget as HTMLElement;
        const [first, last] = getTabbableEdges(container);
        const hasTabbableElementsInside = first && last;

        // shift + tab ν‚€λŠ” 이전 ν™œμ„±ν™” μš”μ†Œ, tab ν‚€λŠ” λ‹€μŒ ν™œμ„±ν™” μš”μ†Œλ‘œ λŒλ„λ‘ 처리
        // 단, loop μ˜΅μ…˜μ΄ μžˆμ„λ•ŒλŠ” loop둜 focusλ˜λ„λ‘ 처리
        // we can only wrap focus if we have tabbable edges
        if (!hasTabbableElementsInside) {
          if (focusedElement === container) event.preventDefault();
        } else {
          if (!event.shiftKey && focusedElement === last) {
            event.preventDefault();
            if (loop) focus(first, { select: true });
          } else if (event.shiftKey && focusedElement === first) {
            event.preventDefault();
            if (loop) focus(last, { select: true });
          }
        }
      }
    },
    [loop, trapped, focusScope.paused]
  );

이외에도 focus된 μš”μ†Œμ™€ κ΄€λ ¨λœ λ‹€μ–‘ν•œ 헬퍼 ν•¨μˆ˜λ“€λ„ 같이 μ •μ˜ λ˜μ–΄ μžˆμœΌλ‹ˆ μ½”λ“œ μ‚΄νŽ΄λ³΄λŠ” 것도 μΆ”μ²œν•œλ‹€.

λ‹€μ΄μ–Όλ‘œκ·Έ μ™ΈλΆ€ μš”μ†Œ hidden 처리 μ½”λ“œ λœ―μ–΄λ³΄κΈ°

슀크린 리더가 λ‹€μ΄μ–Όλ‘œκ·Έ λ‚΄λΆ€λ§Œ 확인이 κ°€λŠ₯ν•˜λ„λ‘ aria-hidden="true" 처리λ₯Ό ν•΄μ•Ό ν•œλ‹€κ³  ν–ˆλ‹€.

λΌμ΄λΈŒλŸ¬λ¦¬μ—μ„œλŠ” 'aria-hidden 라이브러리'λ₯Ό 톡해 λ‚΄λΆ€ μš”μ†Œλ₯Ό μ œμ™Έν•˜κ³  λͺ¨λ‘ hidden 처리λ₯Ό ν•˜κ³  μžˆλ‹€.

// https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/dialog.tsx#L265-L269

    // aria-hide everything except the content (better supported equivalent to setting aria-modal)
    React.useEffect(() => {
      const content = contentRef.current;
      if (content) return hideOthers(content);
    }, []);

λ‹€μ΄μ–Όλ‘œκ·Έ λ‚΄λΆ€ μš”μ†Œλ“€ μ ‘κ·Όμ„± 속성 μ‚΄νŽ΄λ³΄κΈ°

직접 κ΅¬ν˜„ν–ˆμ„ λ•Œμ—λŠ” aria-describedby, aria-haspopupλ“± μ—¬λŸ¬κ°€μ§€ 속성듀을 μ‚¬μš©ν–ˆλŠ”λ° 내뢀에도 μžˆμ„μ§€ μ‚΄νŽ΄λ³΄μž

μ½”λ“œμ—μ„œ 검색해보면 총 12κ°œκ°€ λœ¬λ‹€.

// https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/dialog.tsx#L106-L108

const DialogTrigger = React.forwardRef<DialogTriggerElement, DialogTriggerProps>(
  (props: ScopedProps<DialogTriggerProps>, forwardedRef) => {
    // ...
    return (
      <Primitive.button
        type="button"
        aria-haspopup="dialog"
        aria-expanded={context.open}
        aria-controls={context.contentId}
        data-state={getState(context.open)}
        {...triggerProps}
        ref={composedTriggerRef}
        onClick={composeEventHandlers(props.onClick, context.onOpenToggle)}
      />
    );
  }
);

λΉ„μŠ·ν•˜κ²Œ haspopup속성도 λΆ€μ—¬ν•˜μ˜€κ³ , 이외에도 expanded, controls 속성도 μ‚¬μš© μ€‘μΈκ²Œ 보인닀.

개발자 λ„κ΅¬μ—μ„œ μ ‘κ·Όμ„± 트리λ₯Ό 확인해보면, μœ„μ— μ½”λ“œμ²˜λŸΌ λΆ€μ—¬λœ 것을 확인할 수 μžˆλ‹€.

μ—¬κΈ°μ„œ aria-controlsλŠ” λ‹€μ΄μ–Όλ‘œκ·Έκ°€ μ—΄λ¦° 컨텐츠에 λΆ€μ—¬λ˜μ–΄ μžˆλ‹€.

λ‹Ήμ—°ν•˜κ²Œλ„ describedby, labelledby 도 μ½”λ“œμ—μ„œ μ œκ³΅μ€‘μ΄λ‹€.

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/dialog.tsx#L407-L409

const DialogContentImpl = React.forwardRef<DialogContentImplElement, DialogContentImplProps>(
  (props: ScopedProps<DialogContentImplProps>, forwardedRef) => {
    //...

    return (
      <>
        <FocusScope
          asChild
          loop
          trapped={trapFocus}
          onMountAutoFocus={onOpenAutoFocus}
          onUnmountAutoFocus={onCloseAutoFocus}
        >
          <DismissableLayer
            role="dialog"
            id={context.contentId}
            aria-describedby={context.descriptionId}
            aria-labelledby={context.titleId}
            data-state={getState(context.open)}
            {...contentProps}
            ref={composedRefs}
            onDismiss={() => context.onOpenChange(false)}
          />
        </FocusScope>
      </>
    );
  }
);

각 μš”μ†Œλ“€μ˜ id 값듀은 dialog의 title, subtitle에 각각 맀핑이 λ˜μ–΄ μžˆλ‹€.

πŸ’‘ μ ‘κ·Όμ„±μ˜ μ€‘μš”ν•œ μ‚¬μ†Œν•œ νŒλ“€

πŸ₯Ή λ²„νŠΌμ΄ λ²„νŠΌμ΄ μ•„λ‹ˆμ•Ό

div, a, button νƒœκ·Έλ“€μ€ λͺ¨λ‘ λ²„νŠΌμœΌλ‘œ μ‚¬μš© κ°€λŠ₯ν•œ νƒœκ·Έλ“€μ΄λ‹€. 접근성을 λ°°μ› μœΌλ‹ˆ 각 νƒœκ·Έλ“€μ„ λ²„νŠΌμœΌλ‘œ μ‚¬μš©ν•˜λŠ” μ½”λ“œλ₯Ό μ‚΄νŽ΄λ³΄μž

// 1. div νƒœκ·Έ μ‚¬μš©
<div role="button" style={{ cursor: pointer }}>λ²„νŠΌ</div>
// 2. a νƒœκ·Έ μ‚¬μš©
<a href="#" role="button">λ²„νŠΌ</a>
// 3. λ²„νŠΌ νƒœκ·Έ μ‚¬μš©
<button type="button">λ²„νŠΌ</button>

μœ„ 3κ°€μ§€λŠ” λͺ¨λ‘ μ ‘κ·Όμ„± νŠΈλ¦¬μ—μ„œλŠ” λ²„νŠΌμœΌλ‘œ μΈμ‹λœλ‹€. κ·ΈλŸ¬λ‚˜ 본질적인 κΈ°λŠ₯μ—λŠ” μ•„λ¬΄λŸ° 영ν–₯을 λ―ΈμΉ˜μ§€ μ•ŠλŠ”λ‹€. 우츑 클릭을 ν–ˆμ„ λ•Œ 각기 λ‹€λ₯Έ κΈ°λŠ₯이 λ‚˜νƒ€λ‚œλ‹€.

div νƒœκ·Έ a νƒœκ·Έ button νƒœκ·Έ

링크둜 λ²„νŠΌμ„ λ§Œλ“€ 경우 λ²„νŠΌμ˜ 역할이 μ•„λ‹Œ 링크의 κΈ°λŠ₯이 λ…ΈμΆœλœλ‹€. 또 λ²„νŠΌμ€ Space/Enter ν‚€λ‘œ λ™μž‘μ΄ λ˜λŠ”λ°, λ§ν¬λŠ” Spaceλ‘œλŠ” λ™μž‘ν•˜μ§€ μ•ŠλŠ”λ‹€.

πŸ₯Ή λͺ©λ‘μ΄ λͺ©λ‘μ΄ μ•„λ‹ˆμ•Ό

ulκ³Ό ol νƒœκ·Έλ‘œ λͺ©λ‘ μ»΄ν¬λ„ŒνŠΈλ₯Ό κ΅¬ν˜„ν• λ•Œ λ””μžμΈμ—λŠ” 기본적인 list-style을 μ‚¬μš©ν•˜μ§€ μ•ŠλŠ”κ²Œ λŒ€λΆ€λΆ„μ΄λ‹€. κ·Έλž˜μ„œ reset/nomalize cssλ₯Ό μ‚¬μš©ν•˜μ—¬ μŠ€νƒ€μΌμ„ μ΄ˆκΈ°ν™”ν•˜μ—¬ λ””μžμΈμ„ ν•˜λŠ”κ²Œ λŒ€λΆ€λΆ„μ΄λ‹€.

list-style-type: none

κ·ΈλŸ¬λ‚˜ μ΄λ ‡κ²Œ μŠ€νƒ€μΌμ„ μ§€μ •ν•˜κ²Œ 되면, ν•΄λ‹Ή μš”μ†Œκ°€ 더 이상 'λͺ©λ‘'으둜 μΈμ‹λ˜μ§€ μ•ŠλŠ” λ¬Έμ œκ°€ λ°œμƒν•œλ‹€.

이λ₯Ό ν•΄κ²°ν•˜κΈ° μœ„ν•œ μ—¬λŸ¬κ°€μ§€ 방법듀이 μžˆλ‹€.

role 속성 λΆ€μ—¬ν•˜κΈ°

<ul role="list">
  <li role="listitem">ν•­λͺ©1</li>
</ul>

css marker or before κ°€μƒμš”μ†Œ μ‚¬μš©

  • marker: none 속성을 μ‚¬μš©ν•˜μ§€ μ•Šκ³  font-sizeλ₯Ό 쀄여 글머리 기호λ₯Ό μˆ¨κΈ°λŠ” 방법
  • before: content 속성에 빈 값이 μ•„λ‹Œ 것을 ν• λ‹Ήν•˜μ—¬ 글머리 기호둜 μΈμ‹ν•˜λ„λ‘ ν•œλ‹€.
<ul>
  <li>ν•­λͺ©1</li>
</ul>
// marker μ‚¬μš©
li:marker {
	font-size: 0;
}

// before μ‚¬μš©
ul {
	list-style-type: none;
}
li:before {
	content: "\200B"
}

πŸ₯Ή 이λͺ¨ν‹°μ½˜μ΄ 이λͺ¨ν‹°μ½˜μ΄ μ•„λ‹ˆμ•Ό

μ‹œκ°μ μœΌλ‘œ λ³΄λŠ” 이λͺ¨ν‹°μ½˜κ³Ό 슀크린 리더가 μ½λŠ” 이λͺ¨ν‹°μ½˜μ— λ”°λΌμ„œ 해석이 λ‹¬λΌμ§ˆ 수 μžˆλ‹€.

❌ λ‚˜μœ μ˜ˆμ‹œ
<!-- 슀크린 리더: μ•Œλ¦Ό 있음, μ’… λͺ¨μ–‘ -->
<span aria-live="polite">
  μ•Œλ¦Ό 있음 πŸ””
</span>

βœ… 쒋은 μ˜ˆμ‹œ
<!-- 슀크린 리더: μ•Œλ¦Όμ΄ λ„μ°©ν–ˆμŠ΅λ‹ˆλ‹€ -->
<span role="img" aria-label="μ•Œλ¦Όμ΄ λ„μ°©ν–ˆμŠ΅λ‹ˆλ‹€">
  πŸ””
</span>

μœ„ μ˜ˆμ‹œμ—μ„œ μ’… λͺ¨μ–‘을 읽게 λ˜λ©΄μ„œ μ΄ν•΄ν•˜λŠ”λ° λ°©ν•΄λ₯Ό ν•  수 μžˆλ‹€. 의미 μ—†λŠ” 이λͺ¨ν‹°μ½˜μ΄λΌλ©΄ aria-label을 톡해 μ„€λͺ…을 μ κ±°λ‚˜, aria-hidden을 μ‚¬μš©ν•˜λŠ” 것도 ν•œκ°€μ§€ 방법이닀.

마무리

νλ¦Ών–ˆλ˜ μ›Ή μ ‘κ·Όμ„± 지식이 μ •λ¦¬λ˜λ©΄μ„œ, μ΄μ œλŠ” aria-x 속성을 'κ·Έλƒ₯' μ‚¬μš©ν•˜λŠ” 것이 μ•„λ‹ˆλΌ "슀크린 λ¦¬λ”λŠ” μ–΄λ–»κ²Œ μ½μ„κΉŒ?"λΌλŠ” μ§ˆλ¬Έμ„ μžŠμ§€ μ•Šκ³  κ°œλ°œν•΄μ•Όκ² λ‹€.

μ ‘κ·Όμ„± 곡뢀에 λ„μ›€λ˜λŠ” κΈ€λ“€

profile
I am a front-end developer with 4 years of experience who believes that there is nothing I cannot do.

0개의 λŒ“κΈ€