Stage 5: Professional Skills

Web Accessibility · Lesson 02

Semantics and ARIA

Roles, ARIA attributes, keyboard navigation and focus management.

Web Accessibility3 min readFree lesson

Browsers expose an accessibility tree to assistive technology. Semantic HTML fills that tree correctly for free. ARIA can patch gaps, but misuse makes things worse than no ARIA at all.

Learning objectives

  • Use landmarks, headings and native controls correctly.
  • Apply the five rules of ARIA.
  • Manage focus for dialogs and dynamic content.
  • Build a keyboard-accessible modal.

1. Native first

<!-- wrong: invisible to assistive tech, unusable by keyboard -->
<div class="btn" onclick="save()">Save</div>

<!-- right -->
<button type="button" onclick="save()">Save</button>

A <button> is focusable, activates with Enter and Space, has the button role and gets the right cursor and disabled behaviour. Reproducing that with a div requires tabindex, role, key handlers and still misses details.

Native elements to prefer:

Need Element
Action <button>
Navigation <a href>
Page structure <header> <nav> <main> <aside> <footer>
Section with a heading <section aria-labelledby="...">
Form control <input> <select> <textarea> with <label>
Toggle <input type="checkbox"> or <button aria-pressed>
Dialog <dialog> or role="dialog" + focus management

2. Headings and landmarks

<h1>Full Stack Development</h1>
  <h2>Curriculum</h2>
    <h3>Stage 1</h3>
    <h3>Stage 2</h3>
  <h2>Fees</h2>

One <h1> per page, no skipped levels. Screen-reader users navigate by headings; a page styled with <div class="h2"> has no outline at all.

Landmarks: one <main>, a <nav> per navigation block (label them when there are several), <header> and <footer> at the page level.

<nav aria-label="Primary">
<nav aria-label="Breadcrumb">

3. The five rules of ARIA

  1. Do not use ARIA if a native element exists.
  2. Do not change native semantics — <button role="heading"> is a lie.
  3. All interactive ARIA controls must be keyboard operable.
  4. Never hide a focusable element with aria-hidden="true"; it stays in the tab order but disappears from the tree.
  5. Every interactive element needs an accessible name.
<button type="button" aria-label="Close dialog">×</button>

The visible × is not a name a screen reader can use.

4. States and relationships

<button type="button" aria-expanded="false" aria-controls="menu">Menu</button>
<ul id="menu" hidden>...</ul>

<input aria-describedby="email-hint" />
<p id="email-hint">We never share your email.</p>

<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Confirm deletion</h2>
</div>
  • aria-expanded reflects open/closed and must be updated in JavaScript.
  • aria-controls references the element being controlled.
  • aria-describedby links hints and errors.
  • aria-labelledby names a region from visible text.
  • aria-live announces dynamic changes (see the forms lesson).

5. Focus management

Keyboard users need focus to go somewhere sensible.

On open a dialog: move focus to the dialog (or its first control). While open: trap focus inside (Tab cycles within). On close: return focus to the element that opened it. On route change: move focus to the <h1> or announce the new page.

const dialog = document.querySelector("[role=dialog]");
const focusable = dialog.querySelectorAll(
  'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])',
);

const first = focusable[0];
const last = focusable[focusable.length - 1];

dialog.addEventListener("keydown", (event) => {
  if (event.key !== "Tab") return;

  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  } else if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
});

6. tabindex

  • tabindex="0" — add to the natural tab order (use only for custom widgets).
  • tabindex="-1" — focusable by script, not by Tab (dialogs, headings after navigation).
  • tabindex="1+" — never. It breaks the natural order.

Common mistakes

  • role="button" without keyboard handlers.
  • aria-hidden="true" on a container that still has focusable children.
  • Redundant ARIA on native elements (<button role="button">).
  • A modal that does not trap focus, so Tab escapes to the page behind it.
  • Icon buttons with no label.

Practice

Build the accessible modal in the assignment. Focus trapping, Escape, labels and focus return are the core of almost every accessible widget.

Example files

Complete, runnable files used in this lesson. Copy them into your project and experiment.

accessible-nav.htmlHTML
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>접근성 네비게이션 예제</title>
    <style>
        /* ── 기본 스타일 ── */
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: 'Noto Sans KR', sans-serif; line-height: 1.6; }

        /* ── Skip Navigation Link
           평소에는 숨겨두고, 포커스 받으면 화면에 표시
           키보드 사용자가 반복 내비게이션을 건너뛸 수 있게 함 ── */
        .skip-link {
            position: absolute;
            top: -100%;
            left: 0;
            padding: 10px 20px;
            background: #0066cc;
            color: #fff;
            font-weight: bold;
            text-decoration: none;
            z-index: 9999;
            border-radius: 0 0 4px 0;
        }
        .skip-link:focus {
            top: 0; /* 포커스 시 화면에 표시 */
        }

        /* ── 헤더 & 네비게이션 ── */
        header {
            background: #1a1a2e;
            color: white;
            padding: 0 24px;
            display: flex;
            align-items: center;
            justify-content: space-between;
            height: 64px;
        }
        .logo { font-size: 1.4rem; font-weight: bold; color: white; text-decoration: none; }

        nav ul {
            list-style: none;
            display: flex;
            gap: 4px;
        }
        nav a {
            display: block;
            padding: 8px 16px;
            color: #ccc;
            text-decoration: none;
            border-radius: 4px;
            border: 2px solid transparent;
            transition: all 0.2s;
        }
        nav a:hover,
        nav a[aria-current="page"] {
            background: rgba(255,255,255,0.15);
            color: white;
        }

        /* ── 포커스 스타일: 절대 outline: none 하지 말 것! ──
           키보드 사용자가 현재 위치를 알 수 있어야 함 */
        :focus-visible {
            outline: 3px solid #ffdd57;
            outline-offset: 2px;
        }

        /* ── 드롭다운 메뉴 ── */
        .dropdown { position: relative; }
        .dropdown-menu {
            display: none;
            position: absolute;
            top: 100%;
            left: 0;
            background: white;
            color: #333;
            min-width: 200px;
            border-radius: 4px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.3);
            padding: 8px 0;
            z-index: 100;
        }
        /* aria-expanded="true"일 때만 표시 */
        .dropdown[aria-expanded="true"] .dropdown-menu {
            display: block;
        }
        .dropdown-menu a {
            color: #333;
            border-radius: 0;
        }
        .dropdown-menu a:hover { background: #f0f0f0; }

        /* ── 메인 콘텐츠 ── */
        main { max-width: 900px; margin: 40px auto; padding: 0 24px; }
        h1 { font-size: 2rem; margin-bottom: 16px; color: #1a1a2e; }
        p { color: #555; margin-bottom: 12px; }
    </style>
</head>
<body>

    <!-- ── Skip Navigation: 키보드 사용자가 내비게이션을 건너뛸 수 있음 ── -->
    <a href="#main-content" class="skip-link">본문 바로가기</a>

    <!-- ── 헤더: role="banner"가 암묵적으로 부여됨 ── -->
    <header>
        <!-- 로고: 텍스트로 사이트 이름을 표현 (이미지 로고라면 alt 필요) -->
        <a href="/" class="logo" aria-label="홈으로 이동">접근성 예제</a>

        <!-- 메인 내비게이션: <nav>에 aria-label로 구분
             (여러 nav가 있을 때 "메인 내비게이션", "푸터 내비게이션" 등으로 구분) -->
        <nav aria-label="메인 내비게이션">
            <ul role="list">
                <li>
                    <!-- aria-current="page": 현재 페이지임을 스크린리더에 알림 -->
                    <a href="/" aria-current="page">홈</a>
                </li>
                <li>
                    <a href="/about">소개</a>
                </li>

                <!-- ── 드롭다운 메뉴 ──
                     aria-haspopup="true": 하위 메뉴가 있음을 알림
                     aria-expanded: 열림/닫힘 상태
                     aria-controls: 제어하는 요소의 ID -->
                <li class="dropdown" id="services-dropdown">
                    <button
                        aria-haspopup="true"
                        aria-expanded="false"
                        aria-controls="services-menu"
                        onclick="toggleDropdown('services-dropdown')"
                        style="background:none;border:none;cursor:pointer;color:#ccc;padding:8px 16px;font-size:1rem;border-radius:4px;border:2px solid transparent;"
                    >
                        서비스 ▾
                    </button>
                    <ul id="services-menu" class="dropdown-menu" role="list">
                        <li><a href="/services/web">웹 개발</a></li>
                        <li><a href="/services/mobile">모바일 앱</a></li>
                        <li><a href="/services/design">UI 디자인</a></li>
                    </ul>
                </li>

                <li>
                    <a href="/contact">연락처</a>
                </li>
            </ul>
        </nav>
    </header>

    <!-- ── 메인 콘텐츠 ──
         id="main-content": skip link의 대상
         tabindex="-1": JS로 포커스 이동 가능 (Tab으로는 접근 안 함) -->
    <main id="main-content" tabindex="-1">
        <h1>접근성 네비게이션 예제</h1>
        <p>
            이 페이지는 키보드 내비게이션과 스크린리더를 완벽하게 지원하는
            네비게이션 구현 예제입니다.
        </p>
        <p>
            <strong>테스트 방법:</strong> Tab 키를 눌러 포커스를 이동해보세요.
            상단의 "본문 바로가기" 링크가 먼저 표시됩니다.
        </p>
        <p>
            드롭다운 메뉴는 Enter 또는 Space로 열고, Escape로 닫을 수 있습니다.
        </p>
    </main>

    <script>
        // ── 드롭다운 토글
        function toggleDropdown(id) {
            const dropdown = document.getElementById(id);
            const button = dropdown.querySelector('button');
            const isOpen = button.getAttribute('aria-expanded') === 'true';

            // 모든 드롭다운 닫기
            document.querySelectorAll('.dropdown').forEach(d => {
                d.querySelector('button').setAttribute('aria-expanded', 'false');
                d.removeAttribute('aria-expanded');
            });

            // 현재 드롭다운 토글
            if (!isOpen) {
                button.setAttribute('aria-expanded', 'true');
                dropdown.setAttribute('aria-expanded', 'true');

                // 드롭다운이 열리면 첫 번째 메뉴 아이템으로 포커스 이동
                const firstItem = dropdown.querySelector('.dropdown-menu a');
                if (firstItem) firstItem.focus();
            }
        }

        // ── 키보드 이벤트 처리
        document.addEventListener('keydown', (e) => {
            // Escape: 모든 드롭다운 닫기
            if (e.key === 'Escape') {
                document.querySelectorAll('.dropdown').forEach(d => {
                    const button = d.querySelector('button');
                    button.setAttribute('aria-expanded', 'false');
                    d.removeAttribute('aria-expanded');
                    // 드롭다운을 열었던 버튼으로 포커스 복귀
                    button.focus();
                });
            }
        });

        // ── 드롭다운 외부 클릭 시 닫기
        document.addEventListener('click', (e) => {
            if (!e.target.closest('.dropdown')) {
                document.querySelectorAll('.dropdown').forEach(d => {
                    d.querySelector('button').setAttribute('aria-expanded', 'false');
                    d.removeAttribute('aria-expanded');
                });
            }
        });
    </script>
</body>
</html>

Assignment

Assignment 02 — Accessible modal

Create modal.html with a native-feeling dialog built from scratch.

Markup

  • A Open dialog button.
  • A dialog with role="dialog", aria-modal="true" and aria-labelledby pointing at its heading.
  • An icon-only close button with aria-label="Close".
  • Two actions: Cancel and Confirm.
  • The page behind the dialog must contain enough content to test focus escaping.

Behaviour

  1. Opening the dialog moves focus to the heading (tabindex="-1") or the first control.
  2. Tab and Shift+Tab cycle only inside the dialog.
  3. Escape closes the dialog.
  4. Closing returns focus to the Open dialog button.
  5. Clicking the backdrop closes it; clicking inside does not.
  6. document.body does not scroll while the dialog is open.
  7. The rest of the page is aria-hidden="true" while the dialog is open (and restored on close).

Hints

function getFocusable(container) {
  return [...container.querySelectorAll(
    'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])',
  )].filter((el) => el.offsetParent !== null);
}
lastFocusedElement = document.activeElement;
dialog.hidden = false;
dialog.querySelector("h2").focus();
dialog.addEventListener("keydown", (event) => {
  if (event.key === "Escape") close();
});

Test

  • Navigate the entire flow with the keyboard only.
  • Run axe: no "dialog-name" or "aria-hidden-focus" violations.
  • Turn on a screen reader and confirm the dialog title is announced.

Done when

  • Focus never leaves the dialog while it is open.
  • Closing always returns focus to the trigger.
  • There is exactly one aria-modal dialog in the DOM.
  • The page behind is unreachable by keyboard and screen reader while open.
Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

modal.htmlHTML
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>접근성 모달 다이얼로그 — 과제 정답</title>
    <style>
        /* ── 기본 스타일 ── */
        body { font-family: 'Noto Sans KR', sans-serif; padding: 40px; background: #f5f5f5; }
        h1 { margin-bottom: 24px; }

        /* ── 포커스 스타일: outline 절대 제거 금지! ── */
        :focus-visible {
            outline: 3px solid #0066cc;
            outline-offset: 2px;
            border-radius: 2px;
        }

        button {
            padding: 10px 20px;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 1rem;
            font-weight: 600;
        }
        #open-modal {
            background: #0066cc;
            color: white;
        }
        #open-modal:hover { background: #0052a3; }

        /* ── 모달 오버레이
           모달이 닫혀 있을 때: display:none으로 완전히 숨김
           스크린리더도 접근 못하게 해야 함 (hidden 속성 사용) ── */
        .modal-overlay {
            position: fixed;
            inset: 0;
            background: rgba(0, 0, 0, 0.6);
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 1000;
        }
        /* hidden 속성으로 숨김 (display:none 적용됨) */
        .modal-overlay[hidden] { display: none; }

        /* ── 모달 박스 ── */
        .modal {
            background: white;
            border-radius: 12px;
            padding: 32px;
            max-width: 480px;
            width: 90%;
            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
            position: relative;
        }

        .modal h2 { margin-bottom: 12px; color: #1a1a2e; }
        .modal p  { color: #555; margin-bottom: 24px; }

        /* ── 모달 닫기 버튼 (X 버튼) ── */
        .modal-close {
            position: absolute;
            top: 16px;
            right: 16px;
            background: none;
            border: none;
            font-size: 1.5rem;
            cursor: pointer;
            color: #666;
            width: 40px;
            height: 40px;
            display: flex;
            align-items: center;
            justify-content: center;
            border-radius: 50%;
            padding: 0;
            font-weight: normal;
        }
        .modal-close:hover { background: #f0f0f0; }

        /* ── 모달 내 버튼 그룹 ── */
        .modal-buttons { display: flex; gap: 12px; justify-content: flex-end; }
        .btn-confirm { background: #0066cc; color: white; }
        .btn-confirm:hover { background: #0052a3; }
        .btn-cancel  { background: #eee; color: #333; }
        .btn-cancel:hover { background: #ddd; }

        /* ── 성공 메시지 ── */
        #success-msg {
            margin-top: 16px;
            padding: 12px;
            background: #e6f4ea;
            border: 1px solid #4caf50;
            border-radius: 6px;
            color: #1b5e20;
            display: none;
        }
    </style>
</head>
<body>

    <!-- ── 메인 콘텐츠 영역
         모달이 열리면 이 영역을 aria-hidden="true"로 설정하여
         스크린리더가 배경 콘텐츠를 읽지 않도록 함 ── -->
    <div id="main-content">
        <h1>접근성 모달 다이얼로그 예제</h1>
        <p>아래 버튼을 클릭하거나 Enter/Space로 모달을 열 수 있습니다.</p>
        <p>모달이 열리면 Tab, Shift+Tab으로 포커스가 모달 안에서만 순환합니다.</p>

        <!-- 모달을 여는 트리거 버튼
             id를 기억해두고, 모달 닫힌 후 포커스를 이곳으로 복귀 -->
        <button id="open-modal">주문 확인하기</button>

        <!-- 주문 완료 후 표시될 성공 메시지
             role="status": 스크린리더가 자동으로 읽음 -->
        <div id="success-msg" role="status">
            주문이 완료되었습니다! 감사합니다.
        </div>
    </div>

    <!-- ── 모달 오버레이
         hidden 속성: 닫혀있을 때 완전히 숨김 (display:none)
         모달이 열릴 때 hidden 속성을 제거하여 표시 ── -->
    <div
        class="modal-overlay"
        id="modal-overlay"
        hidden
        onclick="handleOverlayClick(event)"
    >
        <!-- ── 모달 다이얼로그
             role="dialog": 다이얼로그 역할을 스크린리더에 알림
             aria-modal="true": 이 영역 외부를 스크린리더가 무시하도록 함
             aria-labelledby: 제목 요소(h2)와 연결하여 스크린리더가 "주문 확인 다이얼로그"라고 읽음
             aria-describedby: 부가 설명 연결 ── -->
        <div
            class="modal"
            id="main-modal"
            role="dialog"
            aria-modal="true"
            aria-labelledby="modal-title"
            aria-describedby="modal-desc"
        >
            <!-- 닫기 버튼: aria-label로 역할 명시 (X 아이콘만 있어서 텍스트 없음) -->
            <button
                class="modal-close"
                id="modal-close-btn"
                aria-label="모달 닫기"
                onclick="closeModal()"
            >
                &#x2715; <!-- × 문자 -->
            </button>

            <!-- 모달 제목: aria-labelledby에서 참조되는 id -->
            <h2 id="modal-title">주문을 완료하시겠습니까?</h2>

            <!-- 모달 설명: aria-describedby에서 참조 -->
            <p id="modal-desc">
                선택하신 상품 내역을 확인하세요.<br>
                <strong>노트북</strong> 1개 / 총 금액: <strong>1,500,000원</strong>
            </p>

            <!-- 모달 내 버튼들
                 이 버튼들이 Focus Trap의 대상 ── -->
            <div class="modal-buttons">
                <button class="btn-cancel" id="cancel-btn" onclick="closeModal()">취소</button>
                <button class="btn-confirm" id="confirm-btn" onclick="confirmOrder()">주문 확인</button>
            </div>
        </div>
    </div>

    <script>
        // ── 모달을 연 트리거 버튼 참조 보관 (닫힌 후 포커스 복귀용)
        let triggerElement = null;

        // ── 포커스 가능한 요소 선택자 목록
        const FOCUSABLE_SELECTORS = [
            'a[href]',
            'button:not([disabled])',
            'input:not([disabled])',
            'select:not([disabled])',
            'textarea:not([disabled])',
            '[tabindex]:not([tabindex="-1"])',
        ].join(', ');

        // ── 모달 열기 함수
        function openModal(triggerBtn) {
            const overlay = document.getElementById('modal-overlay');
            const modal = document.getElementById('main-modal');
            const mainContent = document.getElementById('main-content');

            // 트리거 버튼 저장 (닫힌 후 포커스 복귀에 사용)
            triggerElement = triggerBtn;

            // 모달 표시 (hidden 속성 제거 → display:flex 적용)
            overlay.removeAttribute('hidden');

            // 배경 콘텐츠를 스크린리더에서 숨김
            // aria-hidden="true": 해당 요소와 자식 요소를 모두 숨김
            mainContent.setAttribute('aria-hidden', 'true');

            // 모달 내 첫 번째 포커스 가능 요소로 이동
            // 닫기(X) 버튼이 첫 번째 요소이므로 그곳으로 포커스
            const firstFocusable = modal.querySelectorAll(FOCUSABLE_SELECTORS)[0];
            if (firstFocusable) {
                // setTimeout: DOM 업데이트 후 포커스 (즉시 하면 hidden이 아직 적용 중일 수 있음)
                setTimeout(() => firstFocusable.focus(), 50);
            }

            // Focus Trap 이벤트 등록
            modal.addEventListener('keydown', trapFocus);

            // Escape 키 닫기 이벤트 등록
            document.addEventListener('keydown', handleEscape);
        }

        // ── 모달 닫기 함수
        function closeModal() {
            const overlay = document.getElementById('modal-overlay');
            const modal = document.getElementById('main-modal');
            const mainContent = document.getElementById('main-content');

            // 모달 숨기기 (hidden 속성 추가)
            overlay.setAttribute('hidden', '');

            // 배경 콘텐츠의 aria-hidden 해제 (다시 접근 가능하게)
            mainContent.removeAttribute('aria-hidden');

            // Focus Trap & Escape 이벤트 제거
            modal.removeEventListener('keydown', trapFocus);
            document.removeEventListener('keydown', handleEscape);

            // 모달을 열었던 버튼으로 포커스 복귀
            // 사용자가 어디에 있었는지 기억하고 돌아감
            if (triggerElement) {
                triggerElement.focus();
                triggerElement = null;
            }
        }

        // ── Focus Trap 함수
        // Tab/Shift+Tab이 모달 내에서만 순환하도록 제한
        function trapFocus(e) {
            if (e.key !== 'Tab') return;

            const modal = document.getElementById('main-modal');
            const focusableElements = Array.from(modal.querySelectorAll(FOCUSABLE_SELECTORS));
            const firstFocusable = focusableElements[0];
            const lastFocusable = focusableElements[focusableElements.length - 1];

            if (e.shiftKey) {
                // Shift+Tab: 첫 번째 요소에서 마지막으로 순환
                if (document.activeElement === firstFocusable) {
                    e.preventDefault(); // 기본 동작(모달 밖으로 이동) 차단
                    lastFocusable.focus();
                }
            } else {
                // Tab: 마지막 요소에서 첫 번째로 순환
                if (document.activeElement === lastFocusable) {
                    e.preventDefault();
                    firstFocusable.focus();
                }
            }
        }

        // ── Escape 키 처리
        function handleEscape(e) {
            if (e.key === 'Escape') {
                closeModal();
            }
        }

        // ── 오버레이 클릭 시 모달 닫기 (배경 클릭)
        function handleOverlayClick(e) {
            // 모달 박스 자체를 클릭한 게 아닐 때만 닫기
            if (e.target === document.getElementById('modal-overlay')) {
                closeModal();
            }
        }

        // ── 주문 확인 처리
        function confirmOrder() {
            closeModal();

            // 성공 메시지 표시
            // role="status": 스크린리더가 자동으로 읽음 (alert보다 덜 방해)
            const msg = document.getElementById('success-msg');
            msg.style.display = 'block';

            // 3초 후 숨기기
            setTimeout(() => { msg.style.display = 'none'; }, 3000);
        }

        // ── 버튼 클릭 이벤트 등록
        document.getElementById('open-modal').addEventListener('click', function () {
            openModal(this); // this: 클릭된 버튼 (트리거)
        });
    </script>
</body>
</html>

Learn this lesson with a mentor

Work through the Build Your Homepage series on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now