dvh (Dynamic Viewport Height) is a modern CSS unit that adapts to the
currently visible height of the browser viewport. It sounds like a small detail,
but in practice it fixes a bug almost every web developer has run into.
If you've ever built a full-screen hero section and watched the browser's address bar cut off the bottom of it on mobile — or seen a stray scrollbar appear on a page designed to fit exactly one screen — this one's for you.
This guide covers the whole picture: why 100vh broke, what svh,
lvh and dvh actually mean, ready-made recipes for the most common
layouts, and the pitfalls you genuinely hit on real projects.
Why 100vh caused problems
By definition, vh is one percent of the viewport height. On desktop that's
unambiguous: the browser window's content area doesn't change as you scroll.
On mobile it does. The address bar and the bottom navigation controls collapse as you scroll
down and reappear as you scroll up, so the visible area is constantly moving. Browsers had to
decide what vh should mean in that situation, and most of them chose to
lock it to the largest possible value — the one you get once the browser chrome has
fully collapsed.
That had a few very visible consequences:
- a
height: 100vhsection extends below the screen on first load, - a layout designed to be exactly one screen tall becomes scrollable anyway,
- the button or scroll cue placed at the bottom of the hero slides out of view.
For years we worked around it: measure the real height in JavaScript, write it into a custom
property (--vh), then multiply it back with calc(). It worked, but it
solved a layout problem with runtime code.
The new family of viewport units
CSS no longer knows just one viewport height — it knows three, because on mobile there really are three sensible answers to the question "how big is the screen?"
svh(small viewport height) — the smallest visible height, i.e. when all the browser UI is showing.lvh(large viewport height) — the largest visible height, once the address bar and navigation have collapsed. This is what most browsers report asvh.dvh(dynamic viewport height) — the current value, which tracks the browser chrome as it moves.
The order is always the same: svh ≤ dvh ≤ lvh.
So 100dvh isn't a fixed number — it's a live value moving between those two
bounds, always equal to the space that's genuinely available right now.
vhtells you how big the screen can be.dvhtells you how big it is.
Worth knowing: on desktop and in an installed (standalone) PWA there is no collapsing browser chrome, so all three units resolve to the same value. The difference only shows up where something actually moves — that is, in a mobile browser.
Quick reference: which unit when?
If you take one thing away from this article, make it this table. The rest of the guide is mostly an expansion of it.
| Unit | What it resolves to | Changes on scroll? | Use it for |
|---|---|---|---|
vh |
the legacy behaviour; on mobile usually the large viewport | no | in new code, at most as a fallback |
svh |
the smallest visible height | no | anything that must always fit: app shells, bottom bars, scroll-snap |
lvh |
the largest visible height | no | rarely: background layers where overflow is intentional |
dvh |
the current visible height | yes | hero sections, full-screen overlays, modals |
A good rule of thumb: dvh for design, svh for guarantees.
If something must always be visible, don't leave it to the dynamic value.
The most common case: the hero section
The basic usage is exactly as simple as it looks:
.hero {
min-height: 100dvh;
}
There are two small but important details in those three lines. First, min-height
rather than height: if the content ever grows taller than one screen it flows on
instead of being clipped. Second, dvh already gives the correct result on its own
wherever it's supported.
If you still need to serve older browsers, the fallback is one extra line. CSS applies the last
declaration it understands, so a browser that doesn't know dvh simply skips the
second one:
.hero {
min-height: 100vh; /* older browsers */
min-height: 100dvh; /* everyone else */
}
No @supports, no JavaScript, no build step required.
A hero below a fixed header
In practice a hero rarely starts at the very top of the screen — there's usually a fixed
navigation bar above it. Viewport units work inside calc() like any other length,
so subtracting the header height is straightforward:
:root {
--header-h: 4rem;
}
.hero {
min-height: calc(100vh - var(--header-h));
min-height: calc(100dvh - var(--header-h));
display: grid;
place-content: center;
}
Keep the header height in a custom property: the same value can then drive
scroll-margin-top, the mobile menu's position and the hero calculation.
Recipes for real layouts
With viewport units the question is rarely "what does dvh mean" — it's which unit
is right in this specific layout. Four recurring cases, with the code.
1. App shell: header, scrollable body, bottom bar
The classic app-like interface: a header on top, a navigation bar at the bottom, scrollable
content in between. This one wants svh, not dvh.
.app {
height: 100svh; /* never promises more room than exists */
display: grid;
grid-template-rows: auto 1fr auto;
}
.app__main {
min-height: 0; /* without this the grid row can't shrink */
overflow-y: auto;
}
Two things are going on. First, min-height: 0: in grid and flex layouts an item
won't shrink below its content by default, so without it the scrollable area pushes the bottom
navigation off the screen. This is the single most common reason a layout that "should work"
overflows anyway.
Second, svh: because the scrolling happens inside an element rather than in the
document, the browser chrome typically never collapses at all. svh is the only
value guaranteed to stay true here.
2. Full-screen sections with scroll snap
Slide-like, full-screen sections that snap into place. svh is the right choice
here too, and for a principled reason:
.slides {
height: 100svh;
overflow-y: auto;
scroll-snap-type: y mandatory;
}
.slides > section {
height: 100svh;
scroll-snap-align: start;
}
If the height of the snap points changed while scrolling, the browser would be working against
its own calculations: every scroll would move the target it's trying to reach. The result is a
jumpy, "snapping back" scroll. svh is a fixed value, so every slide is guaranteed
to fit and snapping stays predictable.
3. Bottom sheet
Here dvh really shines: a fixed overlay's whole job is to match the area that is
visible right now.
.sheet {
position: fixed;
inset-inline: 0;
bottom: 0;
max-height: 85dvh; /* leaves a sliver of the page behind */
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
max-height is the key: the sheet takes as much room as its content needs, but
never grows past 85% of the screen. Short content stays short, long content becomes
scrollable — with no extra rules.
4. Side drawer
And one recipe where the best answer is that there is no viewport unit in it:
.drawer {
position: fixed;
top: 0;
bottom: 0; /* no height unit needed */
inset-inline-start: 0;
width: min(20rem, 80vw);
overflow-y: auto;
}
top: 0; bottom: 0 achieves exactly what height: 100dvh would, except
the browser doesn't have to re-resolve a moving unit to get there. More on that in the next
section.
dvh or 100%? When you don't need a viewport unit
Viewport units are so convenient that it's easy to overuse them. Yet most "fill the available space" problems have nothing to do with the size of the screen.
Fixed overlays: inset: 0 beats 100dvh
For a full-screen modal backdrop, height: 100dvh works — but it needlessly ties
the element to a moving value:
/* Works, but unnecessary */
.overlay {
position: fixed;
top: 0;
inset-inline: 0;
height: 100dvh;
}
/* Better */
.overlay {
position: fixed;
inset: 0;
}
The second version resolves against its containing block rather than a unit — nothing to compute, nothing to re-evaluate while scrolling, and no way for it to end up a pixel off.
Percentages relate to the parent, not the screen
height: 100% is a percentage of the parent element's height, and it only
works when there's an unbroken chain of heights all the way up to html. That's
why the well-known opening lines existed for years:
html, body {
height: 100%;
}
Once you have that chain, 100% is often the more correct choice for
nested elements: it fills its parent, not the screen. An image inside a card behaves properly
even when the card is only half a screen tall.
Decision rule: reach for a viewport unit only when the element genuinely
has to relate to the screen. If it relates to its parent, percentages, flex or grid
are the right tools — and inside a component, the container query units
(cqh, cqi).
The virtual keyboard
This is where most of the confusion lives, so it's worth being precise: the on-screen mobile
keyboard does not change the value of dvh by default.
What the browser shrinks is the visual viewport — the part you can actually see —
while the layout viewport that CSS units resolve against stays the same. Hence the
classic bug: the bottom button of a 100dvh app shell disappears behind the
keyboard the moment a user taps into a field.
The meta tag solution
The interactive-widget setting on the viewport meta tag tells the browser to
shrink the layout viewport too. Dynamic viewport units then follow along:
<meta name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
There are three values:
resizes-visual— the default: only the visual viewport shrinks,resizes-content— the layout viewport shrinks too, sodvhfollows,overlays-content— the keyboard simply floats on top and nothing changes.
This setting applies to the whole page, so it isn't something to turn on for the sake of one component — it makes sense when the page is form-like or chat-like by nature. Support varies between browsers, so treat it as progressive enhancement: the page must still work without it.
When you need exact numbers: the Visual Viewport API
Where something genuinely has to sit above the keyboard — a chat input, a mobile form toolbar — the Visual Viewport API gives you the precise value:
const vv = window.visualViewport;
if (vv) {
const sync = () => {
document.documentElement.style
.setProperty('--vvh', `${vv.height}px`);
};
vv.addEventListener('resize', sync);
vv.addEventListener('scroll', sync);
sync();
}
Yes, this brings JavaScript back — but only for this one genuinely special case, not for every
full-screen section on the site. That's the difference from the old --vh hack:
there the rule needed JavaScript, here only the exception does.
Safe areas: env(safe-area-inset-*)
100dvh tells you how large the visible area is — not how much of it is actually
usable. The notch, the rounded corners and the bottom gesture bar are all inside the
visible area, and none of them are places you want to put content.
Two things are needed. First, opt into edge-to-edge rendering in the meta tag:
<meta name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover">
Then use the env() function to stay out of the unsafe zones:
.hero {
min-height: 100dvh;
padding-block-end: calc(2rem + env(safe-area-inset-bottom, 0px));
}
.bottom-bar {
position: fixed;
inset-inline: 0;
bottom: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
The second argument (0px) is the fallback: without it, an unknown environment
variable would invalidate the whole declaration in browsers that don't recognise it. Always
write it out.
Common misunderstanding: without viewport-fit=cover the
safe-area-inset-* values are all zero. If you've added the env()
calls and nothing changes, that's almost certainly what's missing.
What to watch out for
The strength of dvh is also its limitation: the value genuinely changes while you
scroll, and the browser has to re-run layout because of it.
Don't animate it
If a height expressed in dvh has a CSS transition on it, every movement of the
browser chrome kicks off an animation. The result is a jumpy, stuttering layout. Keep the size
change instant here.
/* This will stutter on mobile */
.hero {
min-height: 100dvh;
transition: min-height 0.3s ease;
}
Mind the content shift
If a 100dvh block sits in the middle of a long page, its height — and with it the
position of everything after it — moves as you scroll. Put full-screen sections near the top of
the page, or use svh further down.
Keep it out of media queries
A breakpoint condition should be stable. If it depends on a value that moves while you scroll,
the media query can flip back and forth and cause bugs that are near-impossible to reproduce
afterwards. Media queries want svh or lvh; leave dvh to
the layout.
Updates aren't necessarily frame-accurate
The specification allows browsers to update the dynamic viewport size less often than the
chrome actually moves. That's deliberate: it avoids re-running the entire layout on every
frame. In practice it means you can rely on dvh as layout, but shouldn't
build anything on it that expects frame-accurate tracking. That's what the Visual Viewport API
is for.
Inside an iframe, the iframe is the viewport
Within an iframe, viewport units resolve against the iframe's own size, not the embedding
page's. For an embedded widget, 100dvh almost certainly doesn't mean what you
expect — percentages or container query units are the right tools there.
Not just height
The same logic runs through all the viewport units, not only height. Each one gained an
s, l and d prefix:
dvw— dynamic viewport width,dvminanddvmax— the smaller and the larger of the two,dvianddvb— inline and block size, independent of writing mode.
In practice dvh is the reason the family exists — width changes far less often
mid-scroll on mobile. dvi and dvb earn their keep when a site serves
multiple writing modes: in vertical writing the "block direction" is horizontal, and the
logical units follow that automatically.
What dvw does not fix: the classic 100vw
horizontal overflow on desktop. If a 100vw-wide element makes the page scroll
sideways, dvw behaves exactly the same way. For that, the answer is still
width: 100%.
Migrating off the JavaScript --vh hack
If your project is a few years old, chances are this code is still in there — it was the standard workaround for a long time:
// Old workaround — safe to delete
const setVh = () => {
document.documentElement.style
.setProperty('--vh', `${window.innerHeight * 0.01}px`);
};
setVh();
window.addEventListener('resize', setVh);
.hero {
height: calc(var(--vh, 1vh) * 100);
}
The whole thing is replaced by this:
.hero {
min-height: 100vh;
min-height: 100dvh;
}
The swap, step by step:
- search the codebase for
--vh,innerHeightandresize, - replace
calc(var(--vh) * 100)with100dvh— or100svhwherever guaranteed room is required, - delete the JavaScript and the
resizelistener, - review where
heightis still used somewheremin-heightbelongs, - verify on a real device (see the next section).
A note on step 2: the old hack measured innerHeight at load
time, while the browser chrome was still showing — so what it actually captured was the
small viewport. If you want identical behaviour, the exact equivalent is
svh. dvh is better because it keeps tracking, so pick
svh only where something was deliberately built on the fixed height.
The swap saves more than lines of code: the native unit is already correct at first paint, whereas the JavaScript version always flashed a wrong height before it ran.
How to test it
This section matters, because most dvh-related bugs
cannot be reproduced in a desktop browser — including DevTools' device
emulation.
Mobile emulation doesn't collapse the address bar, because there isn't one. So
svh, lvh and dvh all report the same value there, and
the exact phenomenon you're trying to catch stays invisible. You need a real device: use
chrome://inspect on Android and Safari's Web Inspector on iOS.
A ruler for all three units
The fastest way to actually see what the three units return on a given device is to paste in this temporary snippet.
// Temporary ruler — don't ship this
const probe = (unit) => {
const d = document.createElement('div');
d.style.cssText = `position:fixed;visibility:hidden;height:100${unit}`;
document.body.append(d);
return d;
};
const p = { svh: probe('svh'), lvh: probe('lvh'), dvh: probe('dvh') };
const out = document.createElement('div');
out.style.cssText =
'position:fixed;z-index:9999;top:0;left:0;padding:.4rem;' +
'background:#000;color:#0f0;font:12px monospace';
document.body.append(out);
(function tick() {
out.textContent =
`svh ${p.svh.offsetHeight} · ` +
`lvh ${p.lvh.offsetHeight} · ` +
`dvh ${p.dvh.offsetHeight}`;
requestAnimationFrame(tick);
})();
Scroll down and back up: svh and lvh stay put while dvh
travels between them. If all three read the same, you're either on desktop or in an installed
PWA — there's nothing to track in either case.
(offsetHeight rounds to whole pixels, so a one-pixel difference is normal.)
Testing checklist
- iOS Safari: scroll all the way down, then back up — is anything clipped?
- Android Chrome: the same, plus the area around the bottom gesture bar,
- focus a text input — does the keyboard hide anything important?
- rotate the device to landscape — screens are short in landscape, and a
100dvhhero easily becomes unusably cramped, - if the site is installable as a PWA, check it in standalone mode too,
- turn on "reduce motion" if the section also animates.
Browser support
Dynamic viewport units have been supported by every major browser since Safari 15.4, Chrome 108,
Edge 108 and Firefox 101. In 2026 that effectively covers the entire active user base, and the
two-line vh fallback above takes care of the rest.
If you need more than that — for instance a layout that is structured differently in each case —
@supports is available:
@supports (height: 100dvh) {
.app {
height: 100svh;
}
}
In practice you rarely need it: the two-declaration fallback is shorter, more readable and
achieves the same thing. @supports is justified when the old and new versions
differ structurally.
If you've been carrying a JavaScript --vh workaround, you can safely delete it:
the native unit is more accurate, faster, and doesn't flash before first paint.
Checklist
Before shipping a full-screen layout, run through this list:
min-heightinstead ofheightwherever content can grow,- a
vhfallback line above thedvhone, svhwherever things must be guaranteed to fit (app shell, bottom bar),svhrather thandvhfor scroll snapping,inset: 0instead of a height unit for fixed overlays,- no
transitionor animation on a size expressed indvh, - no
dvhinside media queries, min-height: 0on the scrollable track of a grid or flex layout,safe-area-insetpadding whereverviewport-fit=coveris used,- the keyboard case is thought through (
interactive-widgetor the Visual Viewport API), - the old JavaScript
--vhhack has been deleted, - tested on a real mobile device, not just in an emulator.
Frequently asked questions
What is the difference between vh and dvh?
vh measures the viewport height, but on mobile browsers it doesn't follow the
address bar and navigation controls as they move — most browsers lock it to the largest possible
height. dvh adapts dynamically to the area that is actually visible, so it stays
accurate while you scroll.
When should I use dvh?
It's ideal for full-screen hero sections, landing pages, mobile web applications, and any layout
that should match the visible screen height. For fixed elements such as bottom bars or sticky
CTAs, svh is often the safer choice, because it never promises more room than there
really is.
Do modern browsers support dvh?
Yes. Modern versions of Chrome, Edge, Firefox and Safari all support the dvh unit.
For older browsers a simple vh fallback is enough: declare the vh rule
first, then the dvh one below it.
Why does my layout jump when I use dvh?
Because the value of dvh genuinely changes while you scroll, and every change
triggers a new layout pass. If the element has a transition on its height, that
movement becomes a visible animation. The fix is twofold: never animate a size expressed in
dvh, and use svh instead for full-height blocks that sit in the
middle of a long page.
Does dvh account for the mobile keyboard?
Not by default. The on-screen keyboard shrinks the visual viewport but leaves the layout
viewport unchanged, so dvh keeps its value. If you want the keyboard to be taken
into account, add interactive-widget=resizes-content to the viewport meta tag, or
measure with the Visual Viewport API.
What is the difference between 100dvh and height: 100%?
100dvh always relates to the visible height of the screen, while
height: 100% relates to the parent element and requires an unbroken chain of
heights up to the html element. If an element should fill its parent, percentages
are the right tool; reach for a viewport unit only when the element really has to match the
screen.
Can I use dvh in a media query?
Better not to. A breakpoint condition should be stable: if it depends on a value that moves
while you scroll, the media query can flip back and forth and produce bugs that are very hard
to reproduce. Use svh or lvh in media queries and keep
dvh for layout.
Should I replace every vh with dvh?
No. Replace vh where the element has to fill the visible screen — typically
full-screen sections. Where vh is just a source of proportional sizing, such as a
decorative element's height, the swap gains you nothing. And where you need guaranteed room,
the right replacement is svh, not dvh.