LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLab CoreTeam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

CSSdvhdvwvhsvhlvhvisualViewportzoomTailwind모바일반응형미러링디바이스 판정

100vh가 100%가 아닌 이유 — 모바일 뷰포트 단위 완전 정리

2026년 3월 24일·28분 읽기

height: 100vh인데 스크롤이 생긴다

전체화면 UI를 만들 때 대부분 height: 100vh부터 시작한다. "뷰포트 높이의 100%니까 화면 전체를 채우겠지"라고 생각하지만, 모바일 브라우저에서 열어보면 하단이 잘리거나 스크롤이 생긴다. 특히 iOS Safari에서 이 현상이 두드러진다.

이유는 100vh가 실제로 보이는 화면 높이가 아니기 때문이다. 모바일 브라우저에는 주소창, 탭 바, 하단 네비게이션 같은 UI 요소가 있다. 이 요소들은 스크롤에 따라 나타났다 사라진다. vh 단위는 이 브라우저 UI가 숨겨진 상태의 최대 높이를 기준으로 계산된다. 그래서 주소창이 보이는 상태에서 100vh는 실제 화면보다 크다.

이 문제는 2015년부터 보고되어 왔고, CSS Working Group이 2022년에 새로운 뷰포트 단위를 표준화하면서 공식적인 해결책이 생겼다. 나는 이 블로그에 프레젠테이션 모드를 만들면서 이 문제를 직접 겪었다. 블로그 글을 PPT처럼 슬라이드로 보여주는 전체화면 기능인데, 모바일 Safari에서 열면 하단 바가 화면 밖으로 밀려나서 페이지 인디케이터가 보이지 않았다.


뷰포트 단위 4종 비교 — vh, svh, lvh, dvh

새로운 뷰포트 단위를 이해하려면 모바일 브라우저의 UI 상태 두 가지를 알아야 한다. 주소창이 완전히 펼쳐진 상태(Small Viewport)와, 스크롤로 주소창이 숨겨진 상태(Large Viewport)이다.

단위이름기준값 변화
vhViewport HeightUA 결정 (보통 Large 기준)고정
svhSmall Viewport Height브라우저 UI가 모두 보이는 상태고정

svh와 lvh는 고정값이다. 브라우저 UI 상태와 무관하게 항상 같은 값을 반환한다. 차이는 어느 쪽을 기준으로 잡느냐일 뿐이다. 반면 dvh는 브라우저 UI가 나타나고 사라질 때마다 값이 바뀐다.

어떤 단위를 써야 하는가

상황에 따라 다르다. 핵심은 "이 요소가 브라우저 UI 변화에 반응해야 하는가"이다.

  • 전체화면 오버레이, 모달: dvh가 적합하다. 주소창이 나타나도 레이아웃이 자동으로 줄어들어서 콘텐츠가 잘리지 않는다.
  • 히어로 섹션, 랜딩 페이지: svh가 안전하다. 가장 작은 뷰포트 기준이므로 콘텐츠가 절대 넘치지 않는다.
  • 배경 이미지 영역: lvh가 낫다. 주소창이 사라졌을 때 빈 공간이 보이지 않도록 최대 크기로 잡아두는 것이다.

dvh에는 주의점이 있다. 값이 동적으로 변하기 때문에 스크롤할 때마다 레이아웃 재계산이 발생한다. 성능에 민감한 애니메이션이나 빈번하게 리사이즈되는 요소에는 svh나 lvh로 고정하는 게 나을 수 있다. 전체화면 UI처럼 스크롤이 없는 상황에서 dvh가 가장 빛난다.

프레젠테이션 모드에 적용하기

프레젠테이션 모드는 전체화면 오버레이이므로 dvh로 바꿨다. 기존 height: 100vh를 height: 100dvh로 교체하는 것만으로 모바일 Safari에서 하단이 잘리는 문제가 해결됐다.

tsx
// 외곽 컨테이너: 100vh → 100dvh
<div
  className="fixed inset-0 z-50 bg-white dark:bg-gray-950"
  style={{ height: "100dvh" }}
>

이 프레젠테이션 모드에는 모바일에서 화면을 CSS rotate(90deg)로 강제 가로 회전하는 기능이 있다. 이 경우 물리적 width와 height가 뒤바뀌므로, 회전된 컨테이너의 크기도 dvh/dvw를 교차해서 지정해야 한다.

tsx
// 모바일 강제 회전: 물리적 width/height가 뒤바뀜
const containerStyle = needsRotation
  ? {
      width: "100dvh", // 물리적 세로 길이 → 회전 후 가로
      height: "100dvw", // 물리적 가로 길이 → 회전 후 세로
      top: "50%",
      left: "50%",
      transform: "translate(-50%, -50%) rotate(90deg)",
    }
  : { width: "100%", height: "100%" };

JavaScript에서 실제 뷰포트 구하기 — visualViewport API

CSS 단위로 레이아웃을 잡는 것과 별개로, JavaScript에서 뷰포트 크기를 계산해야 할 때가 있다. 슬라이드 분할처럼 콘텐츠 높이를 측정해서 페이지를 나누는 로직이 대표적이다. 그런데 window.innerHeight는 vh와 같은 문제를 가지고 있다. 브라우저 UI를 무시한 고정 높이를 반환한다.

window.visualViewport은 실제로 사용자에게 보이는 영역의 크기를 알려준다. 주소창, 소프트 키보드, 핀치 줌 등 모든 브라우저 UI 요소를 고려한 값이다.

ts
// window.innerHeight: 브라우저 UI 무시한 고정값 (≈ 100vh)
// visualViewport.height: 실제 가시 영역 (≈ 100dvh를 px로 환산)
const vv = window.visualViewport;
const realHeight = vv?.height ?? window.innerHeight;

visualViewport의 프로퍼티

프로퍼티설명
width / height가시 영역의 크기 (CSS 픽셀)
offsetLeft / offsetTop핀치 줌으로 이동한 오프셋
pageLeft / pageTop페이지 기준 절대 좌표
scale

resize 이벤트도 지원한다. 소프트 키보드가 올라오거나 주소창이 변할 때 visualViewport.addEventListener('resize', handler)로 반응할 수 있다. 다만 전체화면 UI에서는 초기 한 번만 측정하면 충분한 경우가 많으므로, 이벤트 리스너가 반드시 필요한 것은 아니다.

슬라이드 분할에 적용한 사례

프레젠테이션 모드에서는 블로그 글의 DOM을 복제해서, 화면 높이에 맞게 자동으로 슬라이드를 분할한다. 이때 "화면 높이"를 window.innerHeight로 잡으면 모바일 에서 마지막 줄이 하단 바 뒤에 숨는 문제가 생겼다. visualViewport으로 교체하니 브라우저 UI를 뺀 실제 가용 높이로 분할되어 문제가 사라졌다.

tsx
useEffect(() => {
  if (!articleRef.current) return;
  const vv = window.visualViewport;
  // 회전 시 물리적 width가 높이가 됨
  const viewH = needsRotation
    ? (vv?.width ?? window.innerWidth)
    : (vv?.height ?? window.innerHeight);
  const padding = isMobile ? SLIDE_PADDING_Y_MOBILE : SLIDE_PADDING_Y;
  const available = (viewH - padding - bottomBarHeight) * getFillRatio(viewH);

회전 상태에서는 축이 뒤집히기 때문에 vv?.width가 슬라이드 높이가 되고, vv?.height가 슬라이드 너비가 된다는 점에 주의해야 한다. 물리적 기기의 가로세로와 CSS 레이아웃의 가로세로가 일치하지 않는 것이다.


CSS zoom vs transform: scale — 무엇이 다른가

전체화면 UI에서 콘텐츠를 확대해야 할 때, transform: scale()과 CSS zoom 중 하나를 선택해야 한다. 시각적으로는 비슷해 보이지만 레이아웃에 미치는 영향이 전혀 다르다.

transform: scale()

transform은 시각적 변환이다. 요소의 렌더링된 모습만 바꾸고, 레이아웃 공간은 원래 크기 그대로 유지된다. scale(2)로 2배 확대해도 주변 요소는 원래 위치에 그대로 있다. 요소 자체는 커졌는데 차지하는 공간은 같으니, 확대된 부분이 다른 요소 위에 겹쳐 그려진다.

css
.scaled {
  transform: scale(2);
  /* 레이아웃 공간: 원래 크기 유지 */
  /* 시각적 크기: 2배 확대 (주변 요소 위에 겹침) */
}

CSS zoom

zoom은 레이아웃 변환이다. 요소의 실제 레이아웃 크기 자체를 바꾼다. zoom: 2로 2배 확대하면 요소가 차지하는 공간도 2배가 된다. 주변 요소가 밀려나고, 부모 컨테이너의 스크롤도 정상적으로 동작한다.

css
.zoomed {
  zoom: 2;
  /* 레이아웃 공간: 2배 확대 */
  /* 시각적 크기: 2배 확대 (주변 요소가 밀려남) */
}
속성레이아웃 영향주변 요소스크롤브라우저 지원
transform: scale()없음겹침원래 크기 기준모든 브라우저
zoom있음밀려남

프레젠테이션에서 zoom을 선택한 이유

프레젠테이션 모드는 발표용이다. 빔프로젝터에 띄우거나 큰 모니터에서 멀리서 보는 상황을 고려하면 글자를 키울 수 있어야 했다. 처음에는 transform: scale()을 시도했는데, 슬라이드 콘텐츠가 확대되면서 하단 바 위에 겹쳐 그려지는 문제가 생겼다. 레이아웃 공간이 원래 크기 그대로이기 때문이다. zoom으로 바꾸니 콘텐츠가 확대되면서 자연스럽게 overflow 처리가 되었다.

tsx
// PC에서만 zoom 적용, 모바일은 화면이 작으므로 원본 크기 사용
<div
  ref={slideContentRef}
  className="mx-auto w-full max-w-5xl overflow-hidden"
  style={isMobile ? undefined : { zoom: pcScale }}
/>

다만 zoom을 쓰면 한 가지 함정이 있다. 슬라이드 분할 로직에서 콘텐츠 높이를 getBoundingClientRect()로 측정하는데, 이 값은 zoom 전의 원래 크기를 반환한다. zoom: 2에서 화면 높이가 800px이면 실제로 콘텐츠가 들어갈 공간은 400px인데, 측정 함수에 800px을 전달하면 콘텐츠가 넘친다. 가용 공간을 zoom 배율로 나눠서 전달해야 한다. 이것을 역스케일링이라고 부른다.

tsx
const scale = isMobile ? 1 : pcScale;
// zoom: 2 → 가용 공간을 절반으로 나눠서 전달
extractSlides(article, availableHeight / scale, slideWidth / scale);

하단 바에는 확대/축소 버튼을 추가했다. 스케일을 변경하면 슬라이드 분할이 달라지므로 currentSlide를 0으로 리셋한다. 갑자기 중간 슬라이드로 점프하는 것보다 처음부터 다시 보는 게 자연스럽기 때문이다.


모바일 판정 — width만 보면 안 되는 이유

반응형 웹에서 모바일 판정은 보통 max-width: 768px 미디어 쿼리로 한다. CSS에서는 이게 충분하지만, JavaScript에서 모바일/PC를 구분해서 동작(behavior)을 바꿔야 할 때는 부족하다.

가로 모드(landscape) 태블릿이 대표적인 반례이다. iPad를 가로로 들면 innerWidth가 1024px이므로 max-width: 768px에 해당하지 않는다. PC로 판정되면서 마우스 기반 인터랙션이 적용된다. 터치 기기인데 호버 효과를 기대하게 되는 것이다.

반대로 이미 가로 모드인 스마트폰에서 max-width: 768px에 걸리면, 세로 모드 전용 회전 로직이 적용되어 화면이 뒤집힐 수 있다. 문제는

"모바일이다"와 "회전이 필요하다"를 하나의 조건으로 묶었기 때문

이다.

짧은 변 기준 판정

해결 방법은 간단하다. 현재 width가 아니라 짧은 변(short side) 을 기준으로 판정한다.

ts
// orientation에 무관하게 일관된 모바일 판정
const short = Math.min(window.innerWidth, window.innerHeight);
const isMobile = short <= 768;

가로든 세로든, 짧은 변이 768px 이하면 모바일이다. iPad(짧은 변 768px)는 모바일로, 일반 노트북(짧은 변 900px+)은 PC로 판정된다.

판정을 분리하기

그리고 모바일 여부와 회전 필요 여부를 별도 상태로 분리한다. 이미 가로 모드라면 모바일이어도 회전할 필요가 없기 때문이다. 프레젠테이션 모드에서는 이 두 판정이 각각 다른 곳에 영향을 준다. isMobile은 터치 인터랙션(롱프레스 종료, 탭 네비게이션)과 패딩, 하단 바 높이를 결정하고, needsRotation은 CSS 회전과 축 뒤바꿈 로직을 제어한다.

tsx
const [isMobile] = useState(() => {
  const short = Math.min(window.innerWidth, window.innerHeight);
  return short <= 768;
});

// 세로 상태(portrait)일 때만 회전
const [needsRotation] = useState(() => {
  return isMobile && window.innerHeight > window.innerWidth;
});
기기 상태isMobileneedsRotation결과
스마트폰 세로truetrue모바일 UI + 90도 회전
스마트폰 가로truefalse모바일 UI, 회전 안 함

이전에는 isMobile이 true이면 무조건 회전했기 때문에, 이미 가로로 들고 있는 스마트폰에서 프레젠테이션을 열면 화면이 세로로 뒤집혀 버렸다. 분리 후에는 가로 스마트폰은 모바일 UI(터치 인터랙션)만 적용되고 회전은 하지 않는다.


자동 감지로는 부족했던 순간 — 미러링 환경 대응

짧은 변 기준 판정으로 대부분의 기기는 커버됐는데, 예상 못한 케이스가 스터디 자리에서 나왔다. 노트북을 빔프로젝터에 미러링해서 프레젠테이션을 띄웠는데, 화면에 "꾹 눌러서 종료" 문구가 보이고 배율 버튼이 사라져 있었다. PC인데 모바일 UI가 적용된 것이다.

원인을 추적해보니 미러링된 디스플레이의 해상도가 짧은 변 기준 768px을 밑돌 수 있었다. 물리적으로는 노트북인데, 브라우저가 받는 뷰포트 값이 모바일 조건에 걸려버리는 것이다. 자동 감지 로직만으로는 이런 엣지 케이스를 막기 어렵다. "노트북을 쓰는 건 알지만, 지금 브라우저가 받은 값은 모바일이다"라는 모순적인 상황이기 때문이다.

수동 토글을 허용하기

자동 감지를 더 정교하게 다듬는 대신, 사용자가 직접 모드를 바꿀 수 있게 열어주는 쪽으로 방향을 잡았다. isMobile을 const에서 state로 승격하고, 하단 바에 모바일·데스크탑 아이콘 토글 버튼을 추가했다. 초기 감지는 pointer: coarse(터치 입력) 미디어 쿼리를 우선으로 쓰고, 그게 없으면 짧은 변으로 폴백한다.

tsx
const [isMobile, setIsMobile] = useState(() => {
  if (typeof window === "undefined") return false;
  // pointer: coarse (터치 입력) 우선, 없으면 짧은 변으로 폴백
  if (window.matchMedia?.("(pointer: coarse)").matches) return true;
  const short = Math.min(window.innerWidth, window.innerHeight);
  return short <= 768;
});

pointer: coarse는 주 입력 장치가 정밀하지 않은(터치) 기기에서 true를 반환한다. 미러링된 노트북은 마우스/트랙패드가 주 입력이므로 pointer: coarse 에 걸리지 않고, 짧은 변 fallback에서 걸린다면 사용자가 수동으로 되돌리면 된다.

배율과 표시 영역을 분리하기

모드 토글로 UI 문제는 풀렸는데, 미러링 환경에서 또 다른 문제가 남아 있었다. 슬라이드 하단이 잘리는 현상이었다. 브라우저가 보고하는 visualViewport.height 와 실제 미러링 디스플레이의 가시 영역이 일치하지 않는 경우가 있었다.

처음에는 첫 슬라이드 하단에 "이 문구가 보일 때까지 배율을 조절하세요"라는 기준점 텍스트를 놨었는데, 이 문구가 슬라이드 콘텐츠와 다른 scale에 있어서 배율을 바꿔도 위치가 변하지 않았다. 기준선이 기준 역할을 못 했던 것이다. 그 상태에서 문구를 슬라이드 scale 안으로 옮겨도 봤지만, 이번에는 콘텐츠에 따라 위치가 흔들려서 오히려 혼란스러웠다.

돌아와서 문제를 다시 봤다. 배율(글자 크기)과 표시 영역(슬라이드가 쓰는 세로 공간)은 사실 별개의 축이다. 배율로 읽기 편한 글자 크기를 먼저 맞추고, 그다음에 짤리면 표시 영역을 줄이는 게 자연스러운 흐름이다. 두 개를 하나의 컨트롤로 묶으려 했던 게 잘못이었다.

하단 바에 표시 영역 조절 버튼을 배율 버튼과 구분자로 나눠서 추가했다. 내부 구현은 getFillRatio의 반환값을 사용자 지정값으로 덮어쓰는 방식이다.

tsx
const [fillRatioOverride, setFillRatioOverride] = useState<number | null>(null);

// 슬라이드 생성 시 override가 있으면 우선 사용
const ratio = fillRatioOverride ?? getFillRatio(viewH);
const available = (viewH - padding - bottomBarHeight) * ratio;

override가 null이면 화면 높이 기반 기본 비율을 쓰고, 사용자가 버튼을 누르면 30%~100% 범위에서 5%씩 조정된다. 배율 조정과 표시 영역 조정이 각각 독립적으로 슬라이드 재계산을 트리거하므로, 어느 쪽을 만지든 실시간으로 반영된다.

잘림 감지로 피드백 주기

사용자가 "지금 잘리고 있다"를 눈으로 확인하려면 발표자는 슬라이드를 계속 훑어봐야 한다. 그래서 렌더 후 실제 DOM 높이를 측정해서, 컨테이너를 초과하면 하단 바에 경고 뱃지를 띄우는 로직을 추가했다.

tsx
requestAnimationFrame(() => {
  const el = slideContentRef.current;
  const parent = el?.parentElement;
  if (!el || !parent) return;
  const scale = isMobile ? 1 : pcScale;
  const contentH = el.scrollHeight * scale;
  const parentH = parent.clientHeight;
  setOverflowing(contentH > parentH + 1);
});

scrollHeight는 zoom 전 원본 크기를 돌려주므로 pcScale을 곱해서 실제 렌더링되는 높이로 환산한다. requestAnimationFrame으로 감싼 이유는 DOM 삽입 직후 측정하면 레이아웃이 확정되기 전이라 오차가 나기 때문이다. 초과하면 ⚠ 화면 넘침 — 표시 영역을 줄이세요 뱃지가 하단 바에 뜨고, 표시 영역 버튼으로 바로 대응할 수 있다.

자동 감지의 한계와 수동 탈출구

이번 작업에서 배운 건,

자동 감지가 95%를 커버해도 나머지 5%가 실패하면 사용자는 완전히 막힌다

는 점이다. 미러링·듀얼 모니터·드래그한 창 크기·접근성 확대 같은 조합은 감지 로직으로 전부 거르기 어렵다. 이럴 땐 감지 로직을 더 복잡하게 만드는 것보다, 사용자에게 수동 탈출구를 주는 편이 안정적이다. 지금은 기본 감지가 틀려도 두 번의 클릭으로 정상화할 수 있다.


Tailwind에서 자식 요소 스타일링 — 임의 변형(Arbitrary Variant)

Tailwind CSS는 유틸리티 클래스로 현재 요소의 스타일을 지정하는 게 기본이다. 그런데 가끔 자식 요소의 스타일을 부모에서 제어해야 할 때가 있다. 대표적인 예가 pre > code 패턴이다. pre에서 flexbox를 설정하고, 자식 code에 margin: auto를 줘서 중앙 정렬하고 싶은데, code 태그에 직접 className을 줄 수 없는 경우(외부 라이브러리가 렌더링하는 경우 등)가 있다.

Tailwind의 임의 변형(arbitrary variant) 문법은 대괄호 안에 CSS 선택자를 쓸 수 있게 해준다. &는 현재 요소를 가리킨다.

tsx
<pre className="flex flex-col [&>code]:my-auto [&>code]:mx-auto">
  <code>{children}</code>
</pre>

[&>code]:my-auto는 다음 CSS로 컴파일된다.

css
.\\[\\&\\>code\\]\\:my-auto > code {
  margin-top: auto;
  margin-bottom: auto;
}

코드 전체화면 뷰어에 적용한 사례

이 블로그의 코드 블록에는 전체화면 뷰어가 있다. 코드를 크게 보여주는 기능인데, 짧은 코드일 때 화면 상단에 붙어서 보기 어색했다. pre 안의 code를 세로·가로 중앙에 놓고 싶었지만, code 태그는 dangerouslySetInnerHTML로 렌더링되어 직접 className을 줄 수 없었다. [&>code] 패턴으로 부모인 pre에서 자식 스타일을 제어해 해결했다.

tsx
// FullscreenView 내부의 pre 태그
<pre
  className="flex-1 flex flex-col overflow-auto bg-gray-900 p-6
  text-base leading-7 text-gray-100
  md:py-32 md:px-[25dvw] md:text-2xl md:leading-8
  [&>code]:my-auto [&>code]:mx-auto"
>
  {children}
</pre>

자주 쓰는 패턴들

Tailwind 클래스생성되는 CSS용도
[&>code]:my-auto> code { margin-block: auto }직접 자식 code 수직 중앙
[&_img]:rounded-lgimg { border-radius: ... }하위 모든 img에 rounded
[&>*:first-child]:mt-0

&>는 직접 자식, &_(공백)는 하위 모든 자손, &+는 인접 형제 선택자이다. 별도의 CSS 파일 없이 JSX 안에서 복잡한 선택자를 표현할 수 있다는 점이 핵심이다.


컴포넌트 재사용 — 같은 UI가 두 곳에 있다면

프레젠테이션 모드 안에서 긴 코드 블록이 슬라이드에 다 들어가지 않으면, "코드 전체 보기" 버튼으로 교체된다. 이 버튼을 누르면 전체화면 코드 뷰어가 열리는데, 처음에는 이 뷰어를 프레젠테이션 컴포넌트 내부에 직접 구현했다. 회전 로직, 롱프레스 종료, 하단 바를 그대로 복사해서 넣었다.

그런데 이 코드는 이미 CodeBlock 컴포넌트 안에 FullscreenView로 존재한다. 코드 블록의 전체화면 버튼과 프레젠테이션의 코드 전체 보기가 사실상 같은 UI였다. 하나를 수정하면 다른 쪽도 수정해야 하는 상황이 된 것이다.

사실 초기에 이런 식으로 구현한 이유가, 회전을 해야한다에 너무 매몰돼서, 비효율적으로 구현한 듯하다. 그래서 FullscreenView를 독립적으로 export하고, rotate prop을 추가해서 호출하는 쪽에서 회전 여부를 제어할 수 있게 했다. 프레젠테이션에서는 이미 컨테이너가 회전된 상태이므로 rotate={false}를 넘기고, 코드 블록에서 직접 열 때는 생략하면 자동으로 모바일 세로 모드일 때만 회전한다.

tsx
// 프레젠테이션에서 FullscreenView 재사용
import { FullscreenView } from "@app/ui";

{
  fullscreenCode &&
    createPortal(
      <FullscreenView
        language={fullscreenCode.language}
        onClose={() => setFullscreenCode(null)}
        rotate={false} // 이미 회전된 컨테이너 안이므로
      >
        <code
          classNamefont-mono

이렇게 분리하면서 프레젠테이션 내부의 코드 뷰어 구현부(약 30줄)를 통째로 제거할 수 있었다. 앞으로 전체화면 코드 뷰어의 디자인이나 인터랙션을 수정할 때 한 곳만 고치면 된다.


정리

전체화면 UI를 다양한 기기에서 안정적으로 동작시키려면, 뷰포트의 실체를 이해해야 한다. 100vh가 100%가 아닌 것은 버그가 아니라 모바일 브라우저의 설계이고, 이를 보완하는 도구(dvh, visualViewport)가 이미 표준에 있다.

  • 전체화면 레이아웃에는 100dvh를 쓰고, JS에서는 visualViewport으로 실제 크기를 구한다.
  • 콘텐츠 확대에는 zoom이 transform: scale()보다 레이아웃 친화적이지만, 측정 시 역스케일링을 잊지 않는다.
  • 모바일 판정은 짧은 변 기준으로 하고, "모바일이다"와 "회전이 필요하다"를 분리한다.
  • 자동 감지는 95%만 맞춰도 충분하지 않다. 미러링·듀얼 모니터 같은 엣지 케이스는 수동 토글로 탈출구를 열어둔다.
  • 배율과 표시 영역은 서로 다른 축이다. 하나의 컨트롤로 묶지 말고 각각 조절할 수 있게 분리한다.
  • Tailwind 자식 선택자는 [&>selector] 패턴으로 별도 CSS 없이 처리한다.
  • 같은 UI가 두 곳에 있으면 prop으로 차이를 제어하는 공통 컴포넌트로 뽑아낸다.

PC, 노트북, 태블릿 가로/세로, 스마트폰 가로/세로 — 뷰포트 크기와 브라우저 UI가 모두 다르다. 하나의 코드로 이 모든 환경을 커버하려면, 생각보다 많은 개념이 필요했었던 경험이다.

포스트 목록

/SEOJing
파일 11개, 폴더 1개
Cloudflare Workers에서 fs 모듈이 안 되는 이유와 해결법모바일 웹에서 가로 모드를 강제하는 5가지 방법 — iOS Safari에서도 동작하는 코드 뷰어 만들기블로그 글을 PPT로 만들기 — DOM 클로닝 기반 프레젠테이션 모드100vh가 100%가 아닌 이유 — 모바일 뷰포트 단위 완전 정리Context로 퀴즈 컴포넌트를 만들다 막혀서 React.Children을 공부하게 된 이야기localStorage 읽기에서 하이드레이션 에러가 터지는 이유 useSyncExternalStore로 해결useEffect cleanup과 의존성 배열 — 실전 버그 사례로 이해하는 생애주기vinext + GitHub Actions로 Cloudflare Workers 배포하기vinext 오픈소스 기여기: 한국어 slug가 RSC에서 이슈를 일으킨 이유RSC 환경에서 WebAssembly가 차단되는 이유 — Shiki에서 rehype-prism-plus로vinext는 왜 빠를까? — SSR, Vite, Edge, 그리고 Web Vitals까지
lvhLarge Viewport Height브라우저 UI가 모두 숨겨진 상태고정
dvhDynamic Viewport Height현재 실제 가시 영역동적
핀치 줌 배율 (기본 1.0)
const scale = isMobile ? 1 : pcScale;
const slideW = needsRotation
? (vv?.height ?? window.innerHeight) - 64
: Math.min(window.innerWidth - 128, 1024);
setSlides(
extractSlides(articleRef.current, available / scale, slideW / scale),
);
}, [articleRef, isMobile, needsRotation, bottomBarHeight, pcScale]);
확대된 크기 기준
Chrome, Safari, Edge (Firefox 126+)
태블릿 가로
true
false
모바일 UI, 회전 안 함
노트북/데스크탑falsefalsePC UI
const toggleDeviceMode = useCallback(() => {
setIsMobile((prev) => {
const next = !prev;
// 모바일로 전환할 때만 현재 화면 방향 기준으로 회전 여부 재계산
if (next && typeof window !== "undefined") {
setNeedsRotation(window.innerHeight > window.innerWidth);
} else {
setNeedsRotation(false);
}
return next;
});
setCurrentSlide(0);
}, []);
> *:first-child { margin-top: 0 }
첫 자식 상단 마진 제거
[&+p]:mt-4+ p { margin-top: ... }인접 형제 p에 상단 마진
=
"
"
dangerouslySetInnerHTML={{ __html: fullscreenCode.html }}
/>
</FullscreenView>,
document.body,
);
}