RSS

모바일에서만 가로 스크롤이 생기는 이유

모바일에서 화면을 옆으로 밀었더니 페이지가 밀리고 오른쪽에 빈 여백이 보인다면, 어떤 요소가 뷰포트보다 오른쪽으로 삐져나가 있다는 뜻입니다. 흔한 문제인데 범인을 찾기가 은근히 어렵습니다. 눈에 보이는 요소가 아닌 경우가 많기 때문입니다.

감으로 찾지 않는 법

저는 처음에 플로팅 버튼을 의심했습니다. 화면 오른쪽 아래에 카카오톡 버튼이 떠 있었고, 위치가 딱 문제 영역이었고, 며칠 전에 그 버튼을 손댄 참이었습니다. 셋 다 그럴듯한 이유였는데 전부 틀렸습니다.

넘치는 요소를 그냥 다 뽑으면 목록이 수십 개 나옵니다. 대부분은 조상 요소가 overflow로 잘라주고 있어서 실제로는 문제가 아닙니다. 그래서 클리핑하는 조상이 없는 것만 걸러내야 합니다.

const cw = document.documentElement.clientWidth;

const clipped = el => {
  let p = el.parentElement;
  while (p && p !== document.body) {
    if (/hidden|clip|auto|scroll/.test(getComputedStyle(p).overflowX)) return true;
    p = p.parentElement;
  }
  return false;
};

const out = [];
document.querySelectorAll('body *').forEach(el => {
  const r = el.getBoundingClientRect();
  if (r.width > 0 && r.right > cw + 1 && !clipped(el)) {
    out.push({ c: el.className, right: Math.round(r.right) });
  }
});
out.sort((a, b) => b.right - a.right);
console.log(document.documentElement.scrollWidth, cw, out.slice(0, 10));

브라우저를 모바일 폭으로 줄이고 콘솔에 붙여넣으면 됩니다. 제 경우 결과가 이랬습니다.

clientWidth  374
scrollWidth  411     ← 37px 넘침
범인: .c3d-card  (right: 411)

카카오톡 버튼은 목록에 없었습니다.

3D 변환은 보이는 것보다 넓다

.c3d-card는 3D 캐러셀의 카드였습니다. 카드를 원통형으로 배치해서 돌리는 UI입니다.

<div className="ring">
  {items.map((node, i) => (
    <div className="card"
         style={{ transform: `rotateY(${(360/n)*i}deg) translateZ(${radius}px)` }}>

링 측면에 있는 카드는 거의 옆으로 서 있어서 화면에는 얇은 조각으로 보입니다. 그런데 투영된 박스는 좌우로 크게 뻗습니다. 눈에 보이는 폭과 레이아웃이 차지하는 폭이 다릅니다.

회전이나 스케일이 들어간 요소는 다 마찬가지입니다. 원인을 못 찾겠으면 이런 요소부터 getBoundingClientRect()로 실제 폭을 재보는 게 빠릅니다.

body의 overflow-x를 믿으면 안 되는 이유

여기서 의아한 점이 있었습니다. 프로젝트에는 이미 방어가 걸려 있었습니다.

body {
  overflow-x: hidden;   /* clip 미지원 환경 폴백 */
  overflow-x: clip;
}

크롬은 이 값을 뷰포트로 전파해서 가로 스크롤을 막아줍니다. 실제로 크롬에서 window.scrollTo(200, y)를 호출해도 scrollX가 0으로 유지됐습니다. 그래서 데스크톱에서는 아무 문제가 없어 보였습니다.

iOS 사파리에서는 그렇지 않았습니다. 넘침이 그대로 가로 스크롤로 이어졌습니다.

body나 html에 overflow-x: hidden을 걸어두고 안심하는 경우가 많은데, 그건 크롬 기준의 안전망입니다. 최종 방어선으로 삼으면 안 됩니다. 넘침은 생기는 자리에서 잘라야 합니다.

hidden 말고 clip

그래서 캐러셀 뷰포트에 직접 클립을 걸었습니다.

.carousel-viewport {
  perspective: 1100px;
  position: relative;
  overflow: clip;      /* hidden 아님 */
}

hidden이 아니라 clip을 쓴 데는 이유가 있습니다. overflowvisible이나 clip 이외의 값이면 그 요소의 transform-style: preserve-3d가 flat으로 강제됩니다. 3D 연출이 들어간 컨테이너에 hidden을 걸면 입체감이 사라질 수 있습니다. clip은 스크롤 컨테이너를 만들지 않아서 그런 부작용이 없고 sticky 자식에도 영향을 주지 않습니다. 사파리는 16부터 지원합니다.

결과는 scrollWidth가 411에서 382로 줄었습니다. 정면 카드가 잘리지 않는지도 확인했습니다. 정면 카드가 75~299, 클립 박스가 20~354라 여유가 있었고, 옆면 카드만 좌우로 23px씩 덜 보이는데 원래 화면 밖에 있던 부분이라 손실이 없었습니다.

100vw는 스크롤바를 포함한다

남은 382도 짚고 넘어갈 만합니다. 풀블리드 배경에 이런 패턴을 자주 씁니다.

.full-bleed {
  position: absolute;
  left: 50%;
  width: 100vw;
  margin-left: -50vw;
}

100vw는 데스크톱 클래식 스크롤바 폭을 포함합니다. 제 경우 15px이었고, 그래서 이 배경이 항상 좌우로 조금씩 넘쳤습니다. 모바일은 오버레이 스크롤바라 100vwclientWidth와 같으므로 실제 문제는 아닙니다.

다만 넘침을 측정하는 동안 이게 계속 노이즈로 껴서 헷갈렸습니다. scrollWidth를 재고 있다면 이 8px 남짓한 차이가 어디서 오는지 미리 알아두는 게 좋습니다.

정리

가로 스크롤이 생기면 감으로 찾지 말고 클리핑 조상이 없는 넘침 요소만 걸러내는 스니펫을 돌립니다. 3D나 회전 변환이 들어간 요소는 보이는 폭과 레이아웃 폭이 다르다는 걸 기억합니다. 그리고 body의 overflow-x는 크롬에서만 확실한 방어입니다.

여기 나온 수치는 크롬에서 잰 것입니다. 실기기 확인은 별도로 하는 게 맞습니다.


같은 작업에서 겪은 다른 문제: