본문으로 건너뛰기
wooncloud

모바일 키보드가 모달을 가릴 때 — visualViewport + paddingBottom 한 방

iOS Safari 는 키보드가 떠도 레이아웃 뷰포트를 줄이지 않아 모달이 키보드 뒤에 가려지는데, visualViewport 로 가린 높이를 구해 padding-bottom 에 더하면 정렬을 안 바꾸고도 해결됩니다.

·7 min read·— views··

모바일 웹/웹뷰에서 입력 모달(이름 변경, 새 폴더 같은 작은 프롬프트)을 띄웠을 때, 입력창에 포커스가 가서 소프트 키보드가 올라오면 모달의 입력창·확인 버튼이 키보드 뒤로 깔려 안 보이는 문제가 있습니다. 데스크톱 크롬 DevTools 디바이스 모드로는 재현조차 안 돼서 한참 헤매기 쉽습니다. 정렬 전략을 바꾸지 않고 padding-bottom 하나로 깔끔하게 해결하는 방법을 정리합니다.

왜 가려지나

핵심은 iOS Safari / WKWebView 는 키보드가 떠도 레이아웃 뷰포트를 줄이지 않는다는 점입니다. 줄어드는 건 "시각 뷰포트(visual viewport)"뿐입니다.

그래서 모달 오버레이를 흔히 쓰는 방식으로 잡으면 문제가 생깁니다.

.overlay {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

오버레이는 키보드를 무시한 "전체 화면" 기준으로 그려지고, 가운데 정렬된 모달은 화면 정중앙에 위치합니다. 키보드가 하단 약 40% 를 덮으면 그 뒤로 묻혀버립니다.

게다가 데스크톱 크롬 DevTools 의 디바이스 모드는 OS 소프트 키보드를 띄우지 않습니다. 그래서 visualViewport 가 줄어들지 않고, 이 버그는 실기기(또는 원격 디버깅) 에서만 재현됩니다. 이걸 모르면 "코드는 맞는데 왜 재현이 안 되지" 하며 시간을 버립니다.

해결: 가려진 높이만큼 padding-bottom

window.visualViewport 로 키보드가 가린 하단 높이(inset) 를 구해서, 오버레이의 padding-bottom 에 그만큼 더해줍니다. 그게 전부입니다.

  • align-items: center 모달 → 사용 가능 영역이 위로 좁아지면서 모달이 키보드 위로 올라옵니다
  • align-items: stretch 풀스크린 모달 → 패널이 키보드 위 영역으로 줄어듭니다

즉 정렬 전략을 바꾸지 않고 padding-bottom 하나로 두 케이스를 동시에 해결합니다. 다만 브라우저가 화면을 밀어 올리는 경우(visualViewport.offsetTop 이 0 이 아닌 상황)에는 상단도 같은 값만큼 보정해야 모달 위쪽이 잘리지 않습니다.

inset 계산식은 다음과 같습니다.

inset = 레이아웃 뷰포트 높이 - (시각 뷰포트 높이 + 위로 밀린 양)
      = window.innerHeight - (visualViewport.height + visualViewport.offsetTop)

코드

키보드가 가린 높이를 추적하는 훅입니다. 미지원 환경·데스크톱은 항상 0 을 반환합니다.

import { useEffect, useState } from "react";
 
// 키보드가 가린 화면 하단 높이(px). 미지원/데스크톱은 0.
export function useKeyboardInset() {
  const [inset, setInset] = useState(0);
 
  useEffect(() => {
    const vv = window.visualViewport;
    if (!vv) return;
 
    const update = () => {
      const next = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
      // 주소창 show/hide 같은 미세 변동은 키보드로 오인하지 않게 임계값으로 거른다.
      setInset(next > 80 ? Math.round(next) : 0);
    };
 
    update();
    vv.addEventListener("resize", update);
    vv.addEventListener("scroll", update);
    return () => {
      vv.removeEventListener("resize", update);
      vv.removeEventListener("scroll", update);
    };
  }, []);
 
  return inset;
}

이걸 오버레이의 padding-bottom 에 더합니다.

function Modal({ children }) {
  const kb = useKeyboardInset();
 
  return (
    <div
      style={{
        position: "fixed",
        inset: 0,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        padding: 16,
        paddingBottom: 16 + kb, // ← 키보드만큼 더 → 모달이 위로
        transition: "padding-bottom 0.2s ease",
      }}
    >
      <div style={{ maxHeight: "100%", overflowY: "auto" }}>{children}</div>
    </div>
  );
}

패널에 maxHeight: 100% 와 overflowY: auto 를 같이 주는 게 포인트입니다. 키보드로 영역이 줄어 콘텐츠가 넘쳐도 내부 스크롤로 입력창·버튼에 접근할 수 있습니다.

임계값으로 노이즈 거르기

모바일 주소창의 show/hide 도 시각 뷰포트를 흔듭니다. 그래서 80px 같은 임계값 아래는 0 으로 처리해 키보드만 깔끔하게 잡습니다.

엄밀히 말하면 주소창 변동은 보통 innerHeight 도 함께 변해서 둘의 차이에는 잘 안 잡힙니다. 하지만 기기·브라우저마다 타이밍이 달라 미세 오차가 남을 수 있으니, 임계값은 안전망으로 두는 편이 낫습니다. 키보드는 보통 250px 이상이라 80px 컷에 걸릴 일이 없습니다.

참고

  • 안드로이드 크롬 계열에서는 뷰포트 메타의 interactive-widget=resizes-content 로 레이아웃 자체를 키보드에 맞춰 줄일 수도 있습니다. 다만 iOS 는 미지원이라, 크로스 플랫폼으로는 visualViewport 방식이 더 안정적입니다.
  • MDN — VisualViewport
  • MDN — Viewport meta tag