팝오버·드롭다운·툴팁은 body 포털로 렌더한다
부모의 overflow·transform·z-index 쌓임 맥락에 갇히면 팝오버가 잘리거나 다른 요소에 가려진다. body에 렌더하고 위치는 직접 계산한다.
모바일에서 드롭다운이 카드 경계에서 잘리거나, z-index를 아무리 올려도 다른 요소 뒤에 가려진다.
팝오버를 여는 버튼 옆에 그리면 부모의 영향을 그대로 받는다. 부모에 overflow: hidden이 있으면 잘리고, transform이나 z-index가 있는 부모는 새 쌓임 맥락을 만들어 그 안의 z-index가 바깥과 비교되지 않는다.
팝오버는 document.body에 포털로 렌더하고, 위치는 여는 요소의 getBoundingClientRect()로 계산한다. 창 크기 변경, 스크롤(어느 컨테이너에서 일어나든 잡히게 캡처 단계로), 요소 크기 변경 때 다시 잡는다.
import { createPortal } from 'react-dom';
import { useLayoutEffect, useState, type RefObject, type ReactNode } from 'react';
function Popover({ anchor, children }: { anchor: RefObject<HTMLElement>; children: ReactNode }) {
const [pos, setPos] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
const place = () => {
const r = anchor.current!.getBoundingClientRect();
setPos({ top: r.bottom + 4, left: Math.min(r.left, window.innerWidth - 280) });
};
place();
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true); // 캡처: 안쪽 스크롤도 잡는다
return () => {
window.removeEventListener('resize', place);
window.removeEventListener('scroll', place, true);
};
}, [anchor]);
return createPortal(
<div className="popover" style={{ position: 'fixed', ...pos }}>{children}</div>,
document.body,
);
}
챙길 것: 화면 가장자리에서는 반대쪽으로 뒤집고 화면 안으로 끌어들인다. 바깥 클릭으로 닫을 때는 포털 내부와 여는 버튼을 함께 빼야 한다(DOM 트리가 떨어져 있다). 모바일 폭에서는 아예 바텀 시트로 바꾸는 쪽이 더 안전하다.