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 legacy 100vh on most engines) equals the large state. It never changes during scroll, but in the small state part of the element is under the bar.
  • 100svh equals the small state. It never changes during scroll and always fits, with a gap at the bottom in the large state.
  • 100dvh equals 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 --vh behaves like dvh but 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.

Hero Height During One Scroll GestureA line chart of hero height over a half-second scroll that collapses the address bar. The svh line stays flat at the small value. The lvh line stays flat at the large value. The dvh line and the script-driven custom property both rise from small to large as the bar retracts, the script line stepping later than dvh because it waits for a resize event.6406656907157400100200300400500milliseconds into the scrollhero height in CSS px100svh100lvh100dvh--vh from script

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

CSS
/* The jumpy version: an in-flow hero tied to the dynamic viewport. */
.hero { height: 100dvh; }
TypeScript
// 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.

CSS
/* 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:

TypeScript
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.

Unit or Observer?A decision chain. If the element is in document flow and other content sits below it, use min-block-size 100svh. Otherwise, if it is a fixed or absolutely positioned shell that must exactly fill the screen, use 100dvh. Otherwise, if its size depends on the content that fits rather than on the viewport, use a ResizeObserver on a stable container. Anything else that needs the keyboard accounted for uses visualViewport.In document flow, with content below it?min-block-size: 100svhyesnoA fixed shell that must exactly fill the screen?block-size: 100dvhyesnoDoes its size depend on content that fits?ResizeObserver on an svh-sized containeryesnoNeeds to avoid the on-screen keyboard: measure window.visualViewport.

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.

  1. Inventory every consumer. Search for var(--vh and classify each use as in-flow (sections, heroes, cards) or out-of-flow (fixed shells, overlays, modals).
  2. Replace in-flow uses with svh. These are the rules causing scroll jumps; min-block-size: 100svh is the direct replacement.
  3. Replace out-of-flow uses with dvh. Fixed shells can follow the toolbar without disturbing anything else.
  4. Keep the variable as a fallback only. Define --vh: 1vh in CSS so any consumer you missed still resolves, then delete the script.
  5. Remove the listener last and confirm with the ResizeObserver counter that scroll no longer resizes in-flow elements.

Before and After Removing the --vh ScriptTwo columns. Before: a resize listener writes a custom property after each resize event, in-flow sections resize late during scroll, and observed elements deliver entries on every toolbar movement. After: in-flow sections use svh and never resize during scroll, fixed shells use dvh, and no script runs.Before — script writes --vhA resize listener writes a custom property aftereach eventIn-flow sections resize late, pushing content duringscrollObserved boxes deliver entries on every toolbarmovementAfter — units by roleIn-flow sections use min-block-size: 100svhFixed shells use 100dvh and disturb nothing elseNo listener, no custom property, no late writes

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 svh it 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 vh with dvh. It makes every one of them resize during scroll.
  • Keeping the old --vh script "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 height instead of min-height for 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.


↑ Back to Visual Viewport API & Mobile Viewport Units