HTML 네이티브 다이얼로그와 팝오버: JavaScript 라이브러리 없이 구현

프론트엔드

HTMLdialogPopover API웹 표준접근성

이 글은 누구를 위한 것인가

  • 모달과 팝오버에 무거운 JS 라이브러리를 쓰고 싶지 않은 팀
  • <dialog>의 접근성 자동 처리(포커스 트랩, ARIA)를 활용하려는 개발자
  • Popover API로 선언적인 팝오버를 만들고 싶은 웹 개발자

들어가며

모달 하나 만들려고 react-modal, headlessui 같은 라이브러리를 추가했다. HTML <dialog>와 Popover API는 이 필요를 네이티브로 해결한다. 포커스 트랩, Escape 키 닫기, ::backdrop까지 브라우저가 처리한다.

이 글은 bluefoxdev.kr의 HTML 네이티브 컴포넌트 가이드 를 참고하여 작성했습니다.


1. HTML dialog와 Popover API 비교

[<dialog> 엘리먼트]
  - 모달과 비모달 다이얼로그 모두 지원
  - showModal(): 모달 모드 (backdrop, 포커스 트랩, Escape 닫기)
  - show(): 비모달 (독립 레이어에 표시)
  - close(): 닫기
  - 반환값: returnValue (form[method=dialog]과 연동)
  - 자동 처리: 포커스 트랩, aria-modal, Escape 키
  - 브라우저 지원: Chrome 37+, Firefox 98+, Safari 15.4+

[Popover API]
  - HTML 속성만으로 팝오버 동작
  - popover="auto": 외부 클릭으로 자동 닫힘
  - popover="manual": 명시적으로만 닫힘
  - Top Layer에 표시 (z-index 전쟁 없음)
  - 트리거: popovertarget 속성

[언제 무엇을 쓸까]
  중요한 결정 필요 (확인/취소): <dialog> showModal()
  임시 정보 표시 (툴팁, 알림): Popover API
  사이드 패널, 드로어: <dialog> show()
  드롭다운 메뉴: Popover API + Anchor Positioning

2. 네이티브 컴포넌트 구현

<!-- 기본 <dialog> 모달 -->
<button id="open-btn">모달 열기</button>

<dialog id="confirm-dialog">
  <h2>정말 삭제하시겠습니까?</h2>
  <p>이 작업은 되돌릴 수 없습니다.</p>
  
  <form method="dialog">
    <!-- method="dialog"로 닫기 시 returnValue 설정 -->
    <button value="cancel" autofocus>취소</button>
    <button value="confirm" class="danger">삭제</button>
  </form>
</dialog>

<script>
const dialog = document.getElementById('confirm-dialog');
const openBtn = document.getElementById('open-btn');

openBtn.addEventListener('click', () => {
  dialog.showModal();  // 모달 모드로 열기
});

dialog.addEventListener('close', () => {
  // returnValue: 클릭한 버튼의 value 속성
  if (dialog.returnValue === 'confirm') {
    console.log('삭제 확인됨');
    deleteItem();
  }
});

// Escape 키 처리는 브라우저가 자동으로 함
// 포커스 트랩도 자동 (모달 내부에서만 Tab 이동)
</script>
/* dialog 스타일링 */
dialog {
  border: none;
  border-radius: 12px;
  padding: 24px;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
  max-width: 480px;
  width: 90%;
  
  /* 열림 애니메이션 */
  animation: dialog-show 0.2s ease-out;
}

@keyframes dialog-show {
  from {
    opacity: 0;
    transform: scale(0.95) translateY(-10px);
  }
  to {
    opacity: 1;
    transform: scale(1) translateY(0);
  }
}

/* 배경 오버레이 */
dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(4px);
  animation: backdrop-show 0.2s ease-out;
}

@keyframes backdrop-show {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* 닫힘 애니메이션 (CSS만으로는 어려움 - JS 필요) */
dialog[data-closing] {
  animation: dialog-hide 0.15s ease-in forwards;
}

dialog[data-closing]::backdrop {
  animation: backdrop-hide 0.15s ease-in forwards;
}

@keyframes dialog-hide {
  to { opacity: 0; transform: scale(0.95); }
}

@keyframes backdrop-hide {
  to { opacity: 0; }
}
// 닫힘 애니메이션 추가 (JS)
function closeWithAnimation(dialog) {
  dialog.setAttribute('data-closing', '');
  dialog.addEventListener('animationend', () => {
    dialog.removeAttribute('data-closing');
    dialog.close();
  }, { once: true });
}

// React 래퍼
import { useRef, useEffect } from 'react';

function Dialog({ open, onClose, title, children }) {
  const ref = useRef(null);
  
  useEffect(() => {
    const dialog = ref.current;
    if (!dialog) return;
    
    if (open) {
      dialog.showModal();
    } else {
      // 닫힘 애니메이션
      dialog.setAttribute('data-closing', '');
      const handleEnd = () => {
        dialog.removeAttribute('data-closing');
        dialog.close();
      };
      dialog.addEventListener('animationend', handleEnd, { once: true });
    }
  }, [open]);
  
  useEffect(() => {
    const dialog = ref.current;
    const handleClose = () => onClose?.();
    dialog?.addEventListener('close', handleClose);
    return () => dialog?.removeEventListener('close', handleClose);
  }, [onClose]);
  
  return (
    <dialog ref={ref}>
      <h2>{title}</h2>
      {children}
      <form method="dialog">
        <button>닫기</button>
      </form>
    </dialog>
  );
}
<!-- Popover API 예시 -->

<!-- 1. 기본 팝오버 (외부 클릭 자동 닫힘) -->
<button popovertarget="my-popover">
  도움말
</button>

<div id="my-popover" popover>
  <p>이 기능은 결제 후 이용 가능합니다.</p>
</div>

<!-- 2. manual 팝오버 (명시적으로만 닫힘) -->
<button popovertarget="notification" popovertargetaction="show">
  알림 보기
</button>
<button popovertarget="notification" popovertargetaction="hide">
  알림 닫기
</button>

<div id="notification" popover="manual" class="notification-panel">
  <p>새 메시지 3개</p>
</div>

<!-- 3. popover 이벤트 -->
<script>
const popover = document.getElementById('my-popover');

popover.addEventListener('toggle', (event) => {
  if (event.newState === 'open') {
    console.log('팝오버 열림');
    loadContent();
  } else {
    console.log('팝오버 닫힘');
  }
});
</script>
/* Popover 스타일링 */
[popover] {
  /* Top Layer에 표시 - z-index 불필요 */
  margin: 0;
  padding: 16px;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.1);
  
  /* 기본적으로 숨겨짐 */
  display: none;
}

[popover]:popover-open {
  display: block;
  
  /* 열림 애니메이션 */
  animation: popover-show 0.15s ease-out;
}

@starting-style {
  [popover]:popover-open {
    opacity: 0;
    transform: translateY(-4px);
  }
}

[popover]:not(:popover-open) {
  /* 닫힘 전환 (display: none 전) */
  animation: popover-hide 0.1s ease-in forwards;
  display: block;  /* 애니메이션 동안 표시 유지 */
}

@keyframes popover-show {
  from { opacity: 0; transform: translateY(-4px); }
  to { opacity: 1; transform: translateY(0); }
}

@keyframes popover-hide {
  to { opacity: 0; transform: translateY(-4px); }
}

마무리

<dialog>showModal()은 포커스 트랩, Escape 닫기, aria-modal 설정을 자동으로 처리해서 접근성 높은 모달을 몇 줄로 구현할 수 있다. Popover API는 선언적 HTML만으로 팝오버 열기/닫기를 처리하고, Top Layer 덕분에 z-index 전쟁이 없다. 두 API 모두 2026년 현재 모든 주요 브라우저에서 지원되므로 폴리필 없이 사용 가능하다.