overflow, transform, z-index stacking context가 툴팁을 가리는 원리를 분석하고, createPortal과 getBoundingClientRect를 이용해 어떤 부모 구조에서도 동일한 형태를 유지하는 툴팁을 설계하는 방법을 다룹니다.
툴팁은 UI에서 가장 흔하게 쓰이는 컴포넌트 중 하나지만, 의외로 "망가지기 쉬운" 컴포넌트이기도 합니다.
단순한 정적 페이지에서는 position: absolute와 CSS ::before만으로도 잘 동작합니다.
하지만 실제 앱에서는 카드 안에, 모달 안에, 애니메이션 컨테이너 안에 들어가면서 조용히 잘리거나, 다른 요소 뒤로 숨거나, 예상치 못한 위치에 나타나는 문제가 생깁니다.
이 글은 툴팁이 가려지는 세 가지 근본 원인을 분석하고, 어떤 부모 구조를 갖든 항상 같은 형태로 렌더되는 툴팁을 설계하는 방법을 다룹니다.
overflow: hiddenposition: absolute인 요소는 가장 가까운 positioned 조상을 기준으로 배치됩니다.
그런데 그 조상이 overflow: hidden을 갖고 있으면, 박스 경계를 벗어나는 모든 자식은 잘립니다.
┌───────────────────────────────┐ ← overflow: hidden
│ │
│ [버튼] │
│ ┌──────────────────┐ │
│ │ ✂ 잘린 툴팁 │ │
│ ─ ─│─ ─ ─ ─ ─ ─ ─ ─ ─│─ ─ ─ │ ← 이 선 아래는 보이지 않음
└───────────────────────────────┘
border-radius가 적용된 카드 컴포넌트는 모서리 처리를 위해 overflow: hidden을 함께 쓰는 경우가 많습니다. 그 안에 들어간 툴팁이 소리 없이
잘리는 이유가 바로 이것입니다.
transform이 만드는 containing block 함정overflow: hidden을 피하기 위해 position: fixed로 바꾸는 것은 좋은 시도입니다. 하지만 한 가지 함정이 더 있습니다.
CSS 명세에 따르면, transform / filter / will-change / perspective 속성이 있는 요소는 새로운 containing block을 형성합니다.
position: fixed인 자식 요소는 viewport가 아닌 이 요소를 기준으로 배치됩니다.
“
transform: translateX(0)처럼 아무런 시각적 변화가 없는 선언 하나가, 하위 모든position: fixed요소의 기준점을 바꿔버립니다.
CSS 명세는 transform 프로퍼티가 none이 아닌 값을 가지면 무조건 새
containing block을 생성하도록 정의합니다. 브라우저 렌더러가 transform을 GPU
레이어로 처리하기 때문에, 실제 변형값과 무관하게 별도 컨텍스트가 만들어집니다.
다크모드 전환 애니메이션, 슬라이드 인/아웃, 카드 hover lift 효과 — 모두 transform을 사용하기 때문에 이 함정에 빠지기 쉽습니다.
z-index는 같은 stacking context 안에서만 비교됩니다. 서로 다른 stacking context에 속한 요소들의 z-index는 직접 비교가 불가능합니다.
| 속성 | Stacking Context 생성 조건 |
|---|---|
position | relative/absolute/fixed/sticky + z-index가 auto가 아닐 때 |
opacity | 1 미만 |
transform | none이 아닐 때 |
filter | none이 아닐 때 |
isolation | isolate |
will-change | transform, opacity 등을 값으로 가질 때 |
MDN 기준으로 Stacking Context를 생성하는 모든 조건은 다음과 같습니다.
position: fixed 또는 sticky (z-index 값 무관)position: relative/absolute + z-index가 auto가 아닐 때opacity < 1transform ≠ nonefilter ≠ noneperspective ≠ noneclip-path ≠ nonemask / mask-image / mask-borderisolation: isolatemix-blend-mode ≠ normalwill-change에 위 속성 중 하나가 포함될 때contain: layout / paint / strict / contentbackdrop-filter ≠ none생각보다 많습니다. 모던 CSS 애니메이션을 쓰다 보면 자연스럽게 생성됩니다.
높은 z-index는 같은 stacking context 안에서만 효과가 있습니다. 모달
오버레이가 z-index: 100인 stacking context를 만들었다면, 그 안의 툴팁은
z-index: 9999여도 모달 외부의 z-index: 200 요소 뒤에 숨을 수 있습니다.
세 원인 모두 공통된 해결책이 있습니다. 툴팁을 DOM 트리에서 탈출시키는 것입니다.
React의 createPortal은 컴포넌트를 원래 부모 트리가 아닌 다른 DOM 노드에 렌더합니다. 보통 document.body를 대상으로 사용합니다.
import { createPortal } from "react-dom";
function TooltipPortal({ children }: { children: React.ReactNode }) {
// document.body 직하에 마운트 → 어떤 조상 스타일에도 영향받지 않음
return createPortal(children, document.body);
}
document.body 직하에 마운트된 요소는 다음 세 가지가 보장됩니다.
document.body의 직접 자식이므로, 앱 내부의 어떤 overflow: hidden 조상도 적용되지 않습니다.
body 위에 transform을 가진 조상이 없으므로, position: fixed가 진짜 viewport 기준으로 동작합니다.
앱 레이어 구조와 별개의 stacking context에서 렌더되므로, z-index 값이 실제로 의도한 대로 동작합니다.
Portal로 document.body에 렌더되어도, React의 이벤트 버블링은 가상 DOM 트리(fiber 트리) 를 따릅니다.
Portal 내부의 클릭 이벤트는 원래 부모 컴포넌트까지 버블링됩니다.
ESC 키로 툴팁을 닫는 핸들러를 부모에 붙여도 정상적으로 동작하는 이유입니다.
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 | 요소 높이 |
bottom | top + height |
right | left + 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
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를 가로채지 않도록
}} />
화면 상단에 있는 요소에 툴팁을 올리면 뷰포트 밖으로 잘려나갈 수 있습니다. 공간이 부족하면 반대 방향으로 flip하는 로직이 필요합니다.
rect.top과 툴팁 높이를 비교합니다. 툴팁 높이는 tooltipRef.current?.offsetHeight으로 측정하거나 고정값을 사용합니다.
const TOOLTIP_HEIGHT = 36;
const GAP = 8;
const hasSpaceAbove = rect.top > TOOLTIP_HEIGHT + GAP;
const placement: "top" | "bottom" = hasSpaceAbove ? "top" : "bottom";
const y = placement === "top"
? rect.top // 요소 위 → transform으로 올림
: rect.bottom + GAP; // 요소 아래 → 그대로 내림
툴팁이 좌우로 넘어가지 않도록 뷰포트 너비 기준으로 x를 클램핑합니다.
const TOOLTIP_WIDTH = 200;
const x = Math.max(
TOOLTIP_WIDTH / 2,
Math.min(rawX, window.innerWidth - TOOLTIP_WIDTH / 2)
);
placement 상태를 툴팁 컴포넌트에 전달해 화살표의 border-color 방향을 조건부로 변경합니다.
ref 없이 위치를 얻는 방법Portal 기반 툴팁에서 트리거 요소의 위치를 얻기 위해 반드시 ref가 필요하지는 않습니다.
onMouseEnter 이벤트가 발생한 순간, e.currentTarget이 이벤트를 받은 요소 자신입니다.
그 시점에 getBoundingClientRect()를 호출하면 됩니다.
<button
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
show(rect, "툴팁 텍스트");
}}
onMouseLeave={hide}
>
hover me
</button>
이벤트 핸들러 안에서는 이 방식으로 충분합니다. ref 없이 위치를 계산할 수 있습니다.
툴팁 재배치에 scroll 이벤트를 쓰는 것은 괜찮지만, 요소의 가시성 감지에는 Intersection Observer가 더 적합합니다.
| scroll 이벤트 | Intersection Observer | |
|---|---|---|
| 발화 빈도 | 매 픽셀마다 | 교차 시점에만 |
| 스레드 | 메인 스레드 | 브라우저 최적화 비동기 |
| 용도 | 위치 재계산, 스크롤 진행률 | 가시성 감지, TOC 활성화, 레이지 로딩 |
트리거가 화면 밖으로 스크롤되면 툴팁을 닫는 로직은 Intersection Observer로 구현하는 것이 성능상 유리합니다.
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;
}
완성된 툴팁 컴포넌트의 파일 구조 예시입니다.
각 파일의 역할을 분리하면 다음과 같습니다.
| 파일 | 역할 |
|---|---|
Tooltip.tsx | Portal 렌더러 + 시각적 컴포넌트 |
useTooltip.ts | 열림/닫힘 상태, 텍스트 관리 |
useTooltipPosition.ts | getBoundingClientRect + Flip 로직 |
tooltip.module.css | 스타일 격리 (CSS Module) |
onMouseEnter에서 e.currentTarget.getBoundingClientRect()로 좌표를 읽고
상태에 저장합니다. ref 없이도 충분합니다.
상단/하단/좌우 공간을 확인해 placement를 결정합니다. 경계를 넘지 않도록
x를 클램핑합니다.
createPortal로 DOM 트리를 탈출합니다. 어떤 조상의 overflow, transform,
stacking context에도 영향받지 않습니다.
저장한 좌표를 fixed 요소의 left, top에 주입합니다. transform: translate(-50%, -100%)로 중앙 정렬과 방향을 처리합니다.
폰트, 색상, 크기를 px 단위로 명시 선언합니다. z-index는 레이어 상수로
관리합니다.
overflow: hidden, transform containing block, stacking context 문제를
모두 해결 - 트리거 요소에 별도 CSS 규칙이 필요 없음 — 어떤 요소에든 붙일 수
있음 - Flip 로직으로 뷰포트 경계 대응 가능 - 스타일을 완전히 격리해 디자인
일관성 보장 - e.currentTarget으로 위치를 읽어 ref 없이도 동작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의 낮은 값에 가려질 수 있습니다. -
createPortal 은 document.body에 직접 마운트해
위 세 문제를 모두 해결합니다. - getBoundingClientRect()는 뷰포트 기준 좌표를
반환하며, position: fixed와 좌표 체계가 일치해 바로 사용할 수 있습니다. -
onMouseEnter의 e.currentTarget에서 직접 좌표를 읽으면 ref 없이도 위치
계산이 가능합니다.