네이티브 Popover API: JavaScript 없이 구축한 4가지 메뉴와 툴팁
요약
네이티브 Popover API와 CSS Anchor Positioning을 사용하여 JavaScript 없이 드롭다운, 툴팁 등 4가지 UI 컴포넌트를 구축하는 방법을 소개합니다. 최상위 레이어(top-layer) 렌더링을 통해 z-index 관리와 overflow 문제를 해결할 수 있습니다.
핵심 포인트
- Popover API를 통해 라이트 디스미스(light-dismiss) 기능을 별도 코드 없이 구현 가능
- 최상위 레이어(top-layer) 활용으로 z-index 및 overflow: hidden 문제 해결
- CSS Anchor Positioning으로 트리거와 메뉴를 JavaScript 없이 연결
- popover='auto'와 'manual' 모드를 통해 용도에 맞는 동작 제어 가능
-
Popover 속성은 최상위 레이어 (top-layer) 렌더링과 라이트 디스미스 (light-dismiss) 기능을 무료로 제공합니다.
-
앵커 포지셔닝 (Anchor positioning)은 JavaScript 없이 메뉴를 트리거에 연결합니다.
-
4가지 컴포넌트 구축: 드롭다운 (dropdown), 툴팁 (tooltip), 커맨드 메뉴 (command menu), 확인 팝업 (confirm popup)
-
프로젝트에서 포지셔닝 라이브러리와 모든 z-index 편법을 제거했습니다.
지난달 저는 한 Shopify 테마에서 12KB 크기의 포지셔닝 라이브러리와 40줄에 달하는 z-index 추측 코드를 삭제했습니다. 그 대체재는 단 두 개의 HTML 속성과 몇 가지 CSS 규칙이었습니다. 네이티브 Popover API와 CSS 앵커 포지셔닝 (CSS anchor positioning)은 이제 제가 예전에 일일이 수동으로 연결했던 모든 메뉴, 툴팁, 확인 대화 상자 (confirm dialog)를 처리합니다.
제가 무엇을 만들었는지, 왜 작동하는지, 그리고 아직 다듬어지지 않은 부분은 어디인지 소개합니다.
Popover 속성이 계산 방식을 바꾸는 이유
가장 먼저 저를 놀라게 한 것은 필요한 코드의 양이 매우 적다는 점이었습니다. Popover는 단지 popover 속성을 가진 요소와, popovertarget로 해당 요소를 가리키는 트리거일 뿐입니다. 이벤트 리스너 (event listeners)도, 상태 변수 (state variable)도, 라이브러리도 필요 없습니다.
Options
...
버튼을 클릭하면 div가 나타납니다. div 외부를 클릭하면 div가 숨겨집니다. Escape 키를 누르면 숨겨집니다. 이것이 라이트 디스미스 (light-dismiss)이며, 별도의 비용 없이 제공됩니다. 예전에는 모든 드롭다운에 대해 문서 클릭 리스너 (document click listener)를 작성하여 클릭이 메뉴 내부에 있는지 확인한 다음 닫는 방식을 사용했습니다. 그러다 Escape 키 처리를 잊어버리기도 했고, 언마운트 (unmount) 시 리스너를 제거하는 것을 잊어 메모리 누수 (memory leak)를 일으키기도 했습니다. 그 모든 것이 사라졌습니다.
더 큰 이점은 최상위 레이어 (top layer)입니다. popover 속성이 있는 모든 것은 DOM 내의 위치나 조상 요소의 overflow: hidden 설정과 관계없이, 페이지의 다른 모든 것보다 위에 브라우저 최상위 레이어에서 렌더링됩니다. 이것이 저의 z-index 전쟁을 끝내준 핵심입니다. 예전에 둥근 모서리를 위해 카드에 overflow: hidden을 적용한 테마가 있었는데, 그 안의 드롭다운이 잘리는 문제가 있었습니다. 과거의 해결책은 포털 (portal)을 사용하여 메뉴를 document.body로 텔레포트시킨 다음 수동으로 위치를 잡는 것이었습니다. 최상위 레이어 덕분에 이제 그러한 패턴 전체가 불필요해졌습니다.
두 가지 모드가 있습니다. popover (기본값은 popover="auto")는 가벼운 해제 (light-dismiss) 기능을 제공하며, 한 번에 하나의 자동 팝오버만 열 수 있습니다. popover="manual"은 코드로 직접 닫기 전까지 열려 있는 상태를 유지하며, 저는 이를 토스트 (toasts)나 지속적인 패널 (persistent panels)에 사용합니다. 처음에 적절한 모드를 선택하는 것이 나중에 혼란을 줄이는 길입니다.
브라우저 지원은 이제 안정적입니다. Chrome, Edge, Safari, Firefox 모두 이를 지원합니다. 저는 여전히 구형 브라우저를 사용하는 3%의 트래픽을 위해 아주 작은 기능 체크 (feature check)를 추가하며, 해당 사용자들에게는 플로팅 메뉴 대신 일반적인 인라인 블록 (inline block)이 표시됩니다. 아무도 불평하지 않았습니다. 폴백 (fallback) 방식은 미관상 덜 예쁠 뿐 기능이 고장 난 것은 아니며, 이는 점진적 향상 (progressive enhancement)을 위한 올바른 절충안입니다.
첫 번째와 두 번째 컴포넌트: 드롭다운 (Dropdown) 및 툴팁 (Tooltip)
드롭다운은 쉬운 사례였습니다. 핵심은 트리거 (trigger)를 기준으로 위치를 잡는 것이며, 여기서 앵커 포지셔닝 (anchor positioning)이 등장합니다. 트리거를 앵커 (anchor)로 지정한 다음, 팝오버가 여기에 부착되도록 설정합니다.
.menu-trigger { anchor-name: --opts; }
#menu {
...
position-area는 읽기 쉬운 축약형입니다. bottom span-right는 "트리거 아래에 위치하며 오른쪽으로 확장됨"을 의미합니다. getBoundingClientRect도, 스크롤 리스너 (scroll listeners)도, 리사이즈 (resize) 시의 재계산도 필요 없습니다. 브라우저가 페이지가 스크롤되는 동안 메뉴를 트리거에 딱 붙여서 유지합니다. 스티키 헤더 (sticky header) 내부에서 테스트해 보았는데 완벽하게 추적되었습니다.
가장 유용한 부분은 position-try입니다. 만약 메뉴가 뷰포트 (viewport) 하단을 벗어날 것 같으면, 브라우저가 자동으로 트리거 위쪽으로 뒤집어줍니다.
#menu {
position-try-fallbacks: flip-block, flip-inline;
...
이 한 줄의 코드가 제가 배포했던 모든 메뉴에서 가장 버그가 많았던 부분인 충돌 감지 (collision-detection) 로직을 대체했습니다. 긴 제품 페이지에서도 푸터 (footer) 근처의 드롭다운들이 제가 단 하나의 조건문도 작성하지 않았음에도 이제 위쪽으로 열립니다.
툴팁 (tooltip)은 훨씬 더 가벼웠습니다. 저는 popover="manual"을 popovertargetaction 및 호버 (hover) 처리와 결합하여 사용했지만, 더 깔끔한 버전은 툴팁이 포커스 (focus) 및 호버 시에 나타나는 앵커드 팝오버 (anchored popover)인 새로운 CSS 전용 방식을 사용합니다. 접근성 (accessibility)을 위해 저는 Escape 키로 닫을 수 있고 키보드로 접근할 수 있도록 유지하는데, 마우스 호버 시에만 나타나는 툴팁은 키보드 사용자를 배제하기 때문입니다.
.tip {
position-anchor: --field;
...
실제 수치 하나를 말씀드리자면, 제 툴팁 컴포넌트 (component)의 코드가 84줄의 JavaScript와 CSS에서 마크업을 포함한 21줄의 순수 CSS로 줄어들었습니다. 이는 약 75%의 절감이며, 브라우저가 위치 계산 (positioning math)을 담당하기 때문에 새로운 버전은 예외 케이스 (edge cases)도 더 적습니다. 위치 지정 측면에 대한 더 깊은 맥락을 원하신다면, 제가 가장 자주 사용하는 다섯 가지 패턴을 다루는 "CSS Anchor Positioning Is Production Ready"를 참고하세요. (제가 그곳에 앵커 패턴 목록을 계속 업데이트하고 있으니, 배포하기 전에 인덱스에서 해당 링크가 존재하는지 확인하세요.)
세 번째 컴포넌트: 커맨드 메뉴 (Command Menu)
이것은 제가 반드시 JavaScript로 돌아가야만 할 것이라고 확신했던 것이었지만, 대부분 그렇지 않았습니다. 커맨드 메뉴는 키보드 단축키로 여는 종류의, 검색 필터링이 가능한 액션 (action) 목록입니다. 팝오버 (popover)가 열림과 닫힘을 처리합니다. 앵커 포지셔닝 (Anchor positioning)은 화면 상단 근처 중앙에 배치하는 것을 담당합니다.
popover 속성은 저에게 쉘 (shell)을 무료로 제공합니다.
여전히 두 가지를 위해 JavaScript가 필요합니다: 타이핑 시 목록을 필터링하는 것과 메뉴를 여는 키보드 단축키입니다. 그 외의 모든 것(최상위 레이어 렌더링 (top-layer render), 백드롭 (backdrop), Escape로 닫기, 외부 클릭 시 해제)은 네이티브 (native)입니다. 따라서 제 스크립트는 약 30줄로 줄어들었으며, 그 모든 것은 DOM 배관 작업 (plumbing)이 아닌 순수한 로직입니다.
::backdrop 의사 요소 (pseudo-element)도 언급할 가치가 있습니다. 팝오버는 별도의 div를 추가하지 않고도 스타일을 지정할 수 있는 백드롭 요소를 가집니다.
#cmd::backdrop {
background: rgba(0, 0, 0, 0.4);
...
이전에는 절대 위치를 지정한 오버레이 div로 어둡게 처리된 배경을 만들고, 메뉴와 z-index를 조율해야 했습니다. 이제는 하나의 선택자로 처리할 수 있으며 레이어링은 최상위 계층에서 처리합니다.
애니메이션이 마지막 부분이었습니다. Popover는 @starting-style과 display, 그리고 overlay에 대한 트랜지션을 사용하여 열리고 닫힐 때 애니메이션을 적용할 수 있습니다. 이는 요소가 종료 애니메이션 동안 최상위 계층에 머물도록 해줍니다. 이전에는 애니메이션이 끝날 때까지 언마운트(unmount)를 지연시키기 위해 setTimeout이 필요했고, 이 패턴은 사용자가 빠르게 클릭할 때마다 깨지는 문제가 있었습니다.
#cmd {
opacity: 0;
...
allow-discrete 키워드는 display와 overlay를 애니메이션 가능하게 만드는 마법의 단어입니다. 이것이 없으면 메뉴가 그냥 '딱' 하고 나타납니다. 저는 처음 세 번이나 이 키워드를 잊었고, 제 페이드 효과가 아무것도 하지 않는 이유를 알아낼 수 없었습니다. 배경에 대해서는 전체 분석을 원하시면 CSS Anchor Positioning Is Production Ready에서 동일한 display-transition 트릭을 다루었습니다.
제가 이러한 빌드 과정을 문서화하는 포스팅 일정을 잡기 위해서는 Buffer를 사용하는데, 이는 제가 글을 쓰는 동안 발행 측면을 대신 처리해 줍니다.
컴포넌트 네 개: 확인 팝업과 예외 처리
네 번째 컴포넌트는 작은 확인 팝업입니다. 삭제 버튼에 고정되어 나타나는
첫째, 포커스 관리 (focus management)입니다. Popover API는 dialog 요소의 경우 포커스를 팝오버 내부로 이동시키지만, 일반적인 div popover는 기본적으로 포커스를 가두지(trap focus) 않습니다. 커맨드 메뉴(command menu)의 경우, 아주 작은 toggle 이벤트 리스너를 사용하여 열릴 때 포커스를 검색 입력창으로 이동시킵니다. 이 과정을 생략하지 마세요. 포커스를 보내지 않고 열리는 메뉴는 키보드 사용자에게 사용할 수 없는 상태가 됩니다.
둘째, position-area가 아직 제가 원하는 모든 정렬 케이스를 커버하지는 못합니다. 넓은 앵커(anchor) 위에 팝오버를 가로로 중앙 정렬하려면 때때로 justify-self: anchor-center가 필요한데, 이는 다른 속성들에 비해 지원 범위가 불완전합니다. 저는 @supports 체크를 유지하며 마진(margin)을 이용한 미세 조정(nudge) 방식으로 폴백(fallback) 처리를 합니다.
셋째, 자동 해제(auto-dismiss) 기능이 문제를 일으킬 수 있습니다. 만약 자동 팝오버를 다른 자동 팝오버 안에 중첩시킨다면, 트리거(trigger)를 부모 요소 안에 배치하여 팝오버 중첩 규칙(popover nesting rules)을 올바르게 사용하지 않는 한 자식 팝오버를 여는 동작이 부모를 닫아버릴 수 있습니다. 저는 트리거가 위치한 곳으로부터 관계가 추론되는 것이지, 팝오버 자체의 DOM 중첩으로부터 추론되는 것이 아니라는 사실을 읽기 전까지 이 문제로 한 시간을 허비했습니다.
넷째, 종료 애니메이션(animating exit)을 구현하려면 트랜지션 리스트(transition list)에 overlay가 포함되어야 합니다. 그렇지 않으면 애니메이션 중간에 요소가 최상위 레이어(top layer)에서 벗어나 다른 콘텐츠 뒤에서 깜빡거리게 됩니다. 확인 팝업(confirm popup)을 만들 때 이 부분을 추가하기 전까지 저도 이 문제로 애를 먹었습니다.
이 중 어느 것도 치명적인 결함은 아닙니다. 한 번 겪어보고, 기록해 두면 다시는 겪지 않게 될 종류의 예외 상황들입니다. 이 주제에 대한 저의 러닝 노트(running notes)는 Claude Blueprint로 바로 이어지며, 그곳은 제가 여러 테마에 걸쳐 재사용하는 패턴들을 보관하는 곳입니다.
결론 (Bottom Line)
네 가지 컴포넌트: 드롭다운(dropdown), 툴팁(tooltip), 커맨드 메뉴(command menu), 확인 팝업(confirm popup). 저는 이 모든 것을 네이티브 popover 속성과 CSS 앵커 포지셔닝(CSS anchor positioning)만으로 구현했으며, 그 과정에서 12KB 크기의 라이브러리와 모든 z-index 해킹 코드를 제거했습니다. 툴팁 하나만으로도 코드 양이 약 75% 감소했습니다. 커맨드 메뉴는 약 30줄의 순수 로직만 남기고 나머지는 브라우저에 돌려주었습니다.
진정한 보상은 사고방식의 전환에 있습니다. 최상위 레이어 렌더링 (Top-layer rendering) 덕분에 요소가 잘리거나 쌓임 순서 (stacking)를 두고 싸울 필요가 없어졌습니다. 가벼운 닫기 (Light-dismiss) 기능과 Escape 키 처리도 기본으로 제공됩니다. position-try를 활용한 앵커 포지셔닝 (Anchor positioning)은 단 하나의 조건문 없이도 메뉴가 화면 가장자리에서 벗어나도록 반전시켜 줍니다. 저는 더 적은 코드를 작성하고, 더 적은 버그를 배포하며, 브라우저가 까다로운 부분을 담당하기 때문에 컴포넌트들이 일관되게 동작합니다.
스토어 인터페이스나 플로팅 요소 (floating elements)가 포함된 UI를 구축한다면, 라이브러리를 찾기 전에 메뉴 하나를 이런 방식으로 다시 만들어 보세요. 단순한 드롭다운 (dropdown)부터 시작하여, 앵커 (anchor)를 추가하고, flip-block 폴백 (fallback)을 더한 다음, 커맨드 메뉴 (command menu)로 단계적으로 나아가 보세요. 이러한 패턴들은 차곡차곡 쌓입니다. 제가 사용하는 재사용 가능한 스니펫 (snippets) 전체 세트와 이를 관리하는 방법은, 만약 빠른 시작을 원하신다면 Claude Blueprint에 정리되어 있습니다.
이 기사에는 제휴 링크가 포함되어 있습니다. 이 링크를 통해 가입하시면, 귀하에게 추가 비용 부담 없이 저에게 소정의 수수료가 지급될 수 있습니다. (광고)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기