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 オリジナル · SCSS と React は zip に同梱 · css ブラウザ対応 9選 — 実務で使うフォールバック集
このデモが属する記事
css ブラウザ対応 9選 — 実務で使うフォールバック集9つのcss ブラウザ対応トリックのうち、1つはアドレスバーの開閉のたびに画面全体の高さを再計算し、1つは入力欄にフォーカスしても画面が拡大されないようロックし、1つはリストを端まで引っ張っても背景が一緒に揺れないよう止めます。 この記事のデモをすべて見る


