Skip to Content
AccessibilityHTML 웹표준 + ARIA 사용 기준

HTML 웹표준 + ARIA 사용 기준

원칙: “HTML 먼저, ARIA는 보완” (First Rule of ARIA Use)
참고: MDN Web Docs, WHATWG HTML Standard, WAI-ARIA 1.2
적용 범위: @vortex/ui-foundation, @vortex/ui-icignal, @vortex/ui-cals 컴포넌트


1. 시맨틱 HTML 우선 원칙

1.1 규칙

ARIA role을 추가하기 전에 동일한 의미를 가진 HTML 네이티브 요소로 대체 가능한지 먼저 확인한다.

목적잘못된 패턴올바른 패턴
버튼<div role="button" onClick><button type="button">
링크<div role="link" onClick><a href="...">
현재 페이지 표시<span role="link" aria-disabled><span aria-current="page">
입력 그룹<div role="group"> (label 없음)<fieldset><legend>...</legend></fieldset>
체크박스<div role="checkbox"><input type="checkbox">
<div role="table"><table>
탐색 영역<div role="navigation"><nav>
주 콘텐츠<div role="main"><main>
머리글<div role="heading" aria-level="2"><h2>

1.2 div 대체 가능 시맨틱 태그

구조/레이아웃: <header> 페이지/섹션 헤더 <main> 주 콘텐츠 (페이지당 1개) <nav> 내비게이션 영역 <footer> 페이지/섹션 푸터 <aside> 부가 콘텐츠 (사이드바) <section> 주제별 섹션 (heading 포함 시) <article> 독립 콘텐츠 단위 <figure> 이미지/차트 + <figcaption> 인터랙티브: <button> 클릭 가능한 모든 인터랙티브 요소 <a href> 다른 URL로 이동 <details> 펼침/접힘 (Accordion 대안) <summary> <details>의 제목 <dialog> 모달/팝업 (dialog element) 폼: <form> 폼 컨테이너 <fieldset> 관련 폼 그룹 <legend> <fieldset> 제목 <label> 입력 레이블 <input> 텍스트/체크박스/라디오 등 <select> 선택 목록 <textarea> 여러 줄 텍스트 <datalist> 자동완성 후보 목록 <output> 계산 결과 출력 텍스트: <h1>~<h6> 제목 계층 (페이지당 h1 1개 권장) <p> 단락 <strong> 강한 강조 (의미론적) <em> 약한 강조 (의미론적) <time> 날짜/시간 <abbr> 약어 + title <mark> 하이라이트 <code> 인라인 코드 <pre> 서식 있는 텍스트 <kbd> 키보드 입력 <samp> 샘플 출력 목록: <ul>/<li> 순서 없는 목록 <ol>/<li> 순서 있는 목록 <dl>/<dt>/<dd> 설명 목록 (용어-정의) 표: <table> 데이터 표 <caption> 표 제목 <thead>/<tbody>/<tfoot> 표 영역 구분 <th scope> 헤더 셀 <td> 데이터 셀

2. ARIA 사용 기준

2.1 ARIA를 사용해야 하는 경우

  • 네이티브 HTML 요소로 표현할 수 없는 커스텀 위젯 (combobox, tree, grid, spinbutton 등)
  • 동적으로 업데이트되는 영역의 live region 선언 (aria-live, role="status", role="alert")
  • 네이티브 요소의 기본 role을 명확히 재정의해야 할 때
  • 시각적으로만 연결된 레이블과 컨트롤의 프로그래밍 방식 연결 (aria-labelledby, aria-describedby)

2.2 ARIA를 사용하지 말아야 하는 경우

  • 네이티브 HTML 요소로 대체 가능한 경우 (위 표 참조)

  • 이미 올바른 의미론적 요소를 사용하고 있는 경우에 중복 role 선언

    // 잘못됨 — nav에 role="navigation"은 중복 <nav role="navigation">...</nav> // 올바름 <nav>...</nav>
  • <button>role="button" 중복

  • <a href>role="link" 중복

  • <h2>role="heading" aria-level="2" 중복

2.3 주요 ARIA 속성 사용 기준

aria-label vs aria-labelledby

// aria-label: 레이블 텍스트를 직접 제공 <button aria-label="검색"> <SearchIcon aria-hidden="true" /> </button> // aria-labelledby: 다른 요소의 텍스트를 레이블로 참조 <section aria-labelledby="section-title"> <h2 id="section-title">공지사항</h2> ... </section> // 원칙: 보이는 텍스트가 있으면 aria-labelledby 우선, // 보이는 텍스트가 없으면 aria-label 사용

aria-describedby

// 추가 설명 연결 (레이블 외 보충 정보) <input id="email" aria-describedby="email-hint email-error" /> <p id="email-hint">예: user@example.com</p> <div id="email-error" role="alert"> 유효한 이메일 형식이 아닙니다. </div>

aria-hidden

// 사용해야 하는 경우: // 1. 장식 아이콘 (의미 없는 SVG) <SearchIcon aria-hidden="true" /> // 2. 측정용 숨김 DOM <div aria-hidden="true" style={{ visibility: 'hidden' }}>...</div> // 3. 포커스 불가 장식 요소 // 절대 사용하면 안 되는 경우: // - 포커서블 요소(button, a, input)를 포함하는 컨테이너 // - sr-only 텍스트를 포함하는 컨테이너 // - aria-hidden 요소에 tabIndex가 있는 경우

aria-live regions

// 동적 콘텐츠 업데이트 선언 // 즉시 읽어야 하는 중요 알림 (에러, 경고) <div role="alert">에러 메시지</div> // assertive // 또는 <div aria-live="assertive" aria-atomic="true">...</div> // 비방해적 업데이트 (상태, 진행 상황) <div role="status">저장 완료</div> // polite // 또는 <div aria-live="polite">...</div> // 주의: 페이지 로드 시 이미 존재하는 요소에 role="alert"를 선언해도 // AT가 읽지 않음. 동적으로 DOM에 추가될 때만 효과 있음.

3. 컴포넌트별 ARIA 패턴

3.1 버튼

// 텍스트 버튼 — ARIA 불필요 <button type="button">저장</button> // 아이콘 전용 버튼 — aria-label 필수 <button type="button" aria-label="닫기"> <XIcon aria-hidden="true" /> </button> // 로딩 상태 <button type="button" aria-busy="true" disabled> <Spinner aria-hidden="true" /> <span>처리 중...</span> </button> // 토글 버튼 <button type="button" aria-pressed={isActive}> {isActive ? '활성' : '비활성'} </button>

3.2 폼 필드

// 기본 연결 <label htmlFor="name">이름</label> <input id="name" type="text" /> // 필수 입력 <label htmlFor="email"> 이메일 <span aria-hidden="true"> *</span> {/* 시각적 표시 */} </label> <input id="email" type="email" required aria-required="true" /> // 에러 상태 <input id="password" type="password" aria-invalid="true" aria-describedby="password-error" /> <div id="password-error" role="alert"> 비밀번호는 8자 이상이어야 합니다. </div> // 그룹 (fieldset/legend 우선) <fieldset> <legend>알림 수신 방법</legend> <label><input type="radio" name="notify" value="email" /> 이메일</label> <label><input type="radio" name="notify" value="sms" /> SMS</label> </fieldset> // div role="group" 사용 시 반드시 aria-labelledby <div role="group" aria-labelledby="notify-label"> <span id="notify-label">알림 수신 방법</span> ... </div>

3.3 모달/Dialog

// Base UI Dialog — 자동 처리: // - role="dialog" + aria-modal="true" // - focus trap (포커스 갇힘) // - Escape 키 닫기 // - 초기 포커스 설정 // 반드시 제공해야 하는 것: <DialogTitle>모달 제목</DialogTitle> {/* aria-labelledby 자동 연결 */} <DialogDescription>설명</DialogDescription> {/* aria-describedby 자동 연결 */} // 닫기 버튼 <button aria-label="대화상자 닫기"> <XIcon aria-hidden="true" /> <span className="sr-only">닫기</span> {/* aria-label이 있으면 이건 불필요 */} </button>

3.4 표 (Table)

// 기본 데이터 표 <table> <caption>월별 매출 현황</caption> {/* 또는 aria-label */} <thead> <tr> <th scope="col">월</th> <th scope="col">매출</th> <th scope="col">전월 대비</th> </tr> </thead> <tbody> <tr> <th scope="row">1월</th> {/* 행 헤더 */} <td>₩1,200만</td> <td>+5%</td> </tr> </tbody> </table> // 현재 ui-foundation TableHead 권장 패턴: <TableHead scope="col">월</TableHead> {/* scope prop 전달 */}
// ARIA carousel 패턴 <div role="region" aria-label="최신 공지사항" {/* aria-label 필수 */} aria-roledescription="carousel" > <div role="group" aria-roledescription="slide" aria-label="1 / 3"> ... </div> {/* 자동재생 정지 버튼 — WCAG 2.2.2 */} <button aria-label="자동재생 정지" onClick={pause}> <PauseIcon aria-hidden="true" /> </button> <button aria-label="이전 슬라이드" onClick={prev}> <ChevronLeftIcon aria-hidden="true" /> </button> <button aria-label="다음 슬라이드" onClick={next}> <ChevronRightIcon aria-hidden="true" /> </button> </div>

3.6 Backdrop / 외부 클릭 닫기 오버레이

팝업·모달 외부 클릭 감지용 전체 화면 오버레이는 인터랙티브 요소가 아니므로 AT 접근성 트리에서 숨긴다.

// 올바른 패턴 — aria-hidden으로 AT에서 숨김 <div className="fixed inset-0 z-[9998]" onClick={() => setPopup(null)} aria-hidden="true" // AT에서 완전 숨김 (필수) /> // 잘못된 패턴 — AT가 빈 div를 인식하려 시도 <div className="fixed inset-0" onClick={() => setPopup(null)} />

키보드 닫기 대안 필수 — backdrop은 마우스 전용이므로 반드시 하나 이상의 키보드 대안을 함께 제공해야 한다.

대안방법
닫기 버튼팝업 내부에 <button aria-label="닫기"> 배치
ESC 키팝업 컨테이너에 onKeyDown 또는 라이브러리 내장 처리
// 팝업 구조 예시 — backdrop + 닫기 버튼 함께 제공 <> {/* 외부 클릭 닫기 — 마우스 전용, AT 숨김 */} <div className="fixed inset-0 z-[9998]" onClick={() => setPopup(null)} aria-hidden="true" /> {/* 팝업 본체 */} <div className="absolute z-[9999] ..." role="dialog" aria-modal="true"> <button type="button" aria-label="닫기" onClick={() => setPopup(null)}> <XIcon aria-hidden="true" /> </button> {/* 팝업 콘텐츠 */} </div> </>

aria-hidden="true"가 있으면 A016(비인터랙티브 onClick) 규칙에서 자동 제외됨.

3.7 로딩/상태 표시

// Spinner — SVG에 직접 role 적용 대신 span 래퍼 사용 <span role="status" aria-label="로딩 중"> <Loader2Icon aria-hidden="true" className="animate-spin" /> </span> // Skeleton — AT에서 읽히지 않도록 숨김 <div aria-hidden="true" className="skeleton" /> // 또는 부모 컨테이너에서 처리 <div aria-busy={isLoading} aria-live="polite"> {isLoading ? ( <div aria-hidden="true">...</div> ) : ( <ActualContent /> )} </div>

4. 포커스 관리

4.1 포커스 스타일 기준

/* 금지 패턴 — outline 완전 제거 */ :focus { outline: none; } .element { outline: 0; } /* 권장 패턴 — focus-visible 사용 */ :focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } /* Tailwind CSS 4 패턴 (현재 프로젝트) */ /* focus-visible:ring-[3px] + focus-visible:border-ring → outline-none 대신 ring 방식으로 보상 → :focus-visible 미지원 환경을 위한 fallback 검토 필요 */

4.2 tabIndex 사용 기준

// tabIndex={0} — 포커스 순서에 추가 (키보드 접근 필요한 커스텀 요소) // tabIndex={-1} — 포커스는 받을 수 있지만 Tab 순서에서 제외 // (측정용 숨김 요소, 프로그래밍 방식 포커스 이동 대상) // tabIndex={양수} — 사용 금지 (DOM 순서 기반 포커스 순서 원칙 위반) // 올바른 사용 <div tabIndex={-1} ref={dialogRef}> {/* dialog 초기 포커스 target */} <div aria-hidden="true" tabIndex={-1}> {/* 숨김 컨테이너 — 단 포커서블 자식 없어야 함 */}

5. 안티패턴 목록

안티패턴문제올바른 패턴
<div onClick> (인터랙티브)키보드 접근 불가, role 없음<button> 또는 <a>
<div onClick> (backdrop 오버레이)AT가 빈 오버레이를 인식aria-hidden="true" 추가 + 키보드 닫기 대안 제공
<span role="link" aria-disabled>현재 페이지에 링크 role 오용<span aria-current="page">
<img> (alt 없음)스크린리더 파일명 읽음alt="설명" 또는 alt=""
role="region" (label 없음)landmark 무효aria-label="영역명" 추가
role="group" (label 없음)AT에서 group 명칭 알 수 없음aria-labelledby 또는 <fieldset>
aria-hidden 내부 sr-onlysr-only 텍스트 무효화aria-hidden을 아이콘에만 적용
aria-label + sr-only 동시 사용이중 레이블, 하나 무시됨하나만 사용
중첩 role="group" (3개 이상)AT 탐색 혼란불필요한 group 제거
SVG에 role="status"브라우저 AT 조합 불일치<span role="status"> 래퍼
outline: none 단독WCAG 2.4.7 위반focus-visible:ring 보상
tabIndex={1} 이상예측 불가 Tab 순서tabIndex={0} 또는 DOM 순서 정리
placeholder = 레이블입력 시 사라져 혼란<label> + placeholder 함께 사용
<table> 레이아웃 목적 사용표 데이터로 오인CSS flexbox/grid 사용

참고: WAI-ARIA 1.2 — https://www.w3.org/TR/wai-aria-1.2/ 
MDN ARIA — https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA 
ARIA in HTML — https://www.w3.org/TR/html-aria/ 

Last updated on