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.
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
<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>
.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; }
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.
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(); };
}
.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.
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.
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'; }
});
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-collapseon 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-hiddenandvisibility: hiddenso screen readers and hit testing ignore them. - Observing only the
dvhprobe. Rotation changes the bounds, and a stalesorlturns 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.
Related
- dvh, svh, lvh vs ResizeObserver for Full-Height Layouts — choosing the unit
- Hiding a Header on Scroll Down, Showing on Scroll Up — the in-page counterpart
- Handling the Mobile Virtual Keyboard with visualViewport — the other bottom-edge change