createPortal로 z-index 스택 컨텍스트 문제 해결하기
이슈를 해결해보자
배경
브라우저의 <video> 태그는 기본 전체화면 기능을 제공한다.
하지만 일부 영상에서 전체화면 진입 시 화면이 자동으로 rotate되는 문제가 있었다.
개발자 도구를 열어 뷰포트가 줄어든 상태에서는 rotate가 발생하지 않았다.
이를 통해 CSS로 가짜 전체화면을 만들면 문제를 우회할 수 있을 거라 판단했다.
그렇게 가짜 전체 화면을 만들던 중 문제가 발생하는데..
비디오 플레이어가 모달(Dialog) 안에 있다. 모달 내부에 있는 비디오 플레이어의 확대 버튼을 누르면 전체화면으로 비디오를 보여줘야 한다.
<Dialog>
<DialogContent className="z-50 max-h-[90vh]">
<div className="overflow-y-hidden">
<VideoPlayer />
</div>
</DialogContent>
</Dialog>
function VideoPlayer() {
const [isExpanded, setIsExpanded] = useState(false);
return (
<>
<video src={videoUrl} />
<button onClick={() => setIsExpanded(true)}>확대</button>
{isExpanded && <FullscreenVideoPlayer />}
</>
);
}
Dialog에 가려지길래 FullscreenVideoPlayer의 z-index를 더 키우면 될 거라 생각했다. 그런데 안 된다.
왜 안 되는가?
z-index 스택 컨텍스트
Dialog가 z-50이면, 그 안의 자식 요소가 아무리 z-100을 줘도 Dialog의 스택 컨텍스트 안에 갇힌다.
즉, FullscreenVideoPlayer의 z-index를 아무리 높여도 Dialog 바깥 요소 위로 올라올 수 없으며, 결과적으로 여전히 Dialog에 가려진다.
해결: createPortal
React의 createPortal은 컴포넌트를 원하는 DOM 위치에 렌더링한다. Dialog 안에서 렌더링하는 게 아니라, document.body에 직접 붙이면 모든 CSS 제약에서 벗어난다.
import { createPortal } from "react-dom";
function VideoPlayer() {
const [isExpanded, setIsExpanded] = useState(false);
return (
<>
<video src={videoUrl} />
<button onClick={() => setIsExpanded(true)}>확대</button>
{/* 확대 시 FullscreenVideoPlayer를 body에 렌더링 */}
{isExpanded &&
createPortal(
<FullscreenVideoPlayer
videoUrl={videoUrl}
onClose={() => setIsExpanded(false)}
/>,
document.body,
)}
</>
);
}
function FullscreenVideoPlayer({ videoUrl, onClose }) {
return (
<div className="fixed inset-0 z-100 bg-black">
<button onClick={onClose}>축소</button>
<video src={videoUrl} className="w-full h-full" />
</div>
);
}
이제 확대된 비디오는 Dialog와 무관하게 viewport 전체를 덮는다.
주의할 점
pointer-events 처리
Portal로 렌더링해도 클릭 이벤트가 뒤의 요소로 전달될 수 있다. 전체화면 레이어를 클릭했는데 뒤에 있는 Dialog overlay가 클릭되어 모달이 닫혀버리는 문제가 발생할 수 있다.(필자는 실제로 이 문제를 겪었다.)
pointer-events-auto로 이벤트를 가로채야 한다.
createPortal(
<div className="fixed inset-0 z-100 bg-black pointer-events-auto">
{/* pointer-events-auto: 클릭이 뒤로 전달되지 않음 */}
</div>,
document.body,
);