홈으로

문제 상황: Modal 열림 시 화면이 흔들린다

Modal 혹은 BottomSheet 을 열 때 보통 overflow: hidden을 적용해 배경 스크롤을 막는다.
그런데 이 과정에서 화면 전체가 좌우로 미세하게 흔들리는 현상이 발생했다.

Modal Layout Shift

이러한 레이아웃 시프트(Layout Shift)는 사용자에게 불안정한 화면 경험을 주며, 라이트하우스나 코어 웹 바이탈과 같은 성능 지표에도 부정적인 영향을 주기 때문에 개선이 필요하다고 느꼈다.


원인: 스크롤바 폭이 레이아웃에 영향을 준다

브라우저는 기본적으로 스크롤바 폭만큼 콘텐츠 영역을 줄여서 렌더링한다.

모달이 열리면서 <body>overflow: hidden이 적용되면 스크롤바가 사라지고, 콘텐츠 영역에는 그만큼의 폭이 늘어나면서 레이아웃이 이동한다. 모달이 닫히면 다시 스크롤바가 생기고, 그 폭만큼 콘텐츠 영역이 줄어들어 화면이 왼쪽으로 이동한 것처럼 보인다.


환경별 차이: Overlay Scrollbar vs Classic Scrollbar

맥북 내장 디스플레이에서는 layout shift가 감지되지 않았다.
이는 macOS가 기본적으로 overlay scrollbar를 사용하기 때문이다.

overlay scrollbar는 스크롤하는 동안에만 표시되며, 콘텐츠 영역의 공간을 차지하지 않는다.

맥북을 확인해보면 설정 > 화면 모드 > 스크롤 막대 보기 옵션에서 “마우스 또는 트랙패드에 따라 자동으로”가 기본 선택되어 있는데, 이것이 overlay scrollbar 동작 방식이다.

반면, classic scrollbar는 스크롤 트랙이 콘텐츠 영역의 일부를 차지한다.
흥미롭게도, 맥북에 마우스나 트랙패드를 연결하면 classic scrollbar로 전환되는걸 확인할 수 있었다.
이때는 모달을 열고 닫을 때 layout shift가 훨씬 두드러지게 발생했다.

overlay scrollbar vs classic scrollbar — Chrome Dev Docs

즉, 같은 코드라도 디스플레이 환경, 스크롤바 렌더링 방식, OS 설정에 따라 layout shift 체감이 달라진다.


scrollbar-gutter: stable의 한계

scrollbar-gutter / MDN

CSS 속성 scrollbar-gutter: stableoverflow: auto | scroll | hidden 상태에서도 스크롤바 공간을 미리 확보해 layout shift를 방지하도록 설계된 속성이다.

하지만 이 속성만으로는 모든 상황을 해결할 수는 없다.
특히 Modal이나 BottomSheet처럼 <body>overflow: hidden을 적용해 스크롤을 막는 경우, scrollbar-gutter 는 기대한 대로 동작하지 않는다.

그 이유는 overflowscrollbar-gutter가 뷰포트(viewport) 와 상호작용하는 방식이 다르기 때문이다.

overflow의 경우, <html>뿐 아니라 <body>에 설정해도 브라우저가 자동으로 이를 뷰포트까지 전파한다.
그래서 <body>overflow: hidden 설정을 화면 전체 스크롤이 막힌다.

하지만 scrollbar-gutter는 다르다. 이 속성을 <body>에 설정하면 뷰포트로 전파되지 않는다.

즉, 스크롤이 사라지는 건 뷰포트 차원인데, scrollbar-gutter는 그 뷰포트에 적용되지 않으니 결국 스크롤바 공간이 사라지면서 레이아웃이 한쪽으로 밀리는 문제가 발생한다.

이 때문에 실제로는 scrollbar-gutter: stable만으로는 완벽히 해결되지 않으며, 스크롤바 폭을 JS로 계산해 padding-right를 보정하는 방식이 가장 안정적이다.

As for the overflow property, when scrollbar-gutter is set on the root element, the user agent must apply it to the viewport instead, and the used value on the root element itself is scrollbar-gutter: auto. However, unlike the overflow property, the user agent must not propagate scrollbar-gutter from the HTML body element.
scrollbar-gutter-property / CSS Overflow Module Level 3

그렇다면 루트 요소인 <html>scrollbar-gutter: stable를 설정하면 layout shift를 완화할 수 있을까? 꼭 그렇지만은 않다.

이 방식은 아래와 같이 스크롤이 필요 없는 페이지에서도 모달을 열 때 스크롤 영역이 생길 수 있는 문제가 있다.

HTML Scrollbar Gutter

또한, <html>은 문서 전체를 감싸는 루트 역할을 하고, 실제 콘텐츠를 포함하는 영역은 <body>이므로, 레이아웃을 제어할 때 일반적으로 <body>를 대상으로 삼는다.

따라서, <html>scrollbar-gutter: stable를 적용하는 방식은 기술적으로 정확하지 않을 수 있으며, 코드 맥락상 어색하게 느껴질 수 있다.


해결 방안: useLayoutEffect를 사용한 JS 기반 스크롤바 폭 보정

핵심 아이디어는 스크롤바가 사라질 때 콘텐츠 영역에 그 폭만큼 padding-right를 추가해 레이아웃 폭을 유지하는 것이다.

  1. <body>overflow: hidden을 적용할 때 스크롤바 폭을 계산해 padding-right 추가
  2. 고정 포지션(예: Modal) 레이어는 <body>padding-right가 생긴 만큼 margin-left: -scrollbarWidth(스크롤바 영역)로 보정
    • <body>padding-right가 생기면 문서의 중앙 정렬 등이 어긋나므로, 고정 레이어를 반대 방향으로 이동시켜 시각적 정렬을 유지한다.
  3. 보정 로직은 렌더링 전에 적용되어야 하므로 useLayoutEffect에서 실행
    • useLayoutEffect 는 브라우저가 페인트하기 전에 동기적으로 실행되므로, layout shift가 한 프레임이라도 사용자에게 노출되는 것을 방지할 수 있다.
Modal No Layout Shift

아래 코드는 설명을 위해 작성한 예제입니다.

Before

// Modal.tsx (Before)
import { useEffect } from "react";

const lockScroll = () => {
  document.body.style.overflow = "hidden";
};

const unlockScroll = () => {
  document.body.style.overflow = "auto";
  document.body.style.paddingRight = "";
};

export default function Modal() {
  useEffect(() => {
    lockScroll();

    return () => {
      unlockScroll();
    };
  }, []);

  // 이하 Modal 관련 코드
}

After

// Modal.tsx (After)
import { useState, useLayoutEffect } from "react";

// 스크롤바 영역 계산
const computeScrollbarWidth = () => {
  if (typeof window === "undefined") return 0;
  return window.innerWidth - document.documentElement.clientWidth;
};

// body 스크롤 잠금
const lockScroll = (width: number) => {
  if (typeof document === "undefined") return;
  document.body.style.overflow = "hidden";
  if (width > 0) document.body.style.paddingRight = `${width}px`;
};

// body 스크롤 잠금 해제
const unlockScroll = () => {
  if (typeof document === "undefined") return;
  document.body.style.overflow = "";
  document.body.style.paddingRight = "";
};

export default function DemoModal() {
  const [scrollGap, setScrollGap] = useState(0);

  useLayoutEffect(() => {
    const width = computeScrollbarWidth();
    lockScroll(width);
    setScrollGap(width);

    return () => {
      unlockScroll();
      setScrollGap(0);
    };
  }, []);

  return (
    <>
      {/* Modal 컴포넌트 내부에 화면 전체를 덮는 Dimmer 가 있는 경우, 해당 영역은 보정할 필요 없음 */}
      <BackgroundOverlay />
      <ModalWrapper style={{ marginLeft: `-${scrollGap}px` }}>
        {/* 여기서부터 실제 Modal 콘텐츠 */}
        <ModalHeader>제목</ModalHeader>
        <ModalBody>내용</ModalBody>
      </ModalWrapper>
    </>
  );
}

마무리

layout shift 문제는 단순히 CSS 속성 하나로 해결되지 않는 경우가 많다.
특히 <body>의 overflow를 제어해야 하는 Modal, BottomSheet 등의 환경에서는 JS 기반 보정이 실용적이다.

  1. overflow: <body>에서 viewport까지 전파되어 스크롤을 막을 수 있음

  2. scrollbar-gutter:<body>에 설정해도 HTML/viewport로 전파되지 않음

💡 실용적 해결책

Modal, BottomSheet 열릴 때 overflow:hidden으로 인한 Layout Shift 문제