모달과 팝오버, 그리고 앵커 포지셔닝

작성일:2025.05.31|수정일:2026.07.07|조회수:4

모달과 팝오버, 그리고 앵커 포지셔닝

모달과 팝오버를 칼로 베듯 나눠서 생각해본 적은 별로 없었다. 화면 한가운데 떠서 다른 작업을 막으면 모달, 버튼 옆에 붙어서 옵션이나 보조 정보를 보여주면 팝오버, 정도로 느슨하게 이해하고 있었다. 실제 UI를 만들 때도 대체로 그 정도 감각이면 굴러간다. 문제는 브라우저가 이 둘을 점점 더 구체적인 기능으로 다루기 시작하면서, “대충 떠 있는 것”이라는 분류가 꽤 빨리 한계에 부딪힌다는 점이다.

내가 보기에는 기준은 위치가 아니라 책임이다. 모달은 사용자의 흐름을 잠시 멈추고 하나의 결정을 요구하는 장치다. 배경은 상호작용할 수 없어야 하고, 포커스도 모달 안에 머물러야 하며, 사용자는 모달을 닫거나 완료하기 전까지 원래 화면으로 돌아갈 수 없다. 반면 팝오버는 흐름을 막지 않는다. 메뉴, 도움말, 자동완성 후보, 작은 설정 패널처럼 현재 맥락을 보조하는 쪽에 가깝다.

그래서 둘을 이렇게 정리하는 편이 더 안전하다.

이 차이를 놓치면 구현은 돌아가더라도 접근성과 포커스, 닫기 동작, z-index 같은 문제를 계속 수동으로 붙잡게 된다. 그리고 요즘 브라우저는 이 부분을 꽤 많이 대신해주기 시작했다.

dialog는 모달과 비모달을 모두 표현할 수 있다

<dialog>는 대화상자를 표현하는 시맨틱 태그다. 중요한 점은 dialog 자체가 곧 모달이라는 뜻은 아니라는 것이다. 같은 dialog라도 showModal()로 열면 모달이고, show()로 열면 비모달 대화상자다.

TSX
import { useRef } from 'react';

export default function Page() {
  const dialogRef = useRef<HTMLDialogElement>(null);

  return (
    <>
      <button type="button" onClick={() => dialogRef.current?.showModal()}>
        모달 열기
      </button>

      <button type="button" onClick={() => dialogRef.current?.show()}>
        비모달 dialog 열기
      </button>

      <dialog ref={dialogRef}>
        <p>dialog 안의 내용</p>
        <button type="button" onClick={() => dialogRef.current?.close()}>
          닫기
        </button>
      </dialog>
    </>
  );
}

showModal()로 열린 dialog는 top layer에 올라간다. 이 레이어는 일반적인 문서 흐름이나 z-index 경쟁 바깥에 있다. 덕분에 z-index: 999999 같은 숫자 놀음을 덜 하게 된다. 또한 모달 dialog에는 ::backdrop이 생기고, 브라우저는 dialog 바깥의 문서를 사실상 inert처럼 다룬다. 배경 요소를 클릭하거나 포커스할 수 없게 만들어서 모달의 차단성을 보장하는 것이다.

CSS
::backdrop {
  background: rgb(0 0 0 / 0.45);
}

.confirm-dialog {
  border: 0;
  border-radius: 12px;
  padding: 24px;
}

반대로 show()로 열린 dialog는 모달이 아니다. 배경은 계속 상호작용 가능하고, 자동 ::backdrop도 모달처럼 붙지 않는다. 그래서 “대화상자처럼 의미는 갖고 싶지만 화면을 막고 싶지는 않은” 경우에 쓸 수 있다. 다만 이 경우에는 top layer와 light dismiss 같은 팝오버 쪽 장점을 자동으로 얻는 것은 아니므로, 그 목적이라면 Popover API를 함께 보는 편이 낫다.

command와 commandfor

dialog를 열고 닫는 코드에서 늘 마음에 걸리는 부분은 버튼이 사실상 “이 대화상자를 열어라”라는 선언을 하고 있는데도, 구현은 자바스크립트 이벤트 핸들러로 흩어진다는 점이었다. commandcommandfor는 이 지점을 HTML 쪽으로 끌어올린다. 버튼이 어떤 요소에게 어떤 명령을 보낼지 선언할 수 있다.

HTML
<button type="button" command="show-modal" commandfor="delete-dialog">
  삭제
</button>

<dialog id="delete-dialog" closedby="closerequest">
  <p>정말 삭제할까?</p>

  <button type="button" command="close" commandfor="delete-dialog">
    취소
  </button>

  <button type="button" command="request-close" commandfor="delete-dialog">
    확인
  </button>
</dialog>

자주 쓰는 내장 명령은 다음 정도다.

commandfor는 명령을 받을 대상의 id를 가리킨다. labelfor나 Popover API의 popovertarget과 비슷한 감각이다. 이 방식의 장점은 자바스크립트를 완전히 없애는 데 있다기보다, 기본적인 여닫기 책임을 플랫폼에 맡기고 앱 코드는 실제 비즈니스 로직에 집중할 수 있다는 데 있다.

물론 지원하지 않는 브라우저까지 포함해야 하는 핵심 플로우라면 폴백을 둬야 한다. 선언형 API는 기능을 더 깔끔하게 만드는 장치이지, 지원 범위 확인을 면제해주는 마법은 아니다.

closedby로 닫기 정책을 드러내기

모달을 만들 때 은근히 귀찮은 부분이 “밖을 눌렀을 때 닫히는가”, “ESC로 닫히는가”, “반드시 버튼을 눌러야 하는가” 같은 정책이다. closedby는 이 정책을 dialog 속성으로 드러낸다.

HTML
<dialog id="settings" closedby="any">
  <form method="dialog">
    <h2>설정</h2>
    <button>닫기</button>
  </form>
</dialog>

값은 세 가지다.

삭제 확인처럼 반드시 명시적인 선택을 받아야 하는 UI라면 none이나 closerequest가 맞을 수 있고, 설정 패널처럼 가볍게 열었다 닫는 UI라면 any가 자연스러울 수 있다. 중요한 것은 이 정책을 우연한 이벤트 핸들러의 결과로 두지 않는 것이다.

Popover API는 비차단 UI를 위한 도구다

Popover API는 어떤 요소든 top layer에 올려 비차단 오버레이로 보여주는 API다. 여기서 핵심은 “비차단”이다. Popover API로 만든 팝오버는 배경을 비활성화하지 않는다. 그래서 중요한 확인, 로그인, 결제 승인처럼 사용자를 한 흐름에 묶어야 하는 UI라면 팝오버가 아니라 모달 dialog가 맞다.

간단한 팝오버는 HTML만으로도 충분하다.

HTML
<button type="button" popovertarget="profile-menu" popovertargetaction="toggle">
  프로필
</button>

<div id="profile-menu" popover="auto">
  <a href="/me">내 정보</a>
  <button type="button">로그아웃</button>
</div>

popover="auto"는 보통 메뉴나 작은 패널에 적합하다. 다른 자동 팝오버와의 관계를 브라우저가 관리하고, 바깥을 클릭하거나 ESC를 누르면 닫히는 light dismiss도 제공한다. 반면 popover="manual"은 브라우저가 자동으로 닫아주지 않는다. 토스트처럼 스크립트가 명확히 수명을 관리해야 하는 UI에 더 가깝다.

CSS에서는 열린 상태를 :popover-open으로 잡을 수 있다.

CSS
#profile-menu {
  opacity: 0;
  transform: translateY(-4px);
  transition:
    opacity 120ms ease,
    transform 120ms ease;
}

#profile-menu:popover-open {
  opacity: 1;
  transform: translateY(0);
}

command를 쓰면 팝오버도 같은 명령 모델로 다룰 수 있다.

HTML
<button type="button" command="show-popover" commandfor="profile-menu">
  열기
</button>

<button type="button" command="hide-popover" commandfor="profile-menu">
  닫기
</button>

<div id="profile-menu" popover="auto">
  메뉴 내용
</div>

단순 팝오버라면 popovertarget이 더 짧고 읽기 쉽다. 디자인 시스템처럼 dialog와 팝오버를 하나의 호출 규칙으로 묶고 싶다면 command/commandfor가 더 매력적이다. 어느 쪽이 더 “최신”인지를 따지기보다, 팀이 어떤 추상화를 유지할 수 있는지가 더 중요하다.

non-modal dialog와 popover를 섞기

재미있는 조합도 있다. dialogpopover 속성을 붙이면 대화상자의 의미를 유지하면서 팝오버의 선언적 여닫기와 top layer 동작을 사용할 수 있다. 예를 들어 화면을 막지는 않지만 내용상 대화상자에 가까운 작은 도움말 패널을 만들 수 있다.

HTML
<button type="button" popovertarget="help-dialog">
  도움말
</button>

<dialog id="help-dialog" popover="auto">
  <h2>단축키</h2>
  <p><kbd>⌘</kbd> + <kbd>K</kbd>로 검색을 열 수 있다.</p>

  <button type="button" popovertarget="help-dialog" popovertargetaction="hide">
    닫기
  </button>
</dialog>

같은 구조를 command로도 쓸 수 있다.

HTML
<button type="button" command="toggle-popover" commandfor="help-dialog">
  도움말
</button>

<dialog id="help-dialog" popover="auto">
  <h2>단축키</h2>
  <p><kbd>⌘</kbd> + <kbd>K</kbd>로 검색을 열 수 있다.</p>

  <button type="button" command="hide-popover" commandfor="help-dialog">
    닫기
  </button>
</dialog>

이 패턴은 꽤 유용하지만, 이름 때문에 헷갈리기 쉽다. dialog popover는 모달이 아니다. 배경을 막아야 한다면 showModal() 또는 command="show-modal"을 써야 한다. 팝오버로 연 dialog는 어디까지나 비모달이다.

Anchor Positioning이 필요한 이유

팝오버를 만들고 나면 다음 문제는 거의 항상 위치다. 버튼 아래에 붙이고 싶고, 화면 가장자리에 닿으면 위로 뒤집고 싶고, 스크롤이나 리사이즈에도 맞게 따라다니게 하고 싶다. 그래서 getBoundingClientRect()를 부르고, 스크롤 이벤트를 듣고, ResizeObserver를 붙이고, 결국 Floating UI 같은 라이브러리를 들여오는 일이 많았다.

CSS Anchor Positioning은 이 문제를 CSS의 레이아웃 문제로 다룬다. 위치 기준이 되는 요소에 anchor-name을 주고, 떠 있는 요소가 position-anchor로 그 앵커를 참조한다.

HTML
<button id="menu-button" popovertarget="menu">메뉴</button>

<div id="menu" popover="auto">
  <button type="button">수정</button>
  <button type="button">삭제</button>
</div>
CSS
#menu-button {
  anchor-name: --menu-button;
}

#menu {
  position: absolute;
  position-anchor: --menu-button;
  position-area: bottom start;
  margin-top: 8px;
}

position-area는 앵커 주변의 어느 영역에 배치할지 선언하는 속성이다. 단순한 메뉴나 툴팁은 position-area: bottom start, top center, right center 같은 값만으로도 충분한 경우가 많다. 더 세밀하게 제어해야 하면 anchor() 함수를 쓴다.

CSS
#menu {
  position: absolute;
  position-anchor: --menu-button;
  inset: auto;
  top: anchor(bottom);
  left: anchor(left);
  min-width: anchor-size(width);
  margin-top: 8px;
}

anchor(bottom)은 앵커의 아래쪽 좌표를, anchor(left)는 왼쪽 좌표를 참조한다. anchor-size(width)를 쓰면 드롭다운의 최소 너비를 버튼 너비에 맞추는 식의 처리도 가능하다. 이 지점에서 CSS가 정말 많이 올라왔다는 느낌을 받는다. 위치 계산만을 위해 부모 래퍼를 억지로 만들 필요가 줄어든다.

다만 anchor-name은 컴포넌트가 반복될 때 충돌을 조심해야 한다. 같은 이름의 앵커가 여러 개 있으면 의도와 다른 요소에 붙는 것처럼 보일 수 있다. 실제 서비스 코드에서는 컴포넌트 단위로 고유한 이름을 만들거나, 필요한 경우 anchor-scope 같은 범위 제어까지 고려하는 편이 안전하다.

@position-try와 fallback

앵커 기준 위치 지정에서 제일 반가운 부분은 화면 밖으로 삐져나가는 상황을 CSS가 처리할 수 있다는 점이다. 기본 위치를 먼저 선언하고, 그 위치가 맞지 않을 때 시도할 후보를 position-try-fallbacks로 나열한다.

CSS
#menu-button {
  anchor-name: --menu-button;
}

#menu {
  position: absolute;
  position-anchor: --menu-button;
  position-area: bottom start;
  margin-top: 8px;
  position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}

flip-block은 블록 축으로 뒤집는다. 아래에 놓으려 했는데 공간이 부족하면 위쪽을 시도하는 식이다. flip-inline은 인라인 축으로 뒤집는다. 둘을 조합하면 아래-왼쪽, 위-왼쪽, 아래-오른쪽, 위-오른쪽 같은 후보를 순서대로 시도할 수 있다.

더 구체적인 대체 위치가 필요하면 @position-try를 직접 정의한다.

CSS
#menu {
  position: absolute;
  position-anchor: --menu-button;
  inset: auto;
  top: anchor(bottom);
  left: anchor(left);
  margin-top: 8px;
  position-try-fallbacks: --above, --align-end;
}

@position-try --above {
  inset: auto;
  bottom: anchor(top);
  left: anchor(left);
  margin-bottom: 8px;
}

@position-try --align-end {
  inset: auto;
  top: anchor(bottom);
  right: anchor(right);
  margin-top: 8px;
}

이 기능은 팝오버와 특히 잘 맞는다. Popover API가 “언제 보일지”를 맡고, Anchor Positioning이 “어디에 놓일지”를 맡는다. 둘을 같이 쓰면 흔한 메뉴 UI의 자바스크립트 양이 확 줄어든다.

지원 범위와 폴백

여기까지 보면 이제 모든 팝오버 라이브러리를 지워도 될 것처럼 느껴진다. 하지만 늘 그렇듯 현실은 조금 더 지저분하다. Popover API 자체는 최신 브라우저에서 꽤 넓게 쓸 수 있지만, command/commandfor, closedby, Anchor Positioning의 세부 기능, @position-try 지원은 브라우저와 버전에 따라 편차가 있다. 특히 오래된 Safari나 Firefox까지 넓게 봐야 하는 서비스라면 확인이 필요하다.

내가 실무에서 잡을 기준은 이렇다.

CSS
.menu {
  position: absolute;
  right: 16px;
  top: 48px;
}

@supports (position-anchor: --anchor) {
  .menu-button {
    anchor-name: --menu-button;
  }

  .menu {
    right: auto;
    top: auto;
    position-anchor: --menu-button;
    position-area: bottom end;
    margin-top: 8px;
    position-try-fallbacks: flip-block, flip-inline;
  }
}

정리하면, 요즘의 모달과 팝오버 구현은 “div를 띄우고 z-index를 올리는 일”이 아니라 브라우저가 제공하는 상호작용 모델을 고르는 일에 가깝다. dialog는 차단적 결정 흐름을, Popover API는 비차단 보조 흐름을, Anchor Positioning은 기준 요소에 붙는 레이아웃을 담당한다. 이 셋을 구분해서 쓰면 코드가 줄어드는 것보다 더 중요한 장점이 생긴다. UI의 책임이 조금 더 선명해진다.

댓글

댓글을 불러오는 중...