cd ../
Frontend·2026-03-29·15 min read·# entry/029

Tooltip은 왜 가려질까 — Portal과 getBoundingClientRect로 설계하는 스타일 불변 툴팁

overflow, transform, z-index stacking context가 툴팁을 가리는 원리를 분석하고, createPortal과 getBoundingClientRect를 이용해 어떤 부모 구조에서도 동일한 형태를 유지하는 툴팁을 설계하는 방법을 다룹니다.

툴팁은 UI에서 가장 흔하게 쓰이는 컴포넌트 중 하나지만, 의외로 "망가지기 쉬운" 컴포넌트이기도 합니다.

단순한 정적 페이지에서는 position: absolute와 CSS ::before만으로도 잘 동작합니다. 하지만 실제 앱에서는 카드 안에, 모달 안에, 애니메이션 컨테이너 안에 들어가면서 조용히 잘리거나, 다른 요소 뒤로 숨거나, 예상치 못한 위치에 나타나는 문제가 생깁니다.

이 글은 툴팁이 가려지는 세 가지 근본 원인을 분석하고, 어떤 부모 구조를 갖든 항상 같은 형태로 렌더되는 툴팁을 설계하는 방법을 다룹니다.


1. 툴팁이 보이지 않는 세 가지 원인

원인 1 — overflow: hidden

position: absolute인 요소는 가장 가까운 positioned 조상을 기준으로 배치됩니다. 그런데 그 조상이 overflow: hidden을 갖고 있으면, 박스 경계를 벗어나는 모든 자식은 잘립니다.

┌───────────────────────────────┐  ← overflow: hidden
│                               │
│  [버튼]                        │
│    ┌──────────────────┐       │
│    │ ✂ 잘린 툴팁       │       │
│ ─ ─│─ ─ ─ ─ ─ ─ ─ ─ ─│─ ─ ─ │  ← 이 선 아래는 보이지 않음
└───────────────────────────────┘
가장 흔한 함정: border-radius + overflow: hidden

border-radius가 적용된 카드 컴포넌트는 모서리 처리를 위해 overflow: hidden을 함께 쓰는 경우가 많습니다. 그 안에 들어간 툴팁이 소리 없이 잘리는 이유가 바로 이것입니다.

원인 2 — transform이 만드는 containing block 함정

overflow: hidden을 피하기 위해 position: fixed로 바꾸는 것은 좋은 시도입니다. 하지만 한 가지 함정이 더 있습니다.

CSS 명세에 따르면, transform / filter / will-change / perspective 속성이 있는 요소는 새로운 containing block을 형성합니다. position: fixed인 자식 요소는 viewport가 아닌 이 요소를 기준으로 배치됩니다.

transform: translateX(0)처럼 아무런 시각적 변화가 없는 선언 하나가, 하위 모든 position: fixed 요소의 기준점을 바꿔버립니다.

왜 아무 변화가 없어도 containing block이 생기나요?

CSS 명세는 transform 프로퍼티가 none이 아닌 값을 가지면 무조건 새 containing block을 생성하도록 정의합니다. 브라우저 렌더러가 transform을 GPU 레이어로 처리하기 때문에, 실제 변형값과 무관하게 별도 컨텍스트가 만들어집니다.

다크모드 전환 애니메이션, 슬라이드 인/아웃, 카드 hover lift 효과 — 모두 transform을 사용하기 때문에 이 함정에 빠지기 쉽습니다.

원인 3 — z-index Stacking Context

z-index같은 stacking context 안에서만 비교됩니다. 서로 다른 stacking context에 속한 요소들의 z-index는 직접 비교가 불가능합니다.

속성Stacking Context 생성 조건
positionrelative/absolute/fixed/sticky + z-indexauto가 아닐 때
opacity1 미만
transformnone이 아닐 때
filternone이 아닐 때
isolationisolate
will-changetransform, opacity 등을 값으로 가질 때
Stacking Context 생성 조건 전체 목록 (CSS 명세 기준)

MDN 기준으로 Stacking Context를 생성하는 모든 조건은 다음과 같습니다.

  • position: fixed 또는 sticky (z-index 값 무관)
  • position: relative/absolute + z-indexauto가 아닐 때
  • opacity < 1
  • transformnone
  • filternone
  • perspectivenone
  • clip-pathnone
  • mask / mask-image / mask-border
  • isolation: isolate
  • mix-blend-modenormal
  • will-change에 위 속성 중 하나가 포함될 때
  • contain: layout / paint / strict / content
  • backdrop-filternone

생각보다 많습니다. 모던 CSS 애니메이션을 쓰다 보면 자연스럽게 생성됩니다.

z-index: 9999의 한계

높은 z-index는 같은 stacking context 안에서만 효과가 있습니다. 모달 오버레이가 z-index: 100인 stacking context를 만들었다면, 그 안의 툴팁은 z-index: 9999여도 모달 외부의 z-index: 200 요소 뒤에 숨을 수 있습니다.


2. 세 문제를 한 번에 해결하는 방법: Portal

세 원인 모두 공통된 해결책이 있습니다. 툴팁을 DOM 트리에서 탈출시키는 것입니다.

React의 createPortal은 컴포넌트를 원래 부모 트리가 아닌 다른 DOM 노드에 렌더합니다. 보통 document.body를 대상으로 사용합니다.

TooltipPortal.tsx
import { createPortal } from "react-dom";

function TooltipPortal({ children }: { children: React.ReactNode }) {
// document.body 직하에 마운트 → 어떤 조상 스타일에도 영향받지 않음
return createPortal(children, document.body);
}

document.body 직하에 마운트된 요소는 다음 세 가지가 보장됩니다.

1

overflow 탈출

document.body의 직접 자식이므로, 앱 내부의 어떤 overflow: hidden 조상도 적용되지 않습니다.

2

transform 탈출

body 위에 transform을 가진 조상이 없으므로, position: fixed가 진짜 viewport 기준으로 동작합니다.

3

z-index 독립

앱 레이어 구조와 별개의 stacking context에서 렌더되므로, z-index 값이 실제로 의도한 대로 동작합니다.

이벤트 버블링은 DOM이 아닌 React 트리를 따릅니다

Portal로 document.body에 렌더되어도, React의 이벤트 버블링은 가상 DOM 트리(fiber 트리) 를 따릅니다. Portal 내부의 클릭 이벤트는 원래 부모 컴포넌트까지 버블링됩니다. ESC 키로 툴팁을 닫는 핸들러를 부모에 붙여도 정상적으로 동작하는 이유입니다.


3. getBoundingClientRect()로 위치 계산하기

Portal로 렌더된 툴팁은 document.body 소속이기 때문에 트리거 요소의 위치를 CSS로 알 수 없습니다. JS로 좌표를 계산해 주입해야 합니다.

getBoundingClientRect()는 DOM 요소의 뷰포트 기준 좌표를 반환하는 메서드입니다.


viewport (브라우저 창)
┌────────────────────────────────────────┐
│ │
│ ↑ rect.top (px) │
│ ┌──────────────────┐ │
│ │ │ rect.height │
│ │ element │ │
│ └──────────────────┘ │
│ ↓ rect.bottom │
│ │
│ ←rect.left→ ←rect.right→ │
│ ←── rect.width ───→ │
└────────────────────────────────────────┘

반환되는 DOMRect 객체의 주요 프로퍼티:

프로퍼티의미
top뷰포트 상단 ↔ 요소 상단 거리
left뷰포트 좌측 ↔ 요소 좌측 거리
width요소 너비
height요소 높이
bottomtop + height
rightleft + width

position: fixed도 뷰포트 기준으로 배치되기 때문에, getBoundingClientRect()의 좌표를 바로 left, top에 사용할 수 있습니다. 스크롤 오프셋을 따로 더할 필요가 없습니다.

문서 절대 좌표가 필요하다면

position: absolute 기반 배치처럼 페이지 전체 기준의 절대 좌표가 필요하다면 스크롤 오프셋을 더해야 합니다.

const absoluteTop  = rect.top  + window.scrollY;
const absoluteLeft = rect.left + window.scrollX;

트리거 중앙 위에 배치하는 좌표 공식

x = rect.left + rect.width / 2     y = rect.top

툴팁을 트리거 요소 수평 중앙 위에 배치하는 계산식
useTooltip.ts
function useTooltip() {
  const [pos, setPos] = useState({ x: 0, y: 0, visible: false });

const show = useCallback((e: React.MouseEvent<HTMLElement>, text: string) => {
const rect = e.currentTarget.getBoundingClientRect();

    setPos({
      x: rect.left + rect.width / 2,  // 수평 중앙
      y: rect.top,                    // 요소 상단 (툴팁은 transform으로 위로 올림)
      visible: true,
    });

}, []);

const hide = useCallback(
() => setPos(p => ({ ...p, visible: false })),
[]
);

return { pos, show, hide };
}

좌표를 fixed 요소에 적용할 때는 transform으로 수평 중앙 정렬과 위쪽 배치를 동시에 처리합니다.

<div style={{
  position: "fixed",
  left: pos.x,
  top: pos.y,
  transform: "translate(-50%, -100%)",  // 수평 중앙 + 요소 위로 이동
  marginTop: "-8px",                    // 요소와의 여백
  pointerEvents: "none",                // 툴팁이 hover를 가로채지 않도록
}} />

4. 뷰포트 경계 감지 — Flip Logic

화면 상단에 있는 요소에 툴팁을 올리면 뷰포트 밖으로 잘려나갈 수 있습니다. 공간이 부족하면 반대 방향으로 flip하는 로직이 필요합니다.

1

상단 여유 공간 측정

rect.top과 툴팁 높이를 비교합니다. 툴팁 높이는 tooltipRef.current?.offsetHeight으로 측정하거나 고정값을 사용합니다.

const TOOLTIP_HEIGHT = 36;
const GAP = 8;
const hasSpaceAbove = rect.top > TOOLTIP_HEIGHT + GAP;
2

배치 방향(placement) 결정

const placement: "top" | "bottom" = hasSpaceAbove ? "top" : "bottom";
3

방향에 따라 y 좌표 분기

const y = placement === "top"
  ? rect.top               // 요소 위 → transform으로 올림
  : rect.bottom + GAP;     // 요소 아래 → 그대로 내림
4

좌우 경계 클램핑

툴팁이 좌우로 넘어가지 않도록 뷰포트 너비 기준으로 x를 클램핑합니다.

const TOOLTIP_WIDTH = 200;
const x = Math.max(
  TOOLTIP_WIDTH / 2,
  Math.min(rawX, window.innerWidth - TOOLTIP_WIDTH / 2)
);
5

화살표 방향도 함께 전환

placement 상태를 툴팁 컴포넌트에 전달해 화살표의 border-color 방향을 조건부로 변경합니다.


5. ref 없이 위치를 얻는 방법

Portal 기반 툴팁에서 트리거 요소의 위치를 얻기 위해 반드시 ref가 필요하지는 않습니다.

onMouseEnter 이벤트가 발생한 순간, e.currentTarget이 이벤트를 받은 요소 자신입니다. 그 시점에 getBoundingClientRect()를 호출하면 됩니다.

<button
  onMouseEnter={(e) => {
    const rect = e.currentTarget.getBoundingClientRect();
    show(rect, "툴팁 텍스트");
  }}
  onMouseLeave={hide}
>
  hover me
</button>

이벤트 핸들러 안에서는 이 방식으로 충분합니다. ref 없이 위치를 계산할 수 있습니다.

Intersection Observer vs scroll 이벤트 — 언제 무엇을 쓸까?

툴팁 재배치에 scroll 이벤트를 쓰는 것은 괜찮지만, 요소의 가시성 감지에는 Intersection Observer가 더 적합합니다.

scroll 이벤트Intersection Observer
발화 빈도매 픽셀마다교차 시점에만
스레드메인 스레드브라우저 최적화 비동기
용도위치 재계산, 스크롤 진행률가시성 감지, TOC 활성화, 레이지 로딩

트리거가 화면 밖으로 스크롤되면 툴팁을 닫는 로직은 Intersection Observer로 구현하는 것이 성능상 유리합니다.


6. 스타일 불변성 설계 원칙

Portal로 DOM 위치 문제를 해결했더라도, 툴팁의 스타일이 부모에 오염되지 않도록 설계해야 합니다.

CSS의 color, font-size, line-height, font-family 등은 자식에게 상속됩니다. Portal로 body에 마운트하면 대부분의 앱 스타일을 탈출하지만, 글로벌 CSS가 body 자식에 적용되는 경우를 대비해 툴팁 루트에 명시적으로 재선언하는 것이 안전합니다.

.tooltip {
  /* 상속될 수 있는 속성들을 명시적으로 재선언 */
  font-family: system-ui, -apple-system, sans-serif;
  font-size: 13px;
  font-weight: 500;
  line-height: 1.5;
  color: #f1f5f9;
  letter-spacing: normal;
  text-align: left;
  text-transform: none;
  white-space: nowrap;
}

7. 컴포넌트 파일 구조

완성된 툴팁 컴포넌트의 파일 구조 예시입니다.

components/tooltip
index.ts
Tooltip.tsx
useTooltip.ts
useTooltipPosition.ts
tooltip.module.css

각 파일의 역할을 분리하면 다음과 같습니다.

파일역할
Tooltip.tsxPortal 렌더러 + 시각적 컴포넌트
useTooltip.ts열림/닫힘 상태, 텍스트 관리
useTooltipPosition.tsgetBoundingClientRect + Flip 로직
tooltip.module.css스타일 격리 (CSS Module)

8. 완성된 구조 정리

1

트리거에서 이벤트 감지

onMouseEnter에서 e.currentTarget.getBoundingClientRect()로 좌표를 읽고 상태에 저장합니다. ref 없이도 충분합니다.

2

Flip 로직으로 배치 방향 결정

상단/하단/좌우 공간을 확인해 placement를 결정합니다. 경계를 넘지 않도록 x를 클램핑합니다.

3

createPortal로 document.body에 렌더

createPortal로 DOM 트리를 탈출합니다. 어떤 조상의 overflow, transform, stacking context에도 영향받지 않습니다.

4

position: fixed + 계산된 좌표 적용

저장한 좌표를 fixed 요소의 left, top에 주입합니다. transform: translate(-50%, -100%)로 중앙 정렬과 방향을 처리합니다.

5

스타일 명시적 선언으로 상속 오염 차단

폰트, 색상, 크기를 px 단위로 명시 선언합니다. z-index는 레이어 상수로 관리합니다.

Portal + getBoundingClientRect 방식의 장점
  • overflow: hidden, transform containing block, stacking context 문제를 모두 해결 - 트리거 요소에 별도 CSS 규칙이 필요 없음 — 어떤 요소에든 붙일 수 있음 - Flip 로직으로 뷰포트 경계 대응 가능 - 스타일을 완전히 격리해 디자인 일관성 보장 - e.currentTarget으로 위치를 읽어 ref 없이도 동작
고려할 점
  • CSS-only 대비 JS 실행이 필요 — 스크롤 중 재계산이 필요하면 이벤트 리스너 추가 - SSR 환경에서 document.body는 서버에 존재하지 않음 → typeof window !== "undefined" 체크 필요 - 요소 크기가 동적으로 바뀌면 위치를 재계산해야 함 (ResizeObserver 활용) - createPortal은 React 트리 안에서만 동작 — React 외부 코드에서는 직접 DOM을 조작해야 함

핵심 요약

정리
  • overflow: hidden 조상은 position: absolute 툴팁을 잘라냅니다. - transform / filter / will-change 조상은 새로운 containing block을 만들어 position: fixed조차 가둡니다. 시각적 변화가 없어도 마찬가지입니다. - z-index 는 같은 stacking context 안에서만 비교됩니다. 높은 값도 다른 context의 낮은 값에 가려질 수 있습니다. - createPortaldocument.body에 직접 마운트해 위 세 문제를 모두 해결합니다. - getBoundingClientRect()는 뷰포트 기준 좌표를 반환하며, position: fixed와 좌표 체계가 일치해 바로 사용할 수 있습니다. - onMouseEntere.currentTarget에서 직접 좌표를 읽으면 ref 없이도 위치 계산이 가능합니다.