0Pricing
Tailwind CSS Academy · 강의

접근성 높은 대화 상자 만들기

Headless UI의 Dialog 컴포넌트를 사용하여 포커스 가두기와 Esc 키 처리가 내장된 모달을 만들고, 전체 스타일을 Tailwind로 지정합니다.

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

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

What Makes a Dialog Accessible?

An accessible dialog (modal) must satisfy several requirements: it must receive keyboard focus when opened, trap focus within itself so users cannot Tab outside, be dismissable with the Escape key, apply correct ARIA attributes (role='dialog', aria-modal='true'), and return focus to the trigger element when closed. These requirements are complex to implement correctly. Headless UI's Dialog component handles all of them automatically.

Basic Dialog Structure

The Headless UI Dialog composes three key elements: Dialog (root, handles ARIA and focus), Dialog.Panel (the visible modal box), and optionally Dialog.Title and Dialog.Description (for semantic labeling). The open prop controls visibility, and onClose fires when the user presses Escape or clicks outside the panel — you decide what action to take (typically setting open state to false).

import { Dialog } from '@headlessui/react';
import { useState } from 'react';

function AlertDialog() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button onClick={() => setOpen(true)}>Open Dialog</button>

      <Dialog open={open} onClose={() => setOpen(false)}>
        <Dialog.Panel>
          <Dialog.Title>Alert</Dialog.Title>
          <Dialog.Description>This is an important message.</Dialog.Description>
          <button onClick={() => setOpen(false)}>Close</button>
        </Dialog.Panel>
      </Dialog>
    </>
  );
}

Adding the Backdrop Overlay

A modal should dim the page content behind it to draw the user's attention to the dialog. Add a full-screen backdrop using fixed inset-0 with a semi-transparent background. Place it as the first child of Dialog, before the panel container. Use aria-hidden='true' on the backdrop since it is purely decorative — screen readers should not announce it.

<Dialog open={open} onClose={() => setOpen(false)} className='relative z-50'>

  {/* Backdrop */}
  <div
    className='fixed inset-0 bg-black/50 backdrop-blur-sm'
    aria-hidden='true'
  />

  {/* Panel container — centers the dialog */}
  <div className='fixed inset-0 flex items-center justify-center p-4'>
    <Dialog.Panel className='bg-white rounded-2xl shadow-2xl max-w-md w-full'>
      {/* Dialog content */}
    </Dialog.Panel>
  </div>

</Dialog>

Styling the Dialog Panel

The Dialog.Panel is the visible modal container. Apply Tailwind classes for background, border radius, shadow, padding, and max-width to create a polished card. The panel should have a max-w-* constraint so it does not stretch full-width on large screens, while remaining responsive on small screens with w-full. Add a close button in the top-right corner for mouse users who prefer clicking over pressing Escape.

<Dialog.Panel className='relative bg-white rounded-2xl shadow-2xl max-w-lg w-full p-6'>
  {/* Close button */}
  <button
    onClick={() => setOpen(false)}
    className='absolute top-4 right-4 rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600'
  >
    <XMarkIcon className='h-5 w-5' />
    <span className='sr-only'>Close</span>
  </button>

  {/* Header */}
  <Dialog.Title className='text-lg font-semibold text-gray-900 pr-8'>
    Delete Project
  </Dialog.Title>
  <Dialog.Description className='mt-2 text-sm text-gray-600'>
    This action cannot be undone. All project data will be permanently removed.
  </Dialog.Description>

  {/* Actions */}
  <div className='mt-6 flex gap-3 justify-end'>
    <button onClick={() => setOpen(false)}
      className='px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 hover:bg-gray-50'>
      Cancel
    </button>
    <button
      className='px-4 py-2 text-sm font-medium rounded-lg bg-red-600 text-white hover:bg-red-700'>
      Delete
    </button>
  </div>
</Dialog.Panel>

Scrollable Dialog for Long Content

Dialogs with long content — like terms of service, form wizards, or detailed previews — need to be scrollable without the backdrop scrolling. Apply overflow-y-auto to the panel and a max-h-* constraint so the dialog does not grow beyond the viewport. The outer centering container should be items-start with top padding to keep the dialog near the top of the screen for very long content.

// Scrollable dialog for long content
<div className='fixed inset-0 overflow-y-auto'>
  <div className='flex min-h-full items-start justify-center p-4 pt-16'>
    <Dialog.Panel
      className='
        bg-white rounded-2xl shadow-xl
        max-w-2xl w-full
        max-h-[80vh] overflow-y-auto
      '
    >
      <div className='sticky top-0 bg-white border-b border-gray-100 px-6 py-4 z-10'>
        <Dialog.Title className='text-lg font-semibold'>Terms of Service</Dialog.Title>
      </div>

      <div className='px-6 py-4 prose prose-sm'>
        {/* Long content */}
      </div>

      <div className='sticky bottom-0 bg-white border-t border-gray-100 px-6 py-4'>
        <button className='w-full bg-blue-600 text-white rounded-lg py-2'>Accept</button>
      </div>
    </Dialog.Panel>
  </div>
</div>

Dialog Size Variants

Build reusable dialog size variants using Tailwind's max-w-* utilities. Small dialogs for confirmations, medium for forms, and large for previews or multi-step wizards. Create a DialogModal component that accepts a size prop and applies the corresponding max-width class — this is a natural use case for the CVA (class-variance-authority) pattern.

const panelSizes = {
  sm: 'max-w-sm',
  md: 'max-w-md',
  lg: 'max-w-lg',
  xl: 'max-w-2xl',
  full: 'max-w-5xl'
};

function DialogModal({ open, onClose, size = 'md', title, description, children }) {
  return (
    <Dialog open={open} onClose={onClose} className='relative z-50'>
      <div className='fixed inset-0 bg-black/50' aria-hidden='true' />
      <div className='fixed inset-0 flex items-center justify-center p-4'>
        <Dialog.Panel
          className={cn(
            'bg-white rounded-2xl shadow-xl w-full p-6',
            panelSizes[size]
          )}
        >
          {title && <Dialog.Title className='text-lg font-semibold'>{title}</Dialog.Title>}
          {description && <Dialog.Description className='mt-1 text-sm text-gray-600'>{description}</Dialog.Description>}
          <div className='mt-4'>{children}</div>
        </Dialog.Panel>
      </div>
    </Dialog>
  );
}

Focus Management in Practice

Headless UI automatically moves focus into the dialog when it opens. By default, focus goes to the first focusable element inside the panel. To direct focus to a specific element — like a primary CTA or a text input — use the initialFocus prop with a React ref pointing to that element. This improves the user experience for keyboard and screen reader users who immediately need to interact with a specific control.

import { Dialog } from '@headlessui/react';
import { useRef } from 'react';

function DeleteConfirm({ open, onClose, onDelete }) {
  const cancelButtonRef = useRef(null);

  return (
    <Dialog
      open={open}
      onClose={onClose}
      initialFocus={cancelButtonRef}  // focus Cancel by default (safer)
    >
      {/* ...backdrop... */}
      <div className='fixed inset-0 flex items-center justify-center p-4'>
        <Dialog.Panel className='bg-white rounded-2xl p-6 max-w-sm w-full shadow-xl'>
          <Dialog.Title className='font-semibold text-gray-900'>Delete?</Dialog.Title>
          <div className='mt-4 flex gap-3 justify-end'>
            {/* initialFocus lands here */}
            <button ref={cancelButtonRef} onClick={onClose}
              className='px-4 py-2 text-sm border rounded-lg'>
              Cancel
            </button>
            <button onClick={onDelete}
              className='px-4 py-2 text-sm bg-red-600 text-white rounded-lg'>
              Delete
            </button>
          </div>
        </Dialog.Panel>
      </div>
    </Dialog>
  );
}

Nested Dialog Stacking

Sometimes a dialog triggers another dialog — like a confirmation within a settings modal. Use incrementally higher z-index values for nested dialogs so they layer correctly. Each dialog manages its own focus trap independently; Headless UI supports multiple open dialogs simultaneously. Use state variables for each dialog level and close them in reverse order.

function SettingsModal({ open, onClose }) {
  const [confirmOpen, setConfirmOpen] = useState(false);

  return (
    <>
      {/* Primary dialog — z-40 */}
      <Dialog open={open} onClose={onClose} className='relative z-40'>
        <div className='fixed inset-0 bg-black/40' aria-hidden='true' />
        <div className='fixed inset-0 flex items-center justify-center p-4'>
          <Dialog.Panel className='bg-white rounded-2xl p-6 max-w-lg w-full shadow-xl'>
            <h2 className='font-semibold text-lg'>Settings</h2>
            <button onClick={() => setConfirmOpen(true)}
              className='mt-4 text-red-600 text-sm'>
              Reset all settings
            </button>
          </Dialog.Panel>
        </div>
      </Dialog>

      {/* Nested confirmation — z-50 (higher) */}
      <Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)} className='relative z-50'>
        {/* ... */}
      </Dialog>
    </>
  );
}

Preventing Background Scroll

When a dialog is open, the page content behind it should not scroll. Headless UI does not handle this automatically. Add a side effect that adds overflow-hidden to the body when the dialog opens and removes it when it closes. In a Next.js or React app, use a useEffect inside the dialog component or a custom hook that cleans up correctly on unmount.

import { useEffect } from 'react';

function useBodyScrollLock(isLocked) {
  useEffect(() => {
    if (isLocked) {
      document.body.classList.add('overflow-hidden');
    } else {
      document.body.classList.remove('overflow-hidden');
    }
    // Cleanup on unmount
    return () => document.body.classList.remove('overflow-hidden');
  }, [isLocked]);
}

// Usage in dialog component
function MyDialog({ open, onClose }) {
  useBodyScrollLock(open);

  return (
    <Dialog open={open} onClose={onClose}>
      {/* ... */}
    </Dialog>
  );
}

Dialog Accessibility Checklist

Before shipping a dialog component, verify it meets accessibility requirements: focus moves into the dialog on open, focus is trapped inside while open, Escape dismisses the dialog, clicking the backdrop dismisses the dialog, focus returns to the trigger element on close, screen readers announce the dialog title, and all interactive elements inside are keyboard reachable. Headless UI handles most of these — verify focus return and backdrop click behavior in your implementation.

/*
  Dialog Accessibility Checklist:

  [✓] Focus enters dialog on open (Headless UI automatic)
  [✓] Focus trapped inside while open (Headless UI automatic)
  [✓] Escape key closes dialog (Headless UI automatic)
  [✓] role='dialog' + aria-modal='true' (Headless UI automatic)
  [✓] Dialog.Title used for aria-labelledby (Headless UI automatic)
  [✓] Dialog.Description for aria-describedby (Headless UI automatic)
  [ ] Focus returns to trigger on close → store triggerRef
  [ ] Backdrop click closes dialog → pass handler to onClose
  [ ] Body scroll locked while open → useBodyScrollLock hook
  [ ] Close button has visible label or aria-label
*/

Dialog Variants: Alert vs Confirm vs Form

Dialogs serve different purposes and should be designed accordingly. An alert dialog delivers urgent information with a single acknowledgement button — use role='alertdialog' for these. A confirmation dialog asks a yes/no question before a destructive action, with Cancel as the default focus. A form dialog contains a complete form with validation. Each type has different size, focus, and button order conventions that help users quickly understand what is expected of them.

<!-- Alert dialog: urgent info, single action -->
<Dialog.Panel class='bg-white rounded-2xl p-6 max-w-sm shadow-xl'>
  <div class='flex items-start gap-4'>
    <div class='flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center'>
      <ExclamationTriangleIcon class='h-5 w-5 text-red-600' />
    </div>
    <div>
      <Dialog.Title class='text-base font-semibold text-gray-900'>Session Expired</Dialog.Title>
      <Dialog.Description class='mt-1 text-sm text-gray-600'>
        Your session has expired. Please log in again.
      </Dialog.Description>
      <button class='mt-4 w-full bg-blue-600 text-white rounded-lg py-2 text-sm font-medium'>
        Log In
      </button>
    </div>
  </div>
</Dialog.Panel>

Quick Check

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

Lesson Recap

In this lesson you learned: Headless UI Dialog handles focus trapping, Escape dismissal, and ARIA roles automatically, the backdrop is a full-screen fixed overlay positioned before the panel, and initialFocus directs keyboard focus to a specific element on open. Next up we animate dialog open and close transitions using Headless UI's Transition component.

자주 묻는 질문

“접근성 높은 대화 상자 만들기” 강의는 무료인가요?

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

“접근성 높은 대화 상자 만들기”에서 뭘 배우나요?

Headless UI의 Dialog 컴포넌트를 사용하여 포커스 가두기와 Esc 키 처리가 내장된 모달을 만들고, 전체 스타일을 Tailwind로 지정합니다. 브라우저에서 직접 실행하는 실습 코드로 Tailwind CSS Academy을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

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

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

“접근성 높은 대화 상자 만들기” 강의는 얼마나 걸리나요?

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

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

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

이 강의의 모든 강의

  1. Headless UI 입문
  2. 헤드리스 메뉴와 드롭다운 스타일 지정
  3. 접근성 높은 대화 상자 만들기
  4. Headless UI를 사용한 전환 효과
← Tailwind CSS Academy(으)로 돌아가기