CSS Anchor Positioning으로 툴팁·드롭다운·팝오버 위치를 CSS만으로 선언하기
프론트엔드를 하다 보면 "이거 그냥 CSS로 되면 안 되나?" 싶은 순간이 자주 옵니다. 특히 툴팁·드롭다운을 만들 때 그렇습니다. 버튼 옆에 뭔가 띄우려는데 스크롤 위치 계산하고, 뷰포트 끝에서 뒤집어야 하고, z-index 전쟁까지 붙으면 결국 Floating UI 같은 라이브러리를 집어들게 됩니다.
그런데 그 질문에 이제는 "네, 됩니다"라고 답할 수 있습니다. CSS Anchor Positioning은 Safari 18.2가 지원을 추가한 2024년 12월에 Baseline Newly Available에 진입했고, 2026년 8월 기준 Chrome·Edge, Firefox 131+, Safari 18.2+에서 안정적으로 동작합니다 (MDN anchor-name Browser compatibility). 정확한 커버리지는 Can I Use — anchor-positioning에서 최신 수치를 확인할 수 있습니다.
이 글에서는 핵심 API를 짚고, 툴팁·드롭다운·팝오버 같은 UI 패턴을 JavaScript 없이 선언적으로 구현하는 방법을 코드와 함께 살펴봅니다. 그리고 아직 완벽하지는 않으니, 어떤 상황에서 라이브러리를 여전히 붙여야 하는지도 같이 짚어보겠습니다.
왜 지금까지 JS가 필요했을까
기존 방식의 구조적 문제
CSS position: absolute나 fixed는 부모 요소나 뷰포트를 기준으로만 배치됩니다. "저 버튼 바로 아래에 붙이고 싶다"는 요구를 CSS만으로는 표현할 방법이 없었습니다. DOM 트리 어딘가에 있는 다른 요소를 기준으로 위치를 잡으려면 JavaScript로 좌표를 계산해 직접 주입하는 수밖에 없었죠.
Popper.js와 Floating UI가 해결한 것도 이 문제입니다. getBoundingClientRect()로 앵커 위치를 읽고, 뷰포트 경계를 체크하고, 필요한 시점에 재계산합니다. 유용하지만 비용이 따릅니다. 재계산 로직이 메인 스레드에서 돌고, 앵커 관계가 JS에만 존재하므로 CSS 수정이 JS 수정을 요구하는 경우가 생깁니다.
번들 사이즈는 사용하는 조합에 따라 크게 갈립니다. Floating UI 문서 기준으로 @floating-ui/dom 자체는 gzip 몇 KB 수준이지만, React 바인딩(@floating-ui/react)과 미들웨어를 모두 채용하면 그보다 커집니다. 정확한 수치는 Bundlephobia에서 조합별로 확인하는 편이 안전합니다.
Floating UI는 최신 버전에서 autoUpdate가 ResizeObserver·IntersectionObserver를 조합해 재계산을 최소화합니다. 예전처럼 스크롤 프레임마다 무조건 계산이 도는 건 아니지만, 여전히 좌표 계산과 style 반영이 JS 레이어에서 일어난다는 사실 자체는 그대로입니다.
CSS Anchor Positioning의 접근
CSS Anchor Positioning은 이 책임을 브라우저 엔진으로 옮깁니다. CSS에서 "이 요소는 저 요소의 어느 방향에 붙인다"고 선언하면, 좌표 계산과 뷰포트 오버플로우 감지를 브라우저가 처리합니다. JS가 개입할 필요가 없습니다.
다만 "임의의 요소에 붙일 수 있다"는 말은 반쯤만 진실입니다. 앵커 요소가 overflow: hidden 스크롤 컨테이너 안에 있거나 조상에 transform·filter가 걸려 있으면 컨테이닝 블록·클리핑 컨텍스트가 달라져서 결과가 직관과 어긋날 수 있습니다. 이 얘기는 뒤에서 다시 다룹니다.
핵심 API 뜯어보기
기본 연결: anchor-name과 position-anchor
두 가지 속성으로 앵커 관계가 성립됩니다.
.trigger-btn {
anchor-name: --my-btn;
}
.tooltip {
position: fixed;
position-anchor: --my-btn;
}anchor-name의 값은 반드시 -- 접두사가 붙는 dashed-ident 형식입니다. CSS 커스텀 프로퍼티와 문법이 닮았지만 별개의 네임스페이스입니다.
position: fixed를 선택하는 데는 이유가 있습니다. 앵커 포지셔닝은 DOM 부모-자식 관계와 무관하게 작동해야 편리하고, Popover API가 요소를 top-layer로 승격시키기 때문에 fixed와 잘 맞습니다. 다만 조상에 transform·filter·perspective 같은 컨테이닝 블록 유발 속성이 걸려 있으면 position: fixed가 뷰포트 대신 그 조상을 기준으로 잡히는 함정이 있습니다. Popover API 요소는 top-layer로 올라가면서 이 문제를 우회하는데, 이 조합이 앵커 포지셔닝이 특히 빛나는 지점입니다.
anchor() 함수로 위치 지정
앵커의 특정 엣지를 기준으로 위치를 잡을 때는 anchor() 함수를 씁니다.
.tooltip {
position: fixed;
position-anchor: --my-btn;
top: anchor(bottom);
left: anchor(left);
}anchor(--my-btn bottom)처럼 앵커 이름을 명시할 수도 있지만, position-anchor로 기본 앵커를 지정해두면 생략 가능합니다.
position-area: 3×3 격자로 직관적 배치
매번 top: anchor(bottom) 같은 표현식을 쓰는 게 번거롭다면 position-area가 편합니다. 앵커를 중심으로 두고 상·하·좌·우 각 축을 start/center/end로 나눈 3×3 격자에서 배치 영역을 지정합니다.
.tooltip {
position: fixed;
position-anchor: --my-btn;
position-area: top; /* 앵커 위쪽 한 칸 */
/* position-area: bottom; 앵커 아래쪽 한 칸 */
/* position-area: inline-start; 앵커 왼쪽 한 칸 */
}한 축에 span-* 키워드를 붙이면 여러 칸에 걸쳐 확장할 수 있습니다. 예를 들어 block-end span-inline-start는 "블록 방향으로는 앵커 아래쪽, 인라인 방향으로는 앵커의 inline-start 쪽으로 확장"이라는 의미이고, 결과적으로 앵커 왼쪽 아래에 걸쳐 있는 세로로 긴 드롭다운 배치가 됩니다. 자세한 값 목록은 MDN position-area를 참고하세요.
논리 속성(block-start, inline-end 등)과 조합하면 RTL 지원도 자연스럽게 따라옵니다.
주의: 스펙 초안 단계에서
inset-area가position-area로 이름이 바뀌었습니다. 오래된 튜토리얼에inset-area가 남아 있는데, 현재 이름은position-area입니다.
@position-try: 뷰포트 밖으로 나가면 자동으로 대체
Floating UI의 flip 미들웨어가 하던 역할을 CSS로 선언할 수 있습니다.
.tooltip {
position: fixed;
position-anchor: --my-btn;
position-area: top;
position-try-fallbacks: bottom, inline-start, inline-end;
}position-try-fallbacks에 나열된 값들을 순서대로 시도해서, 뷰포트를 벗어나지 않는 첫 번째 위치를 사용합니다. 더 복잡한 대체 스타일이 필요하면 @position-try at-rule로 정의합니다.
@position-try --flip-to-bottom {
position-area: bottom;
margin-top: 8px;
margin-bottom: 0;
}
.tooltip {
position: fixed;
position-anchor: --my-btn;
position-area: top;
margin-bottom: 8px;
position-try-fallbacks: --flip-to-bottom;
}@position-try와 position-try-fallbacks는 Chromium 계열에 먼저 안착했고, Safari와 Firefox의 지원 시점은 엔진별로 차이가 있습니다. 최신 상태는 MDN @position-try Browser compatibility와 MDN position-try-fallbacks에서 확인하는 것을 권합니다. 이 글을 쓰는 시점(2026년 8월)에도 엣지 케이스의 렌더링이 브라우저마다 미묘하게 다를 수 있으니, 프로덕션에 붙이기 전에 대상 브라우저에서 실제로 뒤집혀 배치되는지 눈으로 확인해두는 편이 안전합니다.
실전: UI 패턴별 구현
툴팁
가장 기본적인 패턴입니다. :hover나 :focus-visible과 조합하면 완성됩니다.
<button class="icon-btn" aria-describedby="tip">
<span aria-hidden="true">⭐</span>
</button>
<div class="tooltip" id="tip" role="tooltip">즐겨찾기에 추가</div>.icon-btn {
anchor-name: --icon-btn;
}
.tooltip {
position: fixed;
position-anchor: --icon-btn;
position-area: top;
position-try-fallbacks: bottom;
margin-bottom: 8px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.icon-btn:hover + .tooltip,
.icon-btn:focus-visible + .tooltip {
opacity: 1;
}opacity: 0 + pointer-events: none을 쓰는 이유는 두 가지입니다. 첫째, display: none → display: block 전환은 트랜지션이 걸리지 않습니다. 둘째, visibility: hidden 대신 opacity를 쓰면 접근성 트리에는 요소가 남아 있어서 aria-describedby로 툴팁 내용을 참조하는 스크린 리더 사용자에게 정보가 전달됩니다. 대신 실제로 안 보이는 시점에는 반드시 pointer-events: none으로 마우스 이벤트를 막아야 합니다. display/content-visibility 기반 트랜지션이 필요하다면 transition-behavior: allow-discrete와 조합하는 방법도 있습니다.
뷰포트 상단에 버튼이 있으면 position-try-fallbacks: bottom이 발동해 툴팁이 아래로 뒤집힙니다.
드롭다운 메뉴 (Popover API 조합)
Popover API와 함께 쓸 때 진가가 나옵니다. Escape 닫기, 라이트 디스미스, top-layer 스태킹, 포커스 관리가 전부 브라우저 몫이 됩니다.
<button popovertarget="menu" class="nav-btn">메뉴 열기</button>
<ul id="menu" popover class="dropdown-menu">
<li><a href="#">프로필</a></li>
<li><a href="#">설정</a></li>
<li><a href="#">로그아웃</a></li>
</ul>.nav-btn {
anchor-name: --nav-btn;
}
.dropdown-menu {
position: fixed;
position-anchor: --nav-btn;
/* 앵커 아래쪽, 인라인 방향으로는 왼쪽으로 확장 */
position-area: block-end span-inline-start;
min-width: anchor-size(width);
margin-top: 4px;
padding: 4px 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
list-style: none;
}anchor-size(width)는 트리거 버튼의 너비를 그대로 읽어 드롭다운 최소 너비로 사용합니다. 버튼 너비가 바뀌어도 자동으로 따라옵니다. 다만 anchor-size()는 anchor-name과 지원 시점이 다를 수 있어서, 프로덕션 대상 브라우저에서는 MDN anchor-size() Browser compatibility를 별도로 확인하는 편이 좋습니다. 미지원 브라우저에서는 앵커 배치는 되지만 min-width가 무시되어 예상보다 좁게 나올 수 있습니다.
popover 속성이 붙은 요소는 브라우저 top-layer에 올라가므로 z-index 충돌 걱정이 없습니다.
top-layer 승격·라이트 디스미스·포커스 트래핑은 Popover API 담당이고, 좌표 계산·오버플로우 대체는 Anchor Positioning 담당입니다. 두 스펙이 겹치지 않게 각자 책임 영역을 나눠 갖는 점이 조합의 이점입니다.
폼 에러 메시지
입력 필드 옆에 붙는 에러 메시지도 앵커 포지셔닝으로 처리할 수 있습니다.
<div class="field-wrap">
<input type="email" id="email" class="input-field" aria-describedby="email-error" />
<p id="email-error" class="error-msg" role="alert">올바른 이메일 형식이 아닙니다.</p>
</div>.input-field {
anchor-name: --email-input;
}
.error-msg {
position: fixed;
position-anchor: --email-input;
position-area: inline-end;
margin-inline-start: 8px;
width: max-content;
max-width: 200px;
font-size: 0.875rem;
color: #e53e3e;
}모바일처럼 오른쪽 공간이 부족하면 position-try-fallbacks: block-end를 추가해서 아래로 내려가게 할 수 있습니다.
브라우저 지원과 점진적 향상
flowchart LR
A[앵커 포지셔닝 사용 시도] --> B{브라우저 지원?}
B -->|Chromium 계열| C[전체 기능 동작]
B -->|Firefox 131+| D[핵심 배치 동작]
B -->|Safari 18.2+| E[핵심 배치 동작]
B -->|미지원 브라우저| F[폴백 스타일 적용]
D --> G[@position-try 등 세부 기능은 최신 정보 확인]
E --> G
F --> H[정적 배치나 폴리필 검토]@supports로 점진적 향상
/* 폴백: 앵커 포지셔닝을 지원하지 않는 환경에서는 정적으로 배치 */
.tooltip {
position: static;
display: inline-block;
margin-inline-start: 8px;
}
@supports (anchor-name: --test) {
.tooltip {
position: fixed;
position-anchor: --my-btn;
position-area: top;
position-try-fallbacks: bottom;
margin: 0 0 8px;
}
}폴백에서 요소를 감추고 싶다면 display: none을 쓸 수는 있지만, 그러면 미지원 브라우저 사용자에게 정보 자체가 사라집니다. 정적 배치로 그냥 흐름에 노출시키거나, 별도의 JS 툴팁 폴백을 붙이는 편이 대체로 안전합니다.
구형 브라우저까지 완전히 커버해야 한다면 OddBird CSS Anchor Positioning Polyfill을 검토할 수 있습니다.
트레이드오프: 언제 쓰고 언제 라이브러리를 쓸까
| 상황 | CSS Anchor Positioning | Floating UI |
|---|---|---|
| 툴팁, 드롭다운, 팝오버 | 추천 | 과잉일 수 있음 |
| 마우스 커서 기반 위치 | 불가 | 필요 |
| 구형 브라우저 필수 지원 | 폴리필 필요 | 적합 |
| 스크롤 컨테이너 클리핑이 복잡한 경우 | 제약 있음 | 더 유연 |
| 번들 사이즈 민감한 프로젝트 | 유리 | 사용 조합에 따라 추가 부담 |
| 좌표 계산 위치 | 브라우저 엔진 | JS 레이어 |
제가 지금 신규 프로젝트를 시작한다면 CSS Anchor Positioning을 기본으로 두고, 커서 기반 위치나 레거시 브라우저 지원이 필요한 케이스에서만 Floating UI를 얹는 방향을 택할 것 같습니다. Popper.js는 공식 사이트와 GitHub 리포지토리에서 후속인 Floating UI로 마이그레이션할 것을 안내하고 있으니, 신규 도입은 굳이 권할 이유가 없습니다.
흔한 실수와 디버깅 팁
display: contents와의 충돌
anchor-name이 붙은 요소를 display: contents로 설정하면 앵커가 잡히지 않습니다. 앵커 요소는 실제 레이아웃 박스를 가져야 합니다.
containing block 함정
position: absolute + position-anchor 조합은 앵커와 포지셔닝된 요소가 같은 containing block 안에 있어야 예상대로 동작합니다. 의도치 않은 위치로 튄다면 fixed로 바꿔서 확인해보세요. 앞에서 언급한 조상 transform/filter/perspective 함정도 같은 결의 문제입니다.
DevTools 활용
Chrome DevTools Elements 패널은 앵커 연결 관계를 시각적으로 표시해줍니다. 어긋난 위치가 보이면 DOM에서 어떤 앵커가 실제로 매칭되고 있는지, 어떤 fallback이 선택됐는지를 먼저 확인하는 것이 시간을 아끼는 길입니다.
마무리
핵심은 Popover API가 top-layer·라이트 디스미스·포커스 관리를 맡고, CSS Anchor Positioning이 좌표와 오버플로우 대체를 맡는 조합입니다. 이 두 스펙이 만나는 지점에서, 지금까지 프레임워크가 컴포넌트로 감춰왔던 "떠 있는 UI"의 구조적 복잡도가 브라우저 플랫폼 레이어로 내려옵니다. 위치 로직이 CSS에 선언적으로 존재하니 디자인 수정 때 JS를 함께 손댈 필요도 없고, 라이브러리 API 릴리스 사이클과 무관하게 스펙 자체가 업그레이드됩니다. 브라우저에 위임할 수 있는 것을 브라우저에 맡기는 방향으로 프론트엔드 UI 코드가 다시 얇아지는 흐름이고, 툴팁·드롭다운은 그 첫 번째 사례가 될 것 같습니다.
참고 자료
- MDN — Using CSS anchor positioning
- MDN — anchor-name
- MDN — position-anchor
- MDN — position-area
- MDN — @position-try
- MDN — position-try-fallbacks
- MDN — anchor-size()
- MDN — Popover API
- Can I Use — CSS Anchor Positioning
- Floating UI Documentation
- Popper.js — Migration to Floating UI
- OddBird CSS Anchor Positioning Polyfill