CSS Anchor Positioning + Popover API로 JS 없이 접근성 갖춘 드롭다운·툴팁 만들기
툴팁 하나 만드는 데 getBoundingClientRect() 계산하고 scroll 이벤트 리스너 붙이고, 결국 Popper.js 번들 통째로 끌어다 쓴 경험, 한 번쯤 있으실 거라 생각합니다. 저도 몇 년 전까지는 그랬거든요. 드롭다운이나 컨텍스트 메뉴를 제대로 만들려면 JavaScript 없이는 불가능하다고 당연하게 생각했습니다.
2026년 현재, 그 공식이 깨졌습니다. CSS Anchor Positioning과 Popover API를 함께 쓰면, 위치 자동 계산·오버플로 처리·포커스 관리·Esc 키 닫기를 브라우저가 알아서 처리해주는 접근성 갖춘 드롭다운, 컨텍스트 메뉴, 커스텀 셀렉트 박스를 순수 HTML/CSS만으로 만들 수 있습니다. Chrome 125+, Firefox 147+, Safari 26에서 모두 지원되는 Baseline 2026 기능(웹 플랫폼 전반의 브라우저 호환성을 추적하는 공식 지표)이라 실무 적용도 현실적인 타이밍입니다.
이 글에서는 두 기술의 핵심 개념을 짚고, 컨텍스트 메뉴, 커스텀 셀렉트 박스, 스크롤 컨테이너 안의 툴팁까지 세 가지 예시를 직접 만들어보면서 실제 어디까지 가능한지, 어디서 주의해야 하는지를 솔직하게 다뤄보겠습니다.
핵심 개념
CSS Anchor Positioning: 브라우저가 계산기 역할을 대신해준다
기존 방식에서 드롭다운 메뉴를 버튼 아래에 붙이려면 버튼의 위치와 크기를 JavaScript로 읽어 팝업 요소에 top/left를 직접 계산해 넣어야 했습니다. 스크롤하거나 창 크기가 바뀌면 다시 계산해야 했고, 뷰포트 경계를 벗어나지 않도록 방향 전환(flip) 로직까지 직접 구현해야 했죠.
CSS Anchor Positioning은 이 역할을 브라우저 레이아웃 엔진에 넘깁니다. 앵커 요소에 이름을 붙이고, 팝업 요소가 그 앵커를 기준으로 배치된다고 선언하기만 하면 됩니다.
/* 앵커 역할을 할 요소 */
.trigger-btn {
anchor-name: --my-anchor;
}
/* 앵커를 기준으로 배치될 요소 */
.popup {
position: absolute;
position-anchor: --my-anchor;
position-area: bottom span-right; /* 앵커 아래, 왼쪽 정렬 */
margin-block-start: 4px;
}
position-area란? 앵커 요소 주변을 3×3 격자로 나눈 뒤, 팝업이 위치할 영역을 키워드로 지정하는 속성입니다.top center,bottom span-right처럼 방향 키워드를 쓸 수 있고,block-end inline-start같은 논리 방향 키워드도 사용할 수 있어 아랍어·히브리어처럼 오른쪽에서 왼쪽으로 읽는 언어(RTL) 레이아웃에도 자연스럽게 대응됩니다.
뷰포트 가장자리에서 잘릴 걱정도 position-try-fallbacks 하나면 해결됩니다.
.popup {
position: absolute;
position-anchor: --my-anchor;
position-area: bottom span-right;
/* 아래 공간이 부족하면 위쪽을 시도, 그다음 좌우 반전 시도 */
position-try-fallbacks: flip-block, flip-inline;
}Popover API: 열기·닫기·포커스 관리를 선언적으로
Popover API는 HTML 속성만으로 팝오버 레이어의 생명주기 전체를 처리합니다. popover 속성이 붙은 요소는 처음엔 숨겨져 있다가, popovertarget으로 연결된 버튼을 클릭하면 열립니다.
<button popovertarget="my-popup">메뉴 열기</button>
<div id="my-popup" popover>
팝오버 내용
</div>브라우저가 자동으로 처리해주는 것들:
| 기능 | 설명 |
|---|---|
| 토글 | 버튼 클릭 시 열기/닫기 자동 처리 |
| Light dismiss | 팝오버 바깥 클릭 시 자동 닫힘 (popover="auto") |
| Esc 키 | 키보드로 닫기 |
| 포커스 반환 | 닫힐 때 트리거 버튼으로 포커스 자동 복귀 |
| Top Layer | z-index 충돌 없이 항상 최상위에 렌더링 |
Top Layer란? 브라우저가 일반 DOM 스택과 별개로 관리하는 렌더링 레이어입니다.
popover와<dialog>요소가 여기에 올라가서, 아무리 복잡한z-index환경에서도 가리지 않고 위에 표시됩니다.overflow: hidden부모가 있어도 문제없습니다.
popover="auto"(기본값)는 다른 팝오버가 열리면 자동으로 닫히는 "하나만 열리는" 동작을 하고, popover="manual"은 JavaScript로 직접 제어할 때 씁니다.
실전 적용
예시 1: JavaScript 없는 컨텍스트 메뉴
실무에서 자주 맞닥뜨리는 상황인데, 카드 컴포넌트 오른쪽 상단의 "더보기" 버튼을 누르면 편집·삭제 메뉴가 뜨는 패턴입니다. 기존엔 포지셔닝 라이브러리 없이는 구현하기 까다로웠죠.
<!-- 앵커 역할: 더보기 버튼 -->
<button
id="menu-btn"
class="ctx-trigger"
popovertarget="ctx-menu"
aria-haspopup="menu"
>
더보기 ···
</button>
<!-- 팝오버 메뉴 -->
<ul
id="ctx-menu"
popover
role="menu"
aria-labelledby="menu-btn"
>
<li role="menuitem"><a href="#">편집</a></li>
<li role="menuitem"><a href="#">복제</a></li>
<li role="menuitem" class="danger"><a href="#">삭제</a></li>
</ul>/* anchor-name은 외부 CSS에 선언해야 DevTools Anchor Inspector에서 관계가 시각적으로 표시됩니다 */
.ctx-trigger {
anchor-name: --ctx-btn;
}
#ctx-menu {
/* 팝오버 기본 스타일 초기화 */
margin: 0;
padding: 4px 0;
list-style: none;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
min-width: 160px;
/* 앵커 포지셔닝 */
position: absolute;
position-anchor: --ctx-btn;
position-area: bottom span-right;
margin-block-start: 4px;
position-try-fallbacks: flip-block;
/* 열림 애니메이션 — 시작값은 @starting-style에서 정의 */
opacity: 0;
transform: translateY(-4px);
transition: opacity 0.15s, transform 0.15s, display 0.15s allow-discrete;
}
#ctx-menu:popover-open {
opacity: 1;
transform: translateY(0);
}
/* display: none → block 전환 시 브라우저가 참조할 시작 스타일 */
@starting-style {
#ctx-menu:popover-open {
opacity: 0;
transform: translateY(-4px);
}
}| 코드 포인트 | 역할 |
|---|---|
popovertarget="ctx-menu" |
버튼과 팝오버를 연결. 브라우저가 열림/닫힘 상태를 추적하며, 일부 보조 기술에서 자동 인식 |
aria-haspopup="menu" |
스크린 리더에 이 버튼이 메뉴를 열 것임을 미리 알림 |
role="menu" + aria-labelledby |
스크린 리더에 팝오버가 메뉴임을 선언하고, 어떤 버튼과 연관됐는지 연결 |
role="menuitem" |
컨테이너에만 role을 붙이면 부족합니다. 각 항목에도 붙어야 스크린 리더가 메뉴 구조를 올바르게 인식합니다 |
position-area: bottom span-right |
버튼 아래, 버튼 왼쪽에 맞춰 오른쪽으로 펼치기 |
position-try-fallbacks: flip-block |
공간 부족 시 위쪽으로 자동 전환 |
@starting-style |
팝오버가 display: none에서 전환될 때 시작 스타일 정의 |
@starting-style이란?display: none에서block으로 바뀌는 순간처럼, 요소가 처음 렌더링 트리에 합류할 때의 초기 스타일을 정의하는 규칙입니다. 이게 없으면transition이 시작점을 알 수 없어 애니메이션이 동작하지 않습니다. Baseline 2024 기능으로 현재 모든 최신 브라우저에서 지원됩니다.
예시 2: 커스텀 셀렉트 박스 (Customizable Select)
저도 처음엔 헷갈렸는데, 기존 <select> 커스터마이징은 CSS로는 사실상 불가능해서 전부 DIV로 재구현하는 게 관행이었습니다. 그 과정에서 접근성을 놓치거나, 키보드 내비게이션을 직접 구현하느라 고생하곤 했죠.
Chrome 135+에서는 appearance: base-select 하나로 네이티브 <select>를 완전히 스타일링할 수 있습니다.
<select class="custom-select">
<!-- 선택된 값을 표시할 커스텀 버튼 -->
<button>
<selectedcontent></selectedcontent>
</button>
<!-- 드롭다운 옵션 목록 -->
<option value="">국가를 선택해주세요</option>
<option value="kr">
<span class="flag">🇰🇷</span> 한국
</option>
<option value="us">
<span class="flag">🇺🇸</span> 미국
</option>
<option value="jp">
<span class="flag">🇯🇵</span> 일본
</option>
</select><selectedcontent>는 2026년에 새롭게 등장한 HTML 요소입니다. 처음 보면 오타처럼 느껴질 수 있는데, 브라우저가 공식으로 인식하는 신규 요소로, 현재 선택된 <option>의 내용을 실시간으로 미러링합니다. 이모지 국기처럼 텍스트 이상의 마크업도 그대로 반영되기 때문에, 예전처럼 선택값을 별도로 렌더링하는 JavaScript가 필요 없습니다.
.custom-select {
/* 네이티브 셀렉트 스타일 언락 */
appearance: base-select;
}
/* 기본 화살표 아이콘 커스터마이징 */
.custom-select::picker-icon {
content: "▾";
color: #6b7280;
}
/* 드롭다운 피커(목록 컨테이너) 스타일 */
.custom-select::picker(select) {
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
padding: 4px 0;
/* position-area 등 앵커 포지셔닝은 브라우저가 자동 처리 */
}
/* 개별 옵션 스타일 */
.custom-select option {
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.custom-select option:hover,
.custom-select option:checked {
background: #eff6ff;
color: #1d4ed8;
}
/* Firefox, Safari 미지원 환경에서 기본 네이티브 셀렉트로 폴백 */
@supports not (appearance: base-select) {
.custom-select {
appearance: auto;
/* 최소한의 폴백 예시입니다. 커스텀 border, border-radius 등을 사용했다면
여기서 함께 되돌려야 실제 네이티브처럼 보입니다 */
}
}내부적으로 Popover API와 Anchor Positioning이 드롭다운 배치를 담당하지만, 개발자는 이를 신경 쓸 필요가 없습니다. 다만 현재 Chrome 135+에서만 지원되므로 Firefox나 Safari 사용자를 위한 폴백 처리는 필요합니다.
예시 3: 스크롤 컨테이너 안의 툴팁
스크롤 가능한 컨테이너 안에 트리거가 있는 경우, position: absolute를 쓰면 overflow: hidden 부모에 의해 팝업이 잘릴 수 있습니다. 이런 상황엔 position: fixed를 사용하면 뷰포트 기준으로 계산되어 항상 제대로 표시됩니다.
솔직하게 미리 말씀드리면, 이 예시만큼은 소량의 JavaScript가 필요합니다. 툴팁은 보통 마우스를 올렸을 때(hover) 나타나는데, popover="manual" 모드는 JS 없이는 열 수 없고, CSS :hover로 Popover API를 트리거할 수도 없거든요. 대신 CSS Anchor Positioning이 위치 계산을 전담하기 때문에, 기존의 getBoundingClientRect() + scroll 이벤트 조합보다 훨씬 단순해집니다.
<span
class="tooltip-trigger"
aria-describedby="my-tooltip"
tabindex="0"
>
도움말 <span aria-hidden="true">ⓘ</span>
</span>
<!-- popover="manual": JS가 직접 열고 닫는 모드 -->
<div id="my-tooltip" role="tooltip" class="tooltip" popover="manual">
이 필드는 필수 입력 항목입니다.
</div>.tooltip-trigger {
anchor-name: --tooltip-anchor;
}
.tooltip {
position: fixed; /* overflow 부모 무시, 뷰포트 기준 배치 */
position-anchor: --tooltip-anchor;
position-area: top center;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
background: #1e293b;
color: #f8fafc;
padding: 6px 10px;
border-radius: 6px;
font-size: 0.875rem;
max-width: 240px;
margin-block-end: 6px;
opacity: 0;
transition: opacity 0.15s, display 0.15s allow-discrete;
}
.tooltip:popover-open {
opacity: 1;
}
@starting-style {
.tooltip:popover-open {
opacity: 0;
}
}// hover 트리거 — JS는 딱 이 신호 역할만 합니다
const trigger = document.querySelector('.tooltip-trigger');
const tooltip = document.getElementById('my-tooltip');
trigger.addEventListener('mouseenter', () => tooltip.showPopover());
trigger.addEventListener('mouseleave', () => tooltip.hidePopover());
trigger.addEventListener('focus', () => tooltip.showPopover());
trigger.addEventListener('blur', () => tooltip.hidePopover());위치 계산, 뷰포트 넘침 처리, 방향 전환은 모두 CSS가 담당하고 JS는 딱 열고 닫는 신호만 보냅니다. 기존 방식보다 코드량이 눈에 띄게 줄어든다는 걸 체감하실 수 있을 겁니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 번들 크기 절감 | Popper.js(~3KB gzip), Floating UI(~5KB gzip) 등 포지셔닝 라이브러리를 제거할 수 있어 초기 로드 시간이 줄어듭니다 |
| 브라우저 내장 접근성 | popovertarget 연결만으로 Esc 닫기, 포커스 반환이 자동 처리됩니다 |
| 자동 오버플로 대응 | position-try-fallbacks로 뷰포트 경계에서의 flip·slide 동작을 CSS 선언 하나로 해결합니다 |
| Top Layer 렌더링 | DOM 위치나 z-index 환경과 무관하게 항상 최상위에 표시되어 스택 충돌 문제가 사라집니다 |
| 성능 | 포지셔닝 계산이 JavaScript 루프 대신 브라우저 레이아웃 엔진에서 처리되어 스크롤·리사이즈 성능이 좋습니다 |
직접 써보면서 생각보다 아쉬웠던 것들도 있었습니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 접근성 완결성은 개발자 몫 | 브라우저가 Esc와 포커스 반환은 처리하지만, role="menu", aria-haspopup, role="menuitem" 등 ARIA 구조는 직접 명시해야 합니다 |
ARIA APG 메뉴 패턴을 참고해 필요한 속성을 꼼꼼히 추가합니다 |
| 키보드 내비게이션 미지원 | 방향키로 메뉴 항목 이동, Tab으로 메뉴 탈출 등 ARIA 메뉴 패턴 요구사항은 소량의 JS가 여전히 필요합니다 | 순수 CSS 범위가 어디까지인지 명확히 인식하고, 필요한 부분만 최소한으로 JS를 추가합니다 |
| Customizable Select 지원 미완 | appearance: base-select는 Chrome 135+에서만 지원됩니다 |
@supports로 폴백 처리하거나, 기존 JS 셀렉트 라이브러리를 점진적 향상 전략으로 병행합니다 |
| 우클릭 컨텍스트 메뉴 불가 | 마우스 우클릭 이벤트에 반응하는 진짜 컨텍스트 메뉴는 contextmenu 이벤트 리스너가 필요합니다 |
CSS Anchor Positioning은 위치 배치만 담당한다는 점을 기억합니다 |
| 애니메이션 추가 설정 필요 | 팝오버 진입·퇴장 애니메이션을 위해 @starting-style과 allow-discrete 키워드를 별도로 작성해야 합니다 |
예시 코드에서 제시한 패턴을 기본 보일러플레이트로 활용합니다 |
실무에서 가장 자주 마주쳤던 건 접근성 완결성 항목이었습니다. 브라우저가 상당 부분을 자동으로 해준다는 기대에 ARIA 속성을 빠뜨리기 쉬운데, 스크린 리더로 직접 테스트해보면 금세 눈에 띄는 부분입니다.
실무에서 가장 흔한 실수
anchor-name을 인라인 스타일로 선언하기 —style="anchor-name: --my-anchor"같은 인라인 선언도 동작은 하지만, 외부 CSS에서 선언해야 Chrome DevTools Anchor Positioning Inspector에서 앵커 관계가 시각적으로 표시되어 디버깅이 훨씬 편해집니다.- 팝오버를
role="menu"로만 선언하고 항목에role="menuitem"빠뜨리기 — 스크린 리더가 메뉴로 인식하려면 컨테이너뿐 아니라 각 항목에도 올바른 role이 있어야 합니다. position: fixedvsabsolute선택 실수 — 스크롤 가능한 부모나overflow: hidden컨테이너 안에 트리거가 있다면position: fixed를 사용해야 팝업이 잘리지 않습니다.absolute는 팝업이 최상위 레이어에 올라가더라도 초기 포함 블록 계산에서 문제가 생길 수 있습니다.
마치며
세 가지 예시를 직접 만들어보면서, "위치 계산은 브라우저에게, 접근성 구조 선언은 개발자에게"라는 역할 분담이 이 두 기술의 핵심이라는 걸 체감했습니다. 포지셔닝 라이브러리가 대신 해주던 가장 귀찮은 부분을 CSS 선언 몇 줄이 대체하면서, 컴포넌트 코드가 눈에 띄게 단순해집니다. 커스터마이저블 셀렉트는 아직 크로스 브라우저 대응이 필요하지만, 컨텍스트 메뉴는 지금 당장 프로덕션에 적용해볼 수 있는 수준입니다.
지금 바로 시작해볼 수 있는 3단계:
- 내 프로젝트의 브라우저 통계를 확인해보기 — Analytics 도구에서 방문자 브라우저 버전 분포를 살펴보세요. Chrome 125+, Firefox 147+, Safari 26 이상의 비율이 충분하다면 도입을 검토해볼 수 있습니다.
@supports (anchor-name: --x) {}로 감싸면 미지원 브라우저에서 기존 구현이 그대로 유지됩니다. - 기존 프로젝트의 툴팁 하나를 교체해보기 — 이미 Floating UI나 직접 구현한 JS 툴팁이 있다면, 그 중 가장 단순한 것 하나를
anchor-name+position-area+popover조합으로 바꿔보시면 좋습니다. 전후 코드량 차이가 바로 보입니다. - ARIA APG 메뉴 패턴 체크리스트 한 번 살펴보기 — ARIA Authoring Practices Guide의 Menu 패턴에서 CSS로 커버되는 부분과 JS가 여전히 필요한 부분을 구분하는 기준을 잡아두시면, 프로젝트 요구사항에 맞는 현실적인 구현 범위를 정하는 데 도움이 됩니다.
CSS Anchor Positioning 미지원 환경을 위한 폴리필이 필요하다면 Oddbird의 CSS Anchor Positioning Polyfill을 사용할 수 있습니다. 또는 @supports (anchor-name: --x)로 지원 여부를 감지해 미지원 환경에서만 Floating UI를 로드하는 점진적 향상 전략도 좋은 선택입니다.
참고 자료
- Popover Context Menus with Anchor Positioning | Frontend Masters Blog
- Anchor Positioning and the Popover API for a JS-Free Site Menu | CSS { In Real Life }
- Using CSS anchor positioning | MDN Web Docs
- Introducing the CSS anchor positioning API | Chrome for Developers
- CSS Anchor Positioning Module Level 1 | W3C TR
- First Public Working Draft: CSS Anchor Positioning Module Level 2 | W3C News
- Anchor Positioning Updates for Fall 2025 | OddBird
- Using the Popover API | MDN Web Docs
- Popover API lands in Baseline | web.dev
- Menus, toasts and more with the Popover API, dialog, invokers, anchor positioning | Frontend Masters Blog
- On popover accessibility: what the browser does and doesn't do | hidde.blog
- Let's build an Accessible Menu with Modern Web Features | oidaisdes.org
- The
<select>element can now be customized with CSS | Chrome for Developers - Customizable select elements | MDN Web Docs
- CSS Anchor Positioning Guide | CSS-Tricks
- A gentle introduction to anchor positioning | WebKit Blog
- Invoker Commands Explainer | Open UI