The cheapest reliable signal for a collapsing mobile address bar is a pair of hidden sentinel elements sized 100svh and 100dvh: when the dynamic one grows to match the large viewport, the bar has collapsed — and a single ResizeObserver on the dynamic sentinel tells you the moment it happens.

Problem / Scenario Context

A reading app shows a floating "back to top" button and a bottom navigation bar. On iOS Safari, when the user scrolls down, Safari's own toolbar shrinks and slides away, giving the page more room. The design wants the app's bottom bar to hide in step with Safari's, and reappear when Safari's does, so the two never stack awkwardly.

Guessing the state from scroll direction is unreliable: Safari collapses its toolbar on its own schedule (it expands again on a tap near the bottom, at the end of the page, and after some scroll reversals). The viewport units described in Visual Viewport API & Mobile Viewport Units encode the real state; the trick is to observe them.

Mechanics Explanation

The layout viewport on these browsers has a small state (toolbar expanded) and a large state (toolbar collapsed). The units expose both ends and the current value:

  • 100svh — constant, the small state.
  • 100lvh — constant, the large state.
  • 100dvh — current, animates between the two as the toolbar moves.

An element sized with 100dvh therefore changes size exactly when the toolbar moves, and ResizeObserver reports that change during the rendering steps of the same frame — no scroll listener and no polling. Comparing the reported size with a sibling sized at 100svh and one at 100lvh turns the raw height into a progress value between 0 (expanded) and 1 (collapsed).

visualViewport.height carries the same information on most engines, but it also changes for pinch-zoom and the on-screen keyboard. The sentinel approach isolates the toolbar because the units ignore both.

Sentinel Heights in Each Toolbar StateBars comparing three sentinel elements. The svh sentinel is always the small height. The lvh sentinel is always the large height. The dvh sentinel equals the small height when the toolbar is expanded and the large height when it has collapsed, so its position between the other two gives the collapse progress.iPhone, portrait — heights in CSS px100svh sentinel664 — constant100dvh, toolbar expanded664 — progress 0100dvh, toolbar collapsed745 — progress 1100lvh sentinel745 — constant

Comparison Table: Ways to Detect the Toolbar

Signal Isolates toolbar? Main-thread cost Timing
Scroll direction heuristic no — guesses scroll listener often wrong
window.innerHeight on resize mostly resize listener after the fact
visualViewport.height no — includes zoom and keyboard event listener per frame
100dvh sentinel + ResizeObserver yes one observer same frame as layout
Scroll-driven CSS only n/a — cannot observe none compositor

Minimal Reproducible Example

HTML
<div class="vh-probe vh-probe--s" aria-hidden="true"></div>
<div class="vh-probe vh-probe--d" aria-hidden="true"></div>
<div class="vh-probe vh-probe--l" aria-hidden="true"></div>
CSS
.vh-probe { position: fixed; top: 0; left: 0; width: 0; visibility: hidden; pointer-events: none; }
.vh-probe--s { height: 100svh; }
.vh-probe--d { height: 100dvh; }
.vh-probe--l { height: 100lvh; }
TypeScript
new ResizeObserver(([e]) => console.log('dvh now', e.contentBoxSize[0].blockSize))
  .observe(document.querySelector('.vh-probe--d')!);

Scroll down on an iPhone; the log prints a sequence of heights climbing from the small value to the large one as Safari's toolbar retracts.

Production-Safe Solution

Wrap the probes in a tiny module that exposes a progress value and a boolean, publishes the progress as a custom property for CSS, and cleans up after itself.

TypeScript
type ToolbarListener = (progress: number, collapsed: boolean) => void;

export function watchToolbar(onChange: ToolbarListener): () => void {
  const make = (unit: string): HTMLDivElement => {
    const d = document.createElement('div');
    d.setAttribute('aria-hidden', 'true');
    d.style.cssText = `position:fixed;top:0;left:0;width:0;height:100${unit};visibility:hidden;pointer-events:none`;
    document.body.append(d);
    return d;
  };
  const small = make('svh');
  const dyn = make('dvh');
  const large = make('lvh');

  // Fallback for engines without the units: all three resolve to the same height.
  let last = -1;
  const ro = new ResizeObserver(() => {
    const s = small.offsetHeight, l = large.offsetHeight, d = dyn.offsetHeight;
    const span = l - s;
    const progress = span > 1 ? Math.min(1, Math.max(0, (d - s) / span)) : 0;
    const rounded = Math.round(progress * 100) / 100;
    if (rounded === last) return;
    last = rounded;
    document.documentElement.style.setProperty('--toolbar-collapse', String(rounded));
    onChange(rounded, rounded > 0.95);
  });
  // Observe all three: rotation changes s and l, the toolbar changes d.
  [small, dyn, large].forEach((el) => ro.observe(el));

  return () => { ro.disconnect(); small.remove(); dyn.remove(); large.remove(); };
}
CSS
.app-bottom-bar {
  /* Slide out in step with the browser's own toolbar. */
  transform: translateY(calc(var(--toolbar-collapse, 0) * 100%));
}

Reading offsetHeight inside the callback is safe here: the callback runs after layout in the rendering steps, so the values are already computed and the reads do not force an extra layout. The writes go to a custom property on the root, which does not change any observed box, so the resize loop settles immediately.

Toolbar Collapse, Frame by FrameA timeline of a scroll gesture. The user scrolls for the first stretch. The browser animates its toolbar away over the next several frames. The dvh sentinel resizes in each of those frames and the ResizeObserver callback updates the collapse progress custom property in the same frames. After the animation ends there are no further callbacks.Scroll gesture on iOS Safariuserscrolling downbrowser toolbarcollapsing animationRO callback--toolbar-collapse 0 → 10ms50ms100ms150ms200ms250ms300ms350ms400ms

Using the Signal Without Fighting the Browser

Knowing the toolbar state is only useful if the app's reaction feels like part of the browser rather than a second animation competing with it. Three rules keep it that way.

Drive transforms from the progress value, not a boolean. A boolean produces a snap at the end of Safari's animation. Multiplying a translateY by --toolbar-collapse follows the browser frame by frame, because the observer updates the property in the same rendering steps where the toolbar moved.

Never move layout, only transforms. If the app bar's height or margins change with progress, every change is itself a layout, and any observed element affected by it delivers another entry — a feedback loop through the page. Transforms are composited and invisible to ResizeObserver.

Keep the semantic state separate. Features that care only whether the toolbar is collapsed — hiding a hint, adjusting a toast position — should subscribe to the boolean with a little hysteresis (collapsed above 0.95, expanded below 0.05) so they do not flap during partial gestures.

TypeScript
let collapsed = false;
const stop = watchToolbar((p) => {
  if (!collapsed && p > 0.95) { collapsed = true;  document.body.dataset.toolbar = 'collapsed'; }
  if (collapsed && p < 0.05)  { collapsed = false; document.body.dataset.toolbar = 'expanded'; }
});

One Signal, Two ConsumersThree boxes. The sentinel ResizeObserver produces a progress value between zero and one. A continuous consumer multiplies a transform by the progress so the app bar tracks the browser toolbar frame by frame. A discrete consumer applies hysteresis and only flips a data attribute when progress passes the upper or lower bound.Sentinel observerprogress 0 → 1 per frameContinuous consumertranslateY × progress, compositedDiscrete consumerflip state above 0.95, below 0.05

Edge Cases

Engines without a retracting toolbar. Desktop browsers and many in-app webviews resolve all three units to the same height. span is then zero, progress stays at 0, and nothing moves — the correct outcome.

Rotation. Landscape on iPhone uses different small and large heights. Because all three probes are observed, a rotation delivers entries for all of them and progress is recomputed from the new bounds.

Keyboard. Under the default interactive-widget behaviour the keyboard does not change any viewport unit, so the probes ignore it. Under resizes-content all three may shrink together; progress remains correct because it is relative.

Safari's minimised tab bar. Recent iOS versions shrink the bottom tab bar into a compact pill rather than hiding it. lvh then reflects the compact state, and progress 1 means "compact", not "gone" — which is still the state the app bar should match.

Verification Steps

  • Remote-debug an iPhone and watch --toolbar-collapse on the root element move from 0 to 1 as you scroll down and back as you scroll up.
  • Check the Performance panel for your callback inside the rendering steps and no forced layouts outside them.
  • Rotate the device mid-page and confirm progress re-settles to 0 or 1.
  • Open the page on desktop and confirm the property stays at 0 with no callbacks after the first.

Common Mistakes to Avoid

  • Using a scroll listener to infer the state. The browser, not the scroll direction, decides when the toolbar moves.
  • Leaving probes in the accessibility tree. Mark them aria-hidden and visibility: hidden so screen readers and hit testing ignore them.
  • Observing only the dvh probe. Rotation changes the bounds, and a stale s or l turns progress into nonsense.
  • Animating the app bar with a separate transition. The browser already animates the toolbar frame by frame; a CSS transition on top lags behind it.

FAQ

Why use offsetHeight instead of the entry's size?

The callback may receive entries for only one probe, but the calculation needs all three heights. Reading offsetHeight inside a ResizeObserver callback is cheap because layout is already up to date at that point.

Does this work in Chrome for Android?

Yes. Chrome's URL bar also moves between small and large states, and the units track it the same way. The progress value animates as the bar slides.

Can I get the toolbar height itself?

Yes: it is the difference between the large and small probe heights. That is often more useful than a fixed pixel guess when positioning fixed elements.

Does watching the toolbar hurt performance?

Three zero-width fixed elements and one observer cost next to nothing. The callback runs only while the toolbar actually moves, which is a few frames per gesture.


↑ Back to Visual Viewport API & Mobile Viewport Units