03동적 뷰포트 높이 — 주소창 크기에 맞춰 다시 재는 전체 화면 높이
주소창이 접히고 펼쳐질 때마다 전체 화면 높이 값이 다시 계산되어 하단 버튼이 화면 밖으로 밀려나지 않습니다.
쓰임전체 화면 랜딩 섹션, 하단 고정 액션 버튼
100vhdvh--bar-h

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="addrToggle" id="addrToggle" type="button" aria-pressed="false">주소창 접기</button>
<div class="phones">
<div class="phone">
<span class="phoneLabel">100vh 고정판</span>
<div class="screen" id="screenFixed">
<div class="addressBar" id="addressBarFixed"></div>
<button class="anchor is-fixed" id="anchorFixed" type="button">계속하기</button>
</div>
</div>
<div class="phone">
<span class="phoneLabel">동적 뷰포트판</span>
<div class="screen" id="screenDvh">
<div class="addressBar" id="addressBarDvh"></div>
<button class="anchor is-dvh" id="anchorDvh" type="button">계속하기</button>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 recalc()/scheduleRecalc() 에 있다 — 주소창 상태가 바뀌면 VH_RECALC_DEBOUNCE_MS
// 뒤에야 --bar-h 커스텀 속성이 갱신된다(실제 동적 뷰포트 재계산의 지연을 흉내). 여기는 두 판(고정·동적)의
// 프레임 모양과, --bar-h 를 transform 으로 반영하는 규칙만 맡는다.
@include stage($stage-ink);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.addrToggle {
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-blue;
color: $subject-cream;
padding: $sp-1 $sp-4;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: translateY(-1px); }
&:active { transform: scale(.95); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.phones {
display: flex;
gap: $sp-4;
}
.phone {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
}
.phoneLabel {
color: $subject-cream;
font-size: 10px;
font-weight: 600;
}
.screen {
position: relative;
width: 96px;
height: 148px;
border-radius: $r-sm;
overflow: hidden;
background: rgba($subject-cream, .08);
box-shadow: inset 0 0 0 2px rgba($subject-cream, .18);
}
.addressBar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 26px;
background: $subject-blue-deep;
transform: translateY(0);
transition: transform $dur-base $ease-out;
z-index: 2;
}
.is-collapsed .addressBar { transform: translateY(100%); }
.anchor {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
padding: $sp-1 0;
border: none;
border-radius: $r-xs;
background: $subject-pink;
color: $subject-ink;
font-size: 9px;
font-weight: 700;
cursor: pointer;
}
// 고정판: 100vh 만 아는 환경 — 주소창이 접히고 펼쳐져도 버튼이 다시 계산되지 않는다(버그 그대로 재현)
.anchor.is-fixed {
z-index: 1;
transform: translateY(0);
}
// 동적 뷰포트판: dvh 를 아는 환경 — --bar-h 만큼 위로 밀어 올려 항상 주소창 위에 머문다
.anchor.is-dvh {
z-index: 2;
transform: translateY(calc(-1 * var(--bar-h, 0px)));
transition: transform $dur-base $ease-out;
opacity: .5;
}
.anchor.is-dvh.is-settled { opacity: 1; }
@media (max-width: 340px) {
.wrap { gap: $sp-1; }
.phones { gap: $sp-2; }
.screen { width: 72px; height: 104px; }
.addressBar { height: 20px; }
.anchor { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.addressBar { transition: none; }
.anchor.is-dvh { transition: none; }
.addrToggle { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.addrToggle {
appearance: none;
border: none;
border-radius: 999px;
background: #2f6df6;
color: #fff7e6;
padding: 4px 16px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.addrToggle:hover {
transform: translateY(-1px);
}
.addrToggle:active {
transform: scale(0.95);
}
.addrToggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.phones {
display: flex;
gap: 16px;
}
.phone {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.phoneLabel {
color: #fff7e6;
font-size: 10px;
font-weight: 600;
}
.screen {
position: relative;
width: 96px;
height: 148px;
border-radius: 8px;
overflow: hidden;
background: rgba(255, 247, 230, 0.08);
box-shadow: inset 0 0 0 2px rgba(255, 247, 230, 0.18);
}
.addressBar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 26px;
background: #0a4fe8;
transform: translateY(0);
transition: transform 300ms ease-out;
z-index: 2;
}
.is-collapsed .addressBar {
transform: translateY(100%);
}
.anchor {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
padding: 4px 0;
border: none;
border-radius: 4px;
background: #ff9ebb;
color: #17141a;
font-size: 9px;
font-weight: 700;
cursor: pointer;
}
.anchor.is-fixed {
z-index: 1;
transform: translateY(0);
}
.anchor.is-dvh {
z-index: 2;
transform: translateY(calc(-1 * var(--bar-h, 0px)));
transition: transform 300ms ease-out;
opacity: 0.5;
}
.anchor.is-dvh.is-settled {
opacity: 1;
}
@media (max-width: 340px) {
.wrap {
gap: 4px;
}
.phones {
gap: 8px;
}
.screen {
width: 72px;
height: 104px;
}
.addressBar {
height: 20px;
}
.anchor {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.addressBar {
transition: none;
}
.anchor.is-dvh {
transition: none;
}
.addrToggle {
transition: none;
}
}(function () {
var VH_RECALC_DEBOUNCE_MS = 150; // 주소창이 움직인 뒤 높이를 다시 계산하기까지의 지연 — 변형 목표: 150 → 50
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var addrToggle = document.getElementById('addrToggle');
var screenDvh = document.getElementById('screenDvh');
var anchorDvh = document.getElementById('anchorDvh');
var isDemo = true;
var BAR_OPEN_PX = 34; // 주소창이 펼쳐졌을 때 화면에서 차지하는 높이
var timer = null;
var collapsed = false;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function recalc() {
var barH = collapsed ? 0 : BAR_OPEN_PX;
// 동적 뷰포트판만 다시 잰다 — 100vh 고정판은 실제로 재계산을 안 하는 버그를 그대로 보여준다
screenDvh.style.setProperty('--bar-h', barH + 'px');
anchorDvh.classList.add('is-settled');
}
function scheduleRecalc() {
anchorDvh.classList.remove('is-settled');
if (timer) clearTimeout(timer);
timer = setTimeout(recalc, VH_RECALC_DEBOUNCE_MS);
}
function setCollapsed(on) {
collapsed = on;
wrap.classList.toggle('is-collapsed', on);
addrToggle.textContent = on ? '주소창 펼치기' : '주소창 접기';
addrToggle.setAttribute('aria-pressed', String(on));
scheduleRecalc();
}
addrToggle.addEventListener('click', function () { human(); setCollapsed(!collapsed); });
addrToggle.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') human();
});
recalc();
var last = 0;
var STEP_MS = 780;
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
setCollapsed(!collapsed);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// DvhMobileViewport — vanilla 판(run/375/demo/03-compat-100vh-mobile-dvh/index.html) 의 React 포트.
// 100vh 고정판은 주소창이 접히고 펼쳐져도 재계산하지 않는 버그를 그대로 보여주고, 동적 뷰포트판은
// VH_RECALC_DEBOUNCE_MS 뒤에 --bar-h 를 다시 놓는다.
import { useEffect, useRef } from "react";
import styles from "./DvhMobileViewport.module.scss";
type Box<T> = { current: T };
const VH_RECALC_DEBOUNCE_MS = 150; // 주소창이 움직인 뒤 높이를 다시 계산하기까지의 지연 — 변형 목표: 150 → 50
const BAR_OPEN_PX = 34; // 주소창이 펼쳐졌을 때 화면에서 차지하는 높이
const STEP_MS = 780;
export default function DvhMobileViewport() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const addrToggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const screenDvhRef = useRef(null) as unknown as Box<HTMLDivElement>;
const anchorDvhRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
const timerRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const addrToggle = addrToggleRef.current;
const screenDvh = screenDvhRef.current;
const anchorDvh = anchorDvhRef.current;
if (!wrap || !addrToggle || !screenDvh || !anchorDvh) return;
let isDemo = true;
let collapsed = false;
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function recalc() {
const barH = collapsed ? 0 : BAR_OPEN_PX;
screenDvh!.style.setProperty("--bar-h", barH + "px");
anchorDvh!.classList.add(styles.isSettled);
}
function scheduleRecalc() {
anchorDvh!.classList.remove(styles.isSettled);
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(recalc, VH_RECALC_DEBOUNCE_MS);
}
function setCollapsed(on: boolean) {
collapsed = on;
wrap!.classList.toggle(styles.isCollapsed, on);
addrToggle!.textContent = on ? "주소창 펼치기" : "주소창 접기";
addrToggle!.setAttribute("aria-pressed", String(on));
scheduleRecalc();
}
function onClick() { human(); setCollapsed(!collapsed); }
function onKeyDown(e: KeyboardEvent) { if (e.key === "Enter" || e.key === " ") human(); }
addrToggle.addEventListener("click", onClick);
addrToggle.addEventListener("keydown", onKeyDown);
recalc();
let last = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
setCollapsed(!collapsed);
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
addrToggle!.removeEventListener("click", onClick);
addrToggle!.removeEventListener("keydown", onKeyDown);
window.clearTimeout(timerRef.current);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button className={styles.addrToggle} ref={addrToggleRef} type="button" aria-pressed="false">주소창 접기</button>
<div className={styles.phones}>
<div className={styles.phone}>
<span className={styles.phoneLabel}>100vh 고정판</span>
<div className={styles.screen}>
<div className={styles.addressBar}></div>
<button className={styles.anchor + " " + styles.isFixed} type="button">계속하기</button>
</div>
</div>
<div className={styles.phone}>
<span className={styles.phoneLabel}>동적 뷰포트판</span>
<div className={styles.screen} ref={screenDvhRef}>
<div className={styles.addressBar}></div>
<button className={styles.anchor + " " + styles.isDvh} ref={anchorDvhRef} type="button">계속하기</button>
</div>
</div>
</div>
</div>
</main>
);
}03 동적 뷰포트 높이 — 주소창 크기에 맞춰 다시 재는 전체 화면 높이 — 측정 누적면적 5.978% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 브라우저 호환 9종 — 실제로 쓰는 폴백 트릭 모음
이 데모가 속한 글
css 브라우저 호환 9종 — 실제로 쓰는 폴백 트릭 모음아홉 개의 css 브라우저 호환 트릭 중 하나는 주소창이 접히고 펼쳐질 때마다 전체 화면 높이 값이 다시 계산되고, 하나는 입력 필드에 포커스를 줘도 화면이 확대되지 않게 잠그고, 하나는 목록을 끝까지 당겨도 뒤 배 이 글의 데모 전부 보기


