Tailwind CSS Academy · 강의

접근성을 고려한 모달과 드롭다운

aria-modal, role dialog, 포커스 가두기, 키보드 Escape 처리를 추가해 오버레이 컴포넌트의 접근성을 완전히 보장합니다.

레슨 4/413개 단계

접근성을 고려한 모달과 드롭다운은(는) CoddyKit의 무료 Tailwind CSS Academy 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Tailwind CSS Academy 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Tailwind CSS Academy 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

Why Accessibility Is Non-Negotiable

Modals and dropdowns are among the most commonly used interactive patterns in web UIs, and they are also among the most frequently inaccessible. Users who rely on screen readers, keyboard-only navigation, or switch controls cannot use poorly implemented overlays.

Making these components accessible is not optional — it is required by WCAG 2.1 (Web Content Accessibility Guidelines) and by laws like the ADA and the EU Web Accessibility Directive. Beyond compliance, accessible components are simply better UX for everyone.

Focus Management When Modal Opens

When a modal opens, keyboard focus must move into the modal so keyboard users do not have to tab all the way through the page to reach the modal's controls. Programmatically focus the first interactive element — usually the close button or the first form field.

Store a reference to the element that triggered the modal (const trigger = document.activeElement) before opening, so you can return focus to it when the modal closes. This preserves the user's navigation position in the page.

<script>
  let triggerEl = null;

  function openModal() {
    triggerEl = document.activeElement; // save current focus position
    const modal = document.getElementById('modal');
    modal.classList.remove('hidden');
    document.body.classList.add('overflow-hidden');

    // Move focus into the modal
    const firstFocusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
    if (firstFocusable) firstFocusable.focus();
  }

  function closeModal() {
    document.getElementById('modal').classList.add('hidden');
    document.body.classList.remove('overflow-hidden');
    // Return focus to trigger
    if (triggerEl) triggerEl.focus();
  }
</script>

Focus Trapping Inside the Modal

While the modal is open, Tab and Shift+Tab must cycle only through the modal's interactive elements and not escape to the page behind it. This is called a focus trap.

Implement it by finding all focusable elements within the modal, detecting when the last one is focused and Tab is pressed (wrapping to the first), and detecting when the first is focused and Shift+Tab is pressed (wrapping to the last).

<script>
  function trapFocus(modalEl) {
    const focusable = modalEl.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstEl = focusable[0];
    const lastEl = focusable[focusable.length - 1];

    modalEl.addEventListener('keydown', (e) => {
      if (e.key !== 'Tab') return;
      if (e.shiftKey) {
        // Shift+Tab on first element -> wrap to last
        if (document.activeElement === firstEl) {
          e.preventDefault();
          lastEl.focus();
        }
      } else {
        // Tab on last element -> wrap to first
        if (document.activeElement === lastEl) {
          e.preventDefault();
          firstEl.focus();
        }
      }
    });
  }
</script>

ARIA for Modal Dialogs

Modals require specific ARIA attributes for screen readers to understand the dialog context:

  • role='dialog' — identifies the element as a dialog
  • aria-modal='true' — tells assistive technology that content behind the dialog is inert
  • aria-labelledby — points to the dialog's visible title element
  • aria-describedby — optionally points to a description paragraph

Without these, a screen reader user opening a modal may not know they are in a dialog at all.

<div
  id="modal"
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-heading"
  aria-describedby="modal-desc"
  class="fixed inset-0 flex items-center justify-center z-50 p-4 hidden">
  <div class="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
    <h2 id="modal-heading" class="text-lg font-semibold text-gray-900">Confirm Action</h2>
    <p id="modal-desc" class="mt-2 text-sm text-gray-600">
      This action will permanently delete your account.
    </p>
    <div class="mt-5 flex justify-end gap-3">
      <button onclick="closeModal()" class="px-4 py-2 text-sm font-semibold border border-gray-300 rounded-lg">Cancel</button>
      <button class="px-4 py-2 text-sm font-semibold text-white bg-red-600 rounded-lg">Delete</button>
    </div>
  </div>
</div>

Making Background Content Inert

When a modal is open, all page content behind the backdrop should be made inert — not focusable and not interactable by screen readers. The HTML inert attribute achieves this natively, and it is now supported in all modern browsers.

Apply inert to the main content area (not the modal or backdrop) when the modal opens, and remove it when the modal closes. This provides a native alternative to complex aria-hidden attribute management.

<script>
  const mainContent = document.getElementById('main-content');

  function openModal() {
    // Make everything behind the modal inert
    mainContent.setAttribute('inert', '');
    mainContent.setAttribute('aria-hidden', 'true');

    document.getElementById('modal').classList.remove('hidden');
    document.body.classList.add('overflow-hidden');
  }

  function closeModal() {
    // Restore background content
    mainContent.removeAttribute('inert');
    mainContent.removeAttribute('aria-hidden');

    document.getElementById('modal').classList.add('hidden');
    document.body.classList.remove('overflow-hidden');
  }
</script>

Dropdown ARIA Roles

Accessible dropdown menus require ARIA attributes on both the trigger and the panel. The trigger button gets aria-haspopup='true' (or aria-haspopup='menu') and aria-expanded='false'. When opened, update aria-expanded to 'true'.

The dropdown panel gets role='menu', and each item inside it gets role='menuitem'. This full role set allows screen readers to announce the dropdown as a menu and navigate items with arrow keys.

<div class="relative inline-block">
  <!-- Trigger with ARIA -->
  <button
    id="dd-btn"
    aria-haspopup="menu"
    aria-expanded="false"
    aria-controls="dd-menu"
    onclick="toggleDropdown()"
    class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium
           text-gray-700 bg-white border border-gray-300 rounded-lg
           hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500">
    Account
    <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
    </svg>
  </button>
  <!-- Panel with role=menu -->
  <div
    id="dd-menu"
    role="menu"
    aria-labelledby="dd-btn"
    class="hidden absolute top-full left-0 mt-1 z-50 w-56 bg-white border border-gray-200 rounded-xl shadow-lg py-1">
    <a href="/profile" role="menuitem" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Profile</a>
    <a href="/settings" role="menuitem" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Settings</a>
  </div>
</div>

Arrow Key Navigation in Dropdowns

WCAG requires that dropdown menu items be navigable with arrow keys: Down Arrow moves to the next item, Up Arrow moves to the previous, Home moves to the first, End moves to the last. This mirrors native OS menu behavior that keyboard users expect.

Implement this by adding a keydown listener on the menu panel that moves focus between role='menuitem' elements. Items should receive tabindex='-1' so they are programmatically focusable but not part of the Tab order.

<script>
  document.getElementById('dd-menu').addEventListener('keydown', (e) => {
    const items = [...document.querySelectorAll('[role=menuitem]')];
    const idx = items.indexOf(document.activeElement);

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      items[(idx + 1) % items.length].focus();
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      items[(idx - 1 + items.length) % items.length].focus();
    } else if (e.key === 'Home') {
      e.preventDefault();
      items[0].focus();
    } else if (e.key === 'End') {
      e.preventDefault();
      items[items.length - 1].focus();
    } else if (e.key === 'Escape') {
      closeDropdown();
      document.getElementById('dd-btn').focus();
    }
  });
</script>

Visible Focus Styles on All Interactive Elements

Every button, link, and interactive element inside a modal or dropdown must have a clearly visible focus indicator. Tailwind's focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 provides this for keyboard users without showing a ring on mouse clicks.

Remove any outline-none classes on interactive elements unless you are providing a custom focus style in its place. Invisible focus is a WCAG failure.

<!-- Close button in a modal -->
<button
  onclick="closeModal()"
  aria-label="Close dialog"
  class="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg
         focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
         focus-visible:ring-offset-1 transition-colors">
  <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
  </svg>
</button>

Screen Reader Only Text With sr-only

Sometimes a button's visible icon is self-explanatory visually, but a screen reader needs text to announce it. Tailwind's sr-only utility visually hides text while keeping it accessible to screen readers.

Use sr-only for icon-button labels when you do not want to use aria-label, or when you want the text to be part of the DOM (useful for some screen reader behaviors). The class applies position: absolute; width: 1px; height: 1px; overflow: hidden — making it invisible but present.

<!-- Icon button with sr-only label -->
<button
  class="p-2 text-gray-500 hover:bg-gray-100 rounded-lg
         focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
  <svg class="w-5 h-5" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
  </svg>
  <span class="sr-only">Close modal</span>
</button>

Color Contrast in Overlays

Text inside modals and dropdown menus must meet WCAG AA contrast ratios: at least 4.5:1 for normal text and 3:1 for large text. Dark text on white backgrounds easily meets this, but subtle grays on white can fail.

The color text-gray-400 on a white background (#9ca3af on #fff) has a contrast ratio of approximately 2.5:1 — failing WCAG AA. Always use at least text-gray-500 (#6b7280, ~4.6:1) for body text in modals and text-gray-900 for primary text.

<!-- Failing contrast -->
<p class="text-gray-400">Helper text (fails WCAG AA)</p>

<!-- Passing contrast -->
<p class="text-gray-500">Helper text (passes WCAG AA ~4.6:1)</p>
<p class="text-gray-600">Body text (strong contrast ~5.9:1)</p>
<h2 class="text-gray-900">Heading (maximum contrast ~21:1)</h2>

Live Region Announcements

When dynamic content appears inside a modal or dropdown (like a loading spinner transitioning to results), a live region ensures screen readers announce the change without the user having to navigate to it. Add aria-live='polite' to a container element that will receive updates.

aria-live='polite' queues the announcement for when the user is idle. Use aria-live='assertive' only for critical errors that need immediate interruption. Overusing assertive announcements is disruptive.

<div class="p-6">
  <!-- Loading state -->
  <div id="modal-status" aria-live="polite" aria-atomic="true" class="text-center">
    <p class="text-sm text-gray-600">Loading results...</p>
  </div>
</div>

<script>
  // After data loads, update the region
  // Screen reader will announce the change
  function showResults(data) {
    document.getElementById('modal-status').innerHTML =
      '<p class="text-sm text-green-600">Found ' + data.length + ' results.</p>';
  }
</script>

Quick Check

Test your understanding of Tailwind CSS Mastery concepts from this lesson.

Lesson Recap

In this lesson you learned: modal accessibility requires focus management (trap focus inside, return focus on close), role='dialog' aria-modal='true', and the HTML inert attribute to lock background content; dropdown ARIA uses role='menu' on the panel, role='menuitem' on items, aria-haspopup='menu' and aria-expanded on the trigger; and Tailwind's sr-only provides screen-reader-only labels for icon-only controls. Next up we explore how the JIT engine works.

무료로 시작

AI 튜터와 함께 HTML을(를) 배우세요 — 무료

브라우저에서 실제 코드를 작성하고 실행하며, 24/7 AI 튜터로부터 즉각적인 도움을 받고, 웹이나 앱에서 중단한 부분부터 계속 학습하세요.

코스
30
레슨
120

자주 묻는 질문

“접근성을 고려한 모달과 드롭다운” 강의는 무료인가요?

네 — “접근성을 고려한 모달과 드롭다운” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Tailwind CSS Academy 강의 전체를 잠금 해제할 수 있습니다. Tailwind CSS Academy 강의에는 총 4개의 강의가 포함되어 있습니다.

“접근성을 고려한 모달과 드롭다운”에서 뭘 배우나요?

aria-modal, role dialog, 포커스 가두기, 키보드 Escape 처리를 추가해 오버레이 컴포넌트의 접근성을 완전히 보장합니다. 브라우저에서 직접 실행하는 실습 코드로 Tailwind CSS Academy을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

Tailwind CSS Academy을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 Tailwind CSS Academy은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.

“접근성을 고려한 모달과 드롭다운” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 Tailwind CSS Academy 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 Tailwind CSS Academy 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 모달 대화상자 구조
  2. 모달 열기 및 닫기 애니메이션
  3. 드롭다운 메뉴 컴포넌트
  4. 접근성을 고려한 모달과 드롭다운
← Tailwind CSS Academy(으)로 돌아가기