CSS Anchor Positioning으로 Popper.js 제거하기 — 브라우저가 직접 처리하는 플로팅 UI
프론트엔드를 하다 보면 어느 순간 이런 생각을 하게 됩니다. "버튼 아래에 드롭다운 하나 붙이는 게 왜 이렇게 복잡하지?" 몇 년 전 팀 내 디자인 시스템을 처음 만들 때, Floating UI 셋업과 뷰포트 플립 로직 디버깅에 하루를 통째로 날린 적이 있었습니다. 지금 돌아보면 그 복잡함이 당연한 게 아니었는데 말이죠.
이 복잡함의 근본 원인은 사실 CSS 자체에 있었습니다. CSS는 "저 버튼 바로 아래에 요소를 붙여줘"라는 관계를 직접 표현하는 방법이 없었거든요. 그래서 Popper.js 같은 라이브러리들이 자바스크립트로 위치를 계산하고, 스크롤이나 창 크기 변화를 감지해서 실시간으로 재계산하고, 화면 경계에 부딪히면 반대쪽으로 뒤집는 처리를 전부 대신해줬던 겁니다. 필요한 도구였지만, 번들에 얹히는 무게와 런타임 비용이 작지는 않았죠.
2026년 현재, CSS Anchor Positioning이 Baseline 2026을 달성하면서 Chrome, Firefox, Safari 최신 버전 모두에서 네이티브 지원됩니다. 전 세계 브라우저 트래픽의 83~91%를 커버하는 환경에서, 자바스크립트가 계산하던 영역을 브라우저 레이아웃 엔진이 직접 처리하는 시대가 왔습니다. 이 글에서는 CSS Anchor Positioning이 어떻게 동작하는지, Popper.js가 하던 일을 어떻게 대체할 수 있는지, 그리고 여전히 JS가 필요한 케이스는 어떤 상황인지 실제 코드와 함께 살펴보겠습니다.
핵심 개념
CSS Anchor Positioning이 해결하는 문제
문제의 본질부터 짚어보겠습니다. 기존 CSS position: absolute는 "가장 가까운 position 지정 조상"을 기준으로 배치됩니다. position: fixed는 뷰포트가 기준이죠. 둘 다 "DOM 트리에서 멀리 떨어진 특정 요소를 기준으로 배치"하는 방법은 없었습니다.
그래서 Popper.js나 Floating UI는 런타임에 이런 과정을 거쳤습니다.
- 트리거 요소의
getBoundingClientRect()호출로 화면 좌표 계산 - 계산된 값으로 플로팅 요소를
position: fixed+transform으로 배치 - 스크롤하거나 창 크기가 바뀔 때마다
ResizeObserver로 감지해서 재계산 - 플로팅 요소가 화면 밖으로 나가면 반대쪽으로 뒤집기(flip)
CSS Anchor Positioning은 이 과정 전체를 선언적인 CSS로 표현하고, 계산은 브라우저 레이아웃 엔진이 담당합니다.
CSS Anchor Positioning: 어떤 요소를 다른 특정 요소(앵커)에 상대적으로 배치할 수 있는 CSS 네이티브 API. 자바스크립트 위치 계산 없이, 선언적 CSS만으로 툴팁·드롭다운·팝오버 같은 플로팅 UI를 구현할 수 있다.
핵심 속성
솔직히 처음 스펙을 읽었을 때 속성 이름들이 낯설게 느껴졌는데, 쓰다 보면 의도가 명확해서 금방 익숙해집니다.
| 속성 / 함수 | 역할 |
|---|---|
anchor-name |
요소를 앵커로 등록 (--my-btn 형태의 dashed-ident 값) |
position-anchor |
플로팅 요소가 참조할 앵커 지정 |
position-area |
앵커 주위 9-grid 중 어느 영역에 배치할지 선언 |
anchor() 함수 |
top: anchor(--btn bottom)처럼 앵커의 특정 모서리를 정밀 참조 |
anchor-size() |
width: anchor-size(width)처럼 앵커의 크기를 직접 참조 |
position-try-fallbacks |
뷰포트 밖으로 나갈 때 시도할 fallback 배치 목록 |
@position-try |
각 fallback 배치를 정의하는 규칙 |
position-visibility |
앵커가 화면 밖으로 벗어나면 플로팅 요소를 자동으로 숨김 |
anchor-scope |
반복 컴포넌트에서 앵커 이름 충돌을 방지하는 격리 속성 |
anchor-name의 -- 접두사는 CSS 커스텀 프로퍼티(--my-color: blue)와 같은 네이밍 규칙입니다. 전역 네임스페이스에서 충돌을 피하기 위한 설계여서, 한 번 익숙해지면 자연스럽습니다.
position-area의 9-grid 모델
position-area는 앵커를 중심에 놓고 주변 9칸 그리드에서 배치 위치를 선언합니다.
┌─────────────┬───────────────┬─────────────┐
│ top left │ top center │ top right │
├─────────────┼───────────────┼─────────────┤
│ left │ (앵커) │ right │
├─────────────┼───────────────┼─────────────┤
│ bottom left │ bottom center │ bottom right│
└─────────────┴───────────────┴─────────────┘position-area: top center라고 쓰면 앵커 바로 위 중앙에 배치됩니다. 처음 봤을 때 "이게 왜 지금 나온 거지?"라는 생각이 들 만큼 직관적입니다.
position-area 대신 anchor() 함수로 더 정밀하게 제어할 수도 있습니다. 앵커의 특정 모서리 값을 직접 참조하는 방식입니다.
.tooltip {
position: absolute;
/* 앵커 하단 모서리에서 시작, 앵커 왼쪽 모서리에 정렬 */
top: anchor(--my-btn bottom);
left: anchor(--my-btn left);
margin-top: 8px;
}대부분의 경우는 position-area로 충분하지만, 비대칭 배치나 커스텀 오프셋이 필요한 상황에서 anchor()가 빛을 발합니다.
알아두면 좋은 제약
코드 예시를 보기 전에 하나 짚고 넘어가겠습니다. CSS Anchor Positioning이 만능은 아닙니다. 실무에서 가장 자주 맞닥뜨리는 제약이 하나 있습니다.
overflow: hidden이나 clip-path가 걸린 부모 요소 아래에 앵커가 있으면, 플로팅 요소도 그 부모에 의해 잘립니다. 기존 position: absolute와 동일한 동작인데, Popper.js가 position: fixed로 DOM 최상단에 띄워서 이 문제를 우회했던 것과는 다릅니다. 스크롤 가능한 컨테이너 안에 앵커가 있다면 이 점을 특히 주의하는 게 좋습니다.
장단점 분석
장점
써보면서 직접 느낀 장점들을 정리했습니다.
| 항목 | 내용 |
|---|---|
| 성능 | 브라우저 레이아웃 엔진이 처리하므로 JS 실행 비용 없음. 스크롤해도 재계산 루프 없음 |
| 번들 크기 | 모던 브라우저는 0KB 추가. 레거시 브라우저만 ~8KB 폴리필 |
| 의존성 | npm 패키지 불필요, 설정 코드 없음 |
| 선언성 | 배치 의도가 CSS에 명확히 드러나서 유지보수가 편함 |
| 접근성 | popover 속성과 조합 시 포커스 관리, aria 처리 기본 제공 |
| 레이어 관리 | popover 속성을 쓰면 top-layer*에 자동 배치되어 z-index 충돌 없음 |
* top-layer: 브라우저가 관리하는 가장 위의 렌더링 레이어입니다. z-index와 무관하게 항상 최상단에 표시되며, 모달·다이얼로그·popover 요소들이 여기에 올라갑니다.
단점 및 주의사항
써보면서 실제로 걸린 부분들을 솔직하게 정리했습니다.
| 항목 | 설명 | 대응 방안 |
|---|---|---|
overflow 클리핑 |
클리핑이 걸린 부모 안의 앵커는 플로팅 요소도 함께 잘림 | position: fixed 기반 Floating UI 병용 |
| Safari 부분 지원 | Safari 18.2–18.3은 기본 배치는 되지만 @position-try 미지원 |
Safari 26+ 환경 보장 또는 OddBird 폴리필 조건부 적용 |
| 호버 툴팁 크로스-브라우저 | popover="hint"(마우스 호버 시 열리는 팝오버)가 Chrome에서만 지원 |
호버 툴팁은 여전히 JS 필요 |
| Shadow DOM* 경계 | Shadow DOM 내부에서 외부 앵커를 참조하는 건 제한적 | OddBird 폴리필로 일부 해결 가능 |
| 가상화 리스트 | 앵커 요소가 DOM에서 언마운트되는 가상 스크롤 환경 | 이 경우 Floating UI 유지 권장 |
anchor-name 중복 |
컴포넌트 반복 렌더링 시 같은 이름이면 마지막 요소만 앵커로 인식 | anchor-scope 사용 또는 동적 고유 이름 주입 |
| 다단계 서브메뉴 | 지연 로딩 서브메뉴 등 복잡한 다단계 구조 | JS 조율 병행 필요 |
* Shadow DOM: 웹 컴포넌트 기술 중 하나로, 독립된 DOM 트리를 만들어 외부 스타일이나 스크립트의 영향을 차단합니다. 커스텀 엘리먼트나 <video> 같은 기본 HTML 요소가 내부적으로 Shadow DOM을 씁니다.
position-visibility:position-visibility: anchors-visible로 설정하면 앵커가 스크롤로 화면 밖을 벗어났을 때 플로팅 요소가 자동으로 숨겨집니다. Popper.js의hide미들웨어에 해당하는 CSS 네이티브 기능입니다.
anchor-scope: 같은anchor-name을 가진 요소가 여러 개일 때 앵커 참조 범위를 격리하는 속성. React나 Vue에서 컴포넌트를 반복 렌더링할 때 발생하는 이름 충돌을 해결합니다. CSS Anchor Positioning Level 2 스펙에 포함됩니다.
실무에서 가장 흔한 실수
-
anchor-name중복: Vue의v-for나 React의.map()으로 컴포넌트를 반복 렌더링할 때, CSS 클래스에anchor-name: --tooltip을 하드코딩해두면 마지막 렌더링 요소만 앵커로 인식됩니다. 인덱스나 고유 ID를 인라인 스타일로 주입하거나anchor-scope를 활용하는 편이 좋습니다. -
position: absolute생략: 플로팅 요소에position: absolute(또는fixed)가 없으면position-anchor와position-area가 아무 동작도 하지 않습니다. 처음 셋업할 때 이걸 빠뜨려서 왜 안 되는지 한참 디버깅하다가 결국 이것 하나 때문이었던 경우가 많습니다. -
Safari 18.x에서
@position-try기대: Safari 26+ 이전 버전은@position-try를 조용히 무시합니다. 프로덕션에서 아이폰 사용자가 "드롭다운이 화면 밖으로 나갔어요"를 신고하는 상황이 생길 수 있습니다. OddBird 폴리필을 조건부로 로드하거나 Floating UI를 폴백으로 병용하는 구조를 권장합니다.
실전 적용
예시 1: 기본 툴팁 — Popper.js 코드를 CSS 6줄로
Popper.js 코드와 나란히 비교해보면 차이가 확 옵니다.
기존 Popper.js 방식:
import { createPopper } from '@popperjs/core';
const button = document.querySelector('#btn');
const tooltip = document.querySelector('#tooltip');
const popperInstance = createPopper(button, tooltip, {
placement: 'top',
modifiers: [
{
name: 'offset',
options: { offset: [0, 8] },
},
],
});CSS Anchor Positioning 방식:
/* 앵커 등록 */
.btn {
anchor-name: --my-btn;
}
/* 플로팅 요소 배치 */
.tooltip {
position: absolute;
position-anchor: --my-btn;
position-area: top center;
margin-bottom: 8px;
}| 항목 | Popper.js | CSS Anchor Positioning |
|---|---|---|
| 코드 라인 | JS 10줄+ | CSS 6줄 |
| 번들 포함 여부 | 항상 (~12KB) | 불필요 |
| 런타임 계산 | 매 렌더링마다 | 브라우저 레이아웃 엔진 |
| 의존성 | npm 패키지 | 없음 |
예시 2: 뷰포트 경계 자동 반전 — @position-try
솔직히 이 기능이 가장 인상적이었습니다. Popper.js의 flip 미들웨어가 하던 일을 @position-try가 순수 CSS로 처리합니다.
/* 기본 배치: 버튼 위 중앙 */
.dropdown {
position: absolute;
position-anchor: --my-trigger;
position-area: top center;
margin-bottom: 8px;
position-try-fallbacks: --flip-below, --flip-left;
}
/* 위에 공간이 없으면 아래로 */
@position-try --flip-below {
position-area: bottom center;
margin-bottom: 0; /* 기본 배치의 margin-bottom 초기화 */
margin-top: 8px;
}
/* 아래도 안 되면 왼쪽으로 */
@position-try --flip-left {
position-area: left span-bottom;
margin-bottom: 0;
margin-right: 8px;
}각 @position-try 블록 안에서 기본 배치의 margin-bottom을 0으로 초기화하는 부분을 빠뜨리기 쉬운데, 이 줄이 없으면 이전 규칙의 여백이 그대로 남아서 배치가 어긋납니다. 브라우저가 fallback을 순서대로 시도하면서 뷰포트 안에 들어오는 배치를 자동으로 선택하기 때문에, 각 케이스의 margin은 완전히 독립적으로 정의해두는 편이 안전합니다.
position-try-fallbacks: 기본 배치가 뷰포트를 벗어날 때 브라우저가 순서대로 시도할 대안 배치 목록. Popper.js의flip미들웨어에 해당하는 CSS 네이티브 기능.
예시 3: Popover API 조합 — 완전한 JS-free 드롭다운
HTML popover 속성과 조합하면 열기/닫기 토글, 외부 클릭 닫기(light dismiss), Escape 키 닫기까지 자바스크립트 0줄로 구현됩니다.
<button popovertarget="menu" class="menu-btn">
메뉴 열기
</button>
<ul id="menu" popover>
<li>프로필</li>
<li>설정</li>
<li>로그아웃</li>
</ul>/* 앵커를 CSS 클래스로 등록 */
.menu-btn {
anchor-name: --menu-btn;
}
#menu {
position: absolute;
position-anchor: --menu-btn;
position-area: bottom span-right;
/* 버튼과 동일한 너비 — Popper.js의 sameWidth 미들웨어 역할 */
width: anchor-size(width);
/* 앵커가 스크롤로 화면 밖에 나가면 팝오버도 자동 숨김 */
position-visibility: anchors-visible;
margin-top: 4px;
list-style: none;
padding: 8px 0;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
}popover 속성이 붙은 요소는 자동으로 top-layer에 올라가므로 z-index 관리도 필요 없습니다. 앵커 이름을 인라인 스타일이 아닌 CSS 클래스로 등록한 것도 의도적인 선택인데, 이 예시처럼 단일 메뉴 버튼에는 CSS 클래스 방식이 더 깔끔합니다. 인라인 스타일 방식은 컴포넌트를 반복 렌더링할 때 고유 이름을 동적으로 주입해야 하는 상황에 한정해서 씁니다.
예시 4: 등장 애니메이션 — @starting-style과 조합 (보너스)
이 예시는 Popper.js 대체와 직접적인 관련은 없지만, Popover API와 조합했을 때 덤으로 얻을 수 있는 기능입니다. @starting-style을 더하면 등장 애니메이션까지 JS 없이 처리됩니다.
#menu {
/* ... anchor positioning 코드 ... */
opacity: 1;
transform: translateY(0);
transition:
opacity 0.15s ease,
transform 0.15s ease,
display 0.15s allow-discrete, /* ① */
overlay 0.15s allow-discrete; /* ② */
}
/* 등장 전 초기 상태 */
@starting-style {
#menu:popover-open {
opacity: 0;
transform: translateY(-4px);
}
}
/* 사라질 때 */
#menu:not(:popover-open) {
opacity: 0;
transform: translateY(-4px);
}① display: 0.15s allow-discrete — display는 원래 트랜지션이 적용되지 않는 이산(discrete) 값인데, allow-discrete를 붙이면 트랜지션 시작·끝 시점에 값이 바뀌는 걸 허용합니다. 이 줄이 없으면 팝오버가 애니메이션 없이 깜빡 나타납니다.
② overlay: 0.15s allow-discrete — 팝오버가 top-layer에서 제거되는 타이밍을 트랜지션이 끝난 후로 미룹니다. 이 줄이 없으면 닫힐 때 사라지는 애니메이션이 실행되지 않고 즉시 숨겨집니다.
마치며
CSS Anchor Positioning은 Popper.js가 해결해주던 문제의 대부분을 브라우저 레이아웃 엔진이 직접 처리하면서, npm 의존성과 런타임 계산 비용을 동시에 줄이는 실질적인 대안이 됐습니다.
처음 이 API를 접했을 때 "이게 진짜로 되는 거야?" 싶었는데, 써보고 나서 Popper.js를 처음 도입했던 때와 비슷한 느낌을 받았습니다. "이걸 왜 이제야 알았지."
지금 바로 시작해볼 수 있는 3단계:
-
브라우저 지원 확인 및 폴리필 추가:
CSS.supports('anchor-name', '--x')로 네이티브 지원 여부를 체크할 수 있습니다. 아직 Safari 26+ 미만 사용자 비율이 높다면pnpm add @oddbird/css-anchor-positioning으로 폴리필을 추가해두는 걸 권장합니다. 모던 브라우저에는 로드하지 않도록 위CSS.supports()조건으로 조건부 import를 구성하면 성능 영향도 최소화됩니다. -
기존 툴팁 하나를 CSS로 교체해보기: 프로젝트에서 가장 단순한 툴팁 하나를 골라서 Popper.js 코드를 제거하고,
anchor-name+position-anchor+position-area세 속성으로 교체해보시면 감이 빠르게 옵니다. Chrome DevTools의 Elements 패널에서 앵커 연결 상태를 시각적으로 확인할 수 있어 디버깅도 편합니다. -
Popover API와 조합해 드롭다운 JS 제거:
<button popovertarget="...">과popover속성을 활용해 열기/닫기 로직을 HTML로 위임하고,position-area로 배치를 CSS로 처리하면 드롭다운 관련 JS 코드가 눈에 띄게 줄어드는 걸 체감할 수 있습니다.
참고 자료
- Introducing the CSS anchor positioning API | Chrome for Developers
- Using CSS anchor positioning | MDN Web Docs
- CSS Anchor Positioning Guide | CSS-Tricks
- Anchor Positioning Updates for Fall 2025 | OddBird
- OddBird CSS Anchor Positioning Polyfill | GitHub
- First Public Working Draft: CSS Anchor Positioning Module Level 2 | W3C
- Dropdown Menus with HTML Popovers and CSS Anchor Positioning | Cory Rylan
- CSS Anchor Positioning and the Popover API for a JS-Free Site Menu | CSS { In Real Life }