For almost every full-height layout, a viewport unit — svh for stable sections, dvh for app shells — is better than measuring with script; keep a ResizeObserver only for layouts whose height depends on content inside the viewport, not on the viewport itself.
Problem / Scenario Context
A landing page has a hero that should fill exactly the first screen. The original implementation used height: 100vh, which on mobile was taller than the visible area because vh meant the large viewport, so the call-to-action button sat hidden under the address bar. A later fix measured window.innerHeight on every resize and wrote a --vh custom property. That fixed the button but introduced a new problem: as the user scrolled and the address bar collapsed, the hero resized, everything below it jumped, and the scroll position lurched.
Both behaviours are explained by the viewport model in Visual Viewport API & Mobile Viewport Units. The fix is to choose which viewport state the hero is tied to, not to measure faster.
Mechanics Explanation
Mobile browsers with a retracting address bar have two layout-viewport states: small (bar expanded) and large (bar retracted). Scrolling toggles between them, animating the transition.
100lvh(and legacy100vhon most engines) equals the large state. It never changes during scroll, but in the small state part of the element is under the bar.100svhequals the small state. It never changes during scroll and always fits, with a gap at the bottom in the large state.100dvhequals the current state. It fits exactly at rest, but changes during scroll — which resizes the element, reflows everything after it, and delivers a ResizeObserver entry to anything observing the affected boxes.- A script-written
--vhbehaves likedvhbut updates later (after a resize event or observer callback) and costs a style recalculation per write.
The jump in the original fix came from tying a document-flow element to the dynamic state. The content below the hero was pushed down and pulled up by the height of the address bar every time it moved.
Comparison Table: Unit vs Behaviour
| Approach | Fits in small state? | Stable during scroll? | Script? | Delivers ResizeObserver entries on scroll? |
|---|---|---|---|---|
100vh / 100lvh |
no — overflows under bar | yes | no | no |
100svh |
yes | yes | no | no |
100dvh |
yes | no | no | yes |
--vh from resize listener |
yes | no, and late | yes | yes |
min-height: 100svh; height: 100dvh in a fixed shell |
yes | shell only, content unaffected | no | only on the shell |
Minimal Reproducible Example
/* The jumpy version: an in-flow hero tied to the dynamic viewport. */
.hero { height: 100dvh; }
// Count how often the hero changes size during one scroll.
let n = 0;
new ResizeObserver(() => n++).observe(document.querySelector('.hero')!);
addEventListener('scrollend', () => { console.log(`hero resized ${n} times`); n = 0; });
Scroll down on Chrome for Android: the hero reports a new size on almost every frame of the address-bar animation, and each one reflows the page below it.
Production-Safe Solution
Pick the unit by role, and reserve script for sizing that depends on content.
/* In-flow sections: stable, always fit. */
.hero {
min-block-size: 100svh;
}
/* App shells that must exactly fill the screen and do not push content: dynamic is fine,
because nothing in flow depends on their height. */
.app-shell {
position: fixed;
inset: 0;
block-size: 100dvh;
}
/* Legacy fallback for engines without the new units. */
@supports not (height: 100svh) {
.hero { min-block-size: 100vh; }
}
A ResizeObserver is still the right tool when the thing you are sizing depends on the content that fits inside the viewport — for example, choosing how many dashboard rows to render, or whether a hero's headline needs a smaller font to avoid overflowing:
interface FitOptions { hero: HTMLElement; headline: HTMLElement; minPx: number; maxPx: number }
export function fitHeadline({ hero, headline, minPx, maxPx }: FitOptions): () => void {
const ro = new ResizeObserver(([entry]) => {
const available = entry.contentBoxSize[0].blockSize * 0.4; // headline gets 40% of the hero
// Size only the headline — a descendant — so the loop settles in one pass.
const size = Math.max(minPx, Math.min(maxPx, available / 3));
headline.style.fontSize = `${Math.round(size)}px`;
});
ro.observe(hero); // hero is sized in svh: stable on scroll
return () => ro.disconnect();
}
Because the hero uses svh, this observer fires on rotation and window resize but not on every frame of address-bar movement. The unit does the viewport work; the observer only does the content work.
Migrating an Existing --vh Script
Many codebases still carry the pre-unit workaround: a resize listener that writes --vh as one percent of innerHeight, consumed as calc(var(--vh) * 100). Removing it is worth doing, but in stages, because some rules relied on its dynamic behaviour and some only needed a stable value.
- Inventory every consumer. Search for
var(--vhand classify each use as in-flow (sections, heroes, cards) or out-of-flow (fixed shells, overlays, modals). - Replace in-flow uses with
svh. These are the rules causing scroll jumps;min-block-size: 100svhis the direct replacement. - Replace out-of-flow uses with
dvh. Fixed shells can follow the toolbar without disturbing anything else. - Keep the variable as a fallback only. Define
--vh: 1vhin CSS so any consumer you missed still resolves, then delete the script. - Remove the listener last and confirm with the ResizeObserver counter that scroll no longer resizes in-flow elements.
Edge Cases
Desktop browsers treat all three units as equal. With no retracting UI, svh, lvh and dvh all equal the window's inner height, so desktop testing never reveals a problem — test on a phone or with remote debugging.
Scrollbars and vw. The width units do not subtract a classic scrollbar on desktop, so 100dvw can cause horizontal overflow exactly like 100vw. Prefer 100% for widths.
Embedded webviews. In-app browsers (social media apps, email clients) sometimes report no retracting UI, making all three units equal to the small state. The svh choice degrades gracefully; lvh choices can end up under a toolbar.
Nested scrollers. When the page scrolls inside a container instead of the document, the address bar may never collapse, and dvh behaves like svh. That is another reason to design for svh first.
Verification Steps
- Remote-debug on a phone and watch the hero's computed height while scrolling; with
svhit must not change. - Count ResizeObserver deliveries with the snippet above: zero during scroll, one or two on rotation.
- Check the call-to-action is visible on first load with the address bar expanded.
- Test an in-app browser (open the page from a messaging app) to confirm the fallback path looks right.
Common Mistakes to Avoid
- Replacing every
vhwithdvh. It makes every one of them resize during scroll. - Keeping the old
--vhscript "just in case". It now fights the units, and its late writes reintroduce the jump. - Sizing an observed element in
dvh. You turn every address-bar frame into a resize callback. - Using
heightinstead ofmin-heightfor content sections. Text that wraps at large accessibility sizes overflows a fixed viewport height.
FAQ
Is 100vh the same as 100lvh?
In current engines, yes for practical purposes: vh was redefined to mean the large viewport so that it stays stable. That is precisely why it overflows when the address bar is visible.
Do viewport units react to the on-screen keyboard?
Only when the keyboard resizes the layout viewport, which requires interactive-widget set to resizes-content on engines that support it. Otherwise they ignore the keyboard entirely.
Why not animate the hero with dvh so it grows smoothly?
Because growing an in-flow element pushes the content below it, which moves what the user is reading while they scroll. That is a layout shift in everything but name, and it harms both perceived stability and CLS.
Can I observe the viewport units with ResizeObserver?
Indirectly: observe an element sized with the unit. That works, but observing a dvh-sized element means a callback per address-bar frame, so observe an svh-sized element unless you specifically want those updates.
Related
- Visual Viewport API & Mobile Viewport Units — the full model
- Tracking the Address Bar Collapse on Mobile Safari — when you do need to know
- Tracking Layout Shifts with PerformanceObserver — measuring the jump you removed