createPortal로 z-index 스택 컨텍스트 문제 해결하기

react

이슈를 해결해보자

배경

브라우저의 <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,
);