Overriding env(safe-area-inset-*) in a WebView: Android and iOS

Published on — Updated on

Sometimes a WebView host needs the page to see a safe area the device does not have. A kiosk view, a video player that should ignore the home indicator, a test harness that wants deterministic insets. You need env(safe-area-inset-bottom) to return 0px so the page stops reserving space for a home bar that isn’t there.

Since this post went up, the case I keep running into is the opposite one: a host needs to remove an inset the device really has, because the app has already handled it. Gadgetbridge’s Bangle.js app manager is one. On Android 15 the app is edge-to-edge, so the platform hands its WebView the real status-bar and navigation-bar insets, and the app’s own native title bar has already made room for them. The page pads for a safe area that is already padded, and sits one status bar too low. The host’s fix is to stop passing those insets down, which is the override this post is about.

The figure below is a reference for the Android terms this post uses. iOS has a single safe area and no equivalent names, so it is Android-only.

Android The parts of the screen, and what Android calls them

Hover or focus a number to highlight it on the screen.

A real Android screen: the status bar across the top, the page in the middle, the keyboard above the navigation bar at the bottom
On the screen
1
The app's window
The rectangle the screen draws into. This app is edge-to-edge, so that is the whole screen and the content sits behind the bars.
2
The system bars
The status bar, the navigation bar and the caption bar. Android reports their sizes together as systemBars().
3
navigationBars()
The bottom bar on its own. With the keyboard up it reports the keyboard's height, which is where the double-count in this post starts.
4
ime()
The keyboard's own inset. The WebView reads it separately from the system bars and uses it to resize the visual viewport.
Inside the window, outermost first
5
The decor view
Android's frame inside the window: the title bar, if the app has one, plus the content frame.
6
android.R.id.content
The frame Android puts your layout into. Gadgetbridge adds padding to this view, and that padding is what causes the problem.
7
The app's layout
Whatever the app puts inside that frame. In this launcher it is a ConstraintLayout holding nothing but the WebView; Gadgetbridge wraps a LinearLayout around it.
8
The WebView
The browser widget the page runs in. It has its own opinion about the keyboard, which is the second half of the problem.

The window is the rectangle the screen draws into. Here that is the whole screen, because the app is edge-to-edge. The system bars are the status bar, the navigation bar and the caption bar; Android reports their sizes together as systemBars(). android.R.id.content is the frame Android puts the app’s layout into. A host pads this view to keep its content clear of the bars.

Both platforms let you override those numbers. The mechanisms are completely different, and each fails silently. iOS additionally has a WebKit bug that makes a working override look broken. The keyboard is a second channel of the same mechanism, covered in The keyboard is a second channel below.

Before and after

Both platforms, same page, same requested insets. Overrides requested were top 150, right 40, bottom 180, left 40.

iOS: iPhone 16 Pro simulator, iOS 18.6

No overrideOverride 150/40/180/40
iOS before the override: insets read 62 0 34 0, the real device valuesiOS after the override: insets read 150 40 180 40, with the page's own overlays drawn at the overridden positions

62 0 34 0 → 150 40 180 40. The real top inset is 62pt (the notch); the override replaced it. The blue overlay labels track the change.

Android: physical device, Android 15, WebView 153

No overrideOverride 150/40/180/40
Android before the override: insets read 41 0 0 0, the real device valuesAndroid after the override: insets read 77 21 92 21, converted from the requested device pixels at dpr 1.96

41 0 0 0 → 77 21 92 21. Note the unit difference. Android’s override is in physical pixels and CSS sees the result divided by dpr (1.96 here: 150/1.96 ≈ 77). iOS’s override is in points, which already are CSS pixels, so 150 in, 150px out. If you port an override value between platforms you must multiply by dpr on the Android side.

Android: the mechanism

Android WebView computes the page’s safe-area insets inside an internal AwContents view, from the WindowInsets that reach it:

int insetTypes = displayCutout() | systemBars();
Insets safeArea = toWindowInsetsCompat(insets).getInsets(insetTypes);

So the only lever is the insets themselves. Subclass WebView and override onApplyWindowInsets:

@Override
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
if (!hasInsetOverride() || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return super.onApplyWindowInsets(insets);
}
return super.onApplyWindowInsets(applyOverrides(insets));
}
private WindowInsets applyOverrides(WindowInsets insets) {
WindowInsets.Builder builder = new WindowInsets.Builder(insets);
int[] types = {
WindowInsets.Type.statusBars(),
WindowInsets.Type.navigationBars(),
WindowInsets.Type.captionBar(),
WindowInsets.Type.displayCutout(),
};
for (int type : types) {
Insets current = insets.getInsets(type);
builder.setInsets(type, Insets.of(
leftOverride != null ? leftOverride : current.left,
topOverride != null ? topOverride : current.top,
rightOverride != null ? rightOverride : current.right,
bottomOverride != null ? bottomOverride : current.bottom));
}
return builder.build();
}

Condensed from InsetAwareWebView.java; that class is 101 lines and this is all of it that matters.

One line in that excerpt is out of date. The gate reads VERSION_CODES.R, which is API 30, and the floor is really 31, for the dispatch-order reason given below.

Three things that each cost someone a day:

A listener on the WebView cannot do this. A listener is called instead of the view’s own onApplyWindowInsets, so one set on the WebView replaces the handling that feeds AwContents: the rebuilt insets go back to the caller, not down into the widget. Overriding onApplyWindowInsets works because ViewGroup.dispatchApplyWindowInsets passes its return value down to children, and AwContents is one.

A listener on the WebView’s parent also works, for the same reason in reverse: the parent’s dispatch hands the modified insets to each of its children. The keyboard fix later in this post uses that.

Correction: the first version of this post said a listener could never do this. That is only true when the listener is on the WebView itself.

Every edge must be set on every type in the mask. getInsets(mask) returns the per-edge maximum across the mask. Overriding only navigationBars() leaves a non-zero bottom wherever a display cutout also contributes one.

Requires API 31+, and only works in edge-to-edge mode, because otherwise the framework consumes the insets before the WebView sees them. In that mode the status bar stays visible but transparent; hiding it zeroes the statusBars() inset, which leaves the page with a top inset of zero and hides the cutout from it.

The floor is 31 rather than 30 because of who gets to consume the dispatch. On API 28 to 30 the WebView provider installs its own OnApplyWindowInsetsListener on the container view that wraps your WebView, and Android stops delivering insets as soon as a listener consumes them, so the onApplyWindowInsets you overrode is never reached. From API 31 the provider hooks onApplyWindowInsets on a view inside the WebView instead, which sits below yours, so yours runs first. There is no API 28 to 30 device in the matrix below, so that part rests on the code path rather than on a measurement.

iOS: the mechanism

Much smaller, because there is no interception layer to hook. WebKit derives the page’s env(safe-area-inset-*) from the receiving view’s safeAreaInsets, so overriding that one getter is the whole mechanism:

final class InsetOverrideWebView: WKWebView {
/// Per-edge overrides in points. `nil` leaves that edge at the real system value.
var insetOverride = InsetOverride() {
didSet {
guard insetOverride != oldValue else { return }
// UIKit memoises safe-area geometry per layout pass, so the override is
// only re-read once something invalidates that cache.
setNeedsLayout()
invalidateIntrinsicContentSize()
}
}
override var safeAreaInsets: UIEdgeInsets {
insetOverride.applying(to: super.safeAreaInsets)
}
}
struct InsetOverride: Equatable {
var top: CGFloat?
var right: CGFloat?
var bottom: CGFloat?
var left: CGFloat?
var isEmpty: Bool { top == nil && right == nil && bottom == nil && left == nil }
func applying(to base: UIEdgeInsets) -> UIEdgeInsets {
UIEdgeInsets(
top: top ?? base.top,
left: left ?? base.left,
bottom: bottom ?? base.bottom,
right: right ?? base.right
)
}
static func parseEdges(top: String, right: String,
bottom: String, left: String) -> InsetOverride {
InsetOverride(top: parse(top), right: parse(right),
bottom: parse(bottom), left: parse(left))
}
/// Blank = leave at the real system value. Non-numeric is treated the same
/// way rather than silently becoming 0.
private static func parse(_ raw: String) -> CGFloat? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let value = Double(trimmed), value >= 0 else { return nil }
return CGFloat(value)
}
}

Condensed from InsetOverrideWebView.swift. The comment above insetOverride is the same one, and it is worth reading in place: UIKit memoises safe-area geometry per layout pass, so an override that is not read after something invalidates that cache silently does nothing.

nil per edge means “use the real system value”. Values are deliberately not clamped, so a value larger than the real inset pushes content further in, which is the point when you’re inflating a fake inset.

Verified per-edge independence on iOS 27.0, with only top overridden:

ConfigwebView.safeAreaInsetspage env()
all blankt:62 r:0 b:34 l:062px 0px 34px 0px
top=111 onlyt:111 r:0 b:34 l:0111px 0px 34px 0px
111/44/222/33t:111 r:44 b:222 l:33111px 44px 222px 33px

Row 1 is the sanity check that the pipeline reads real data, since 62px is exactly window.safeAreaInsets.top. Row 2 shows the other three edges falling through to genuine system values rather than being zeroed. Row 3 sets all four and gets all four back.

Side by side

AndroidiOS
HookView.onApplyWindowInsetsWKWebView.safeAreaInsets getter
Override unitsPhysical pxPoints (= CSS px)
viewport-fit=cover neededNo (WebView M136+)Yes
CSS injection neededNoNo
Minimum versionAPI 31iOS 11
Extra requirementEdge-to-edge, or insets are consumed firstcontentInsetAdjustmentBehavior = .never
Cost of skipping that requirementInsets never reach the pagePage reads them, but in a viewport already shrunk by them
Per-edge nil fall-throughYesYes
Change while the page is loadedYes, after an insets dispatchYes, assign the property
Bottom edge with the keyboard openenv() reads 0; the keyboard arrives as ime() insteadNot measured
Easy to get wrongMust set all edges on all types in the maskValues arrive non-deterministically (below)

Three iOS conditions

Each one fails in its own way, and only the first fails loudly. Miss viewport-fit=cover and every edge reads 0px with no error. Miss the adjustment behaviour and the page reports the right numbers from the wrong viewport, which is harder to catch than zeros because nothing looks broken until the layout is compared against something. The third one, ordering, fails by leaving the page briefly correct against the wrong insets.

1. viewport-fit=cover in the document.

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

WebKit reports 0 for every env(safe-area-inset-*) without it, and no native change can alter that. It’s the same rule Safari follows. This is the one real behavioural gap versus Android, whose WebView M136+ populates the values from WindowInsets with no opt-in. The page must cooperate.

2. contentInsetAdjustmentBehavior = .never.

webView.scrollView.contentInsetAdjustmentBehavior = .never

Any other value lets UIKit inset the scroll view by the safe area, and the page ends up inside an already-inset viewport. That is the failure, and it is not zeros. Measured on iOS 18.6 and again on 26.5, iPhone 17 Pro, override applied in makeUIView, sampled every animation frame. env() is identical in every row, so the readout will not warn you:

contentInsetAdjustmentBehavioradjustedContentInsetPage env()clientWidth x clientHeight
.never0 0 0 0150 40 180 40402 x 874
.automatic150 0 180 0150 40 180 40402 x 544
.scrollableAxes150 0 180 0150 40 180 40402 x 544
.always150 40 180 40150 40 180 40322 x 544

The height arithmetic is the same in every row that insets: 874 minus the 150 top and 180 bottom override is 544. Width only goes under .always, where 402 minus the 40 left and 40 right is 322.

Neither of the middle two is a safe compromise. They keep the width and still cost the page 330 points of height, which is the part a document is least likely to notice has gone.

The consequence is a double inset. A page that pads its body by env() gets the safe area applied twice, once by UIKit shrinking the layout viewport and once by the page’s own padding. The demo page’s scroll range shows it: 797 under .never, 1286 under .always.

The demo page under .never, spanning the full 402 point width with insets reading 150 40 180 40The same page under .always, still reading insets 150 40 180 40 but confined to 322 points of width, with its content pushed down by the top inset a second time

Two shots of the same page with the same override. env() and the insets T R B L readout are identical; what differs is the viewport the page is laid out in. Re-assert the line in updateUIView regardless, since a layout change can reset it:

func updateUIView(_ webView: WKWebView, context: Context) {
if let overridable = webView as? InsetOverrideWebView {
overridable.insetOverride = insetOverride
}
webView.scrollView.contentInsetAdjustmentBehavior = .never
// …
}

Straight from WebViewScreen.swift.

3. Set the override before the first layout pass, in makeUIView and not from a later callback. It is one of the first lines of makeUIView, and the Android host orders it the same way, before setContentView rather than after.

Setting it late does not zero the reporting. It leaves the page laid out against the real safe area until something invalidates the geometry UIKit has memoised, and then it converges on the override without a reload. Same page, two runs, the only difference being when the assignment happens:

Override assignedFirst readingThen
In makeUIView, before the first layout pass150 40 180 40stays there
1.0s later, from a callback62 0 34 0150 40 180 40 at 562 ms

The 562 ms is the didSet reaching a layout pass. If you go looking for the symptom the post used to promise, zeros, you will not find it: the page is briefly correct against the wrong insets instead.

That is about the first value only. Changing the override later, with the page already loaded, reaches the page on both platforms.

Changing the override while the page is loaded

Both mechanisms can be re-applied after the page has loaded, and on every runtime measured here the page followed without a reload. What changes is the extra step: setting the value is no longer enough, because something has to make the platform read it again.

On Android the override is applied inside onApplyWindowInsets, so a new value does nothing until insets are dispatched again. Either call works:

webView.setInsetOverride(top, right, bottom, left);
// then one of these
webView.dispatchApplyWindowInsets(webView.getRootWindowInsets());
webView.getRootView().requestApplyInsets();

Both produced the same numbers on the OnePlus and both reached the page on the next animation frame. Prefer requestApplyInsets(): it travels the hierarchy the way a real insets change does, so it cannot skip a parent that consumes them, which is the same mechanism that puts the Android floor at API 31.

On iOS there is no dispatch to trigger. Assigning the property is the whole update, because its didSet calls setNeedsLayout() and invalidateIntrinsicContentSize(), which is what invalidates the safe-area geometry UIKit has memoised.

Sampled every animation frame, so “second frame” means the frame after the first one that drew:

RuntimeDeviceCorrect after loadLive change seen by the page
18.6iPhone 16 Pro847 ms, second frame150 40 180 40 to 300 80 360 80
26.5iPhone 17 Pro67 ms150 40 180 40 to 300 80 360 80
27.0iPhone 18 Pro Max165 ms150 40 180 40 to 300 80 360 80
27.1iPhone Duo276 ms150 40 180 40 to 300 80 360 80
27.2iPhone 18 Pro667 ms150 40 180 40 to 300 80 360 80
Android 15OnePlus 6T, WebView 15340 ms, second frame77 21 92 21 to 153 41 184 41

The Android row is the same two requests as the iOS rows, reported in CSS px after the division by dpr.

Those are single runs, and the runtime is not the whole story: 18.6 gave 463 ms on one simulator and 847 ms on another, which is the same non-determinism the sampled table below records.

nil still means the real system value after a change, not only at load. Requesting 300 on the top edge alone gave the page 300 0 34 0 on every runtime, with the untouched bottom edge reporting its real 34pt.

Neither launcher exposes this today. Android reads its overrides once in onCreate and the settings screen finishes by starting a second MainActivity, which reloads the page; on iOS the values live in the settings screen, so changing them means leaving the page. The useful shape is a test loop: run one page and walk it through no inset, a home bar, a notch and back, rather than relaunching with different intent extras each time. After changing a value, confirm the page moved, because from the native side a change that never reached the page looks exactly like one that did.

The keyboard takes the bottom edge

While a text field has focus, Android hands the bottom of the screen to the IME and the page’s bottom inset goes to zero, whatever the host asked for. The page below requested 180px, which is 92 CSS px at dpr 1.96, and the blue band is the safe area it was given:

Keyboard closedKeyboard open
A page reading 77 21 92 21, with a blue band along all four edges, above a text fieldThe same page reading 77 21 0 21 with the keyboard up: the top, left and right bands are unchanged and the bottom band is gone

77 21 92 21 becomes 77 21 0 21, and the other three edges are untouched. The override is still set throughout and the value returns when the IME closes, so nothing reports a problem.

This matters most for the inflating case. A kiosk page that inflates its bottom inset to clear a home bar the device does not have loses exactly that margin the moment an input takes focus. The keyboard really does occupy that space, so read it as the platform handing the edge back rather than as the override failing, and do not build a layout that needs the bottom inset to survive a keyboard. iOS was not measured: raising the software keyboard needs Simulator.app, which this run did not have.

The keyboard is a second channel

The keyboard arrives through Android’s inset types, so this section is Android-only. iOS has a single safe area and reports the keyboard only as a visualViewport resize, with no inset type to override.

env(safe-area-inset-*) is only one of the two things a WebView reads out of the same WindowInsets. Chromium’s WebView insets documentation splits them:

WebView respects the following inset types: displayCutout(), systemBars(), ime(). displayCutout and systemBars are forwarded to the web content via the safe-area-insets API. The ime() insets directly affect the size of the Visual Viewport.

The keyboard does not appear in env(). It arrives as a visual viewport resize, and has done since WebView M139. An override that zeroes the safe-area types and leaves ime() alone misses the keyboard.

What Android reports, before and after the keyboard

Measured on a Pixel 7 Pro (Android 17), in Gadgetbridge’s app-manager screen, by logging every inset dispatch. Values are device pixels; divide by ~3 for CSS pixels.

Keyboard closed
statusBars() top = 334 displayCutout() top = 144 navigationBars() = 71
statusBars() top334
displayCutout() top144
navigationBars() bottom71
ime() bottom0
systemBars() = status + nav + caption71
Keyboard open
statusBars() top = 334 displayCutout() top = 144 navigationBars() = 966 ← the keyboard IME
statusBars() top334
displayCutout() top144
navigationBars() bottom966
ime() bottom966
systemBars() = status + nav + caption966

navigationBars() grew from 71 to 966, the height of the keyboard. Gadgetbridge pads its content with getInsets(systemBars() | displayCutout()), so that padding grew by the keyboard too.

Why the page then sees the keyboard twice

Two independent mechanisms each make room for the keyboard:

1 · The app makes room
Gadgetbridge pads android.R.id.content by systemBars()
the padding the app added the WebView's box, now 895px shorter keyboard
The WebView is an ordinary view. Padding its parent shrinks it, so the page's layout viewport gets shorter. webview height 2715 → 1820 px
2 · The WebView makes room
The WebView reads ime() itself and shrinks its visual viewport
layout viewport, from the box on the left the part the page can still see, the visual viewport keyboard
Chromium's own rule: “the ime() insets directly affect the size of the Visual Viewport.” Nothing to do with the app's padding. this shrink is the WebView's doing
layout viewport (100dvh)912.6 → 611.8 css px
visual viewport611.8 → 310.9 css px
keyboard height300.8 css px

The page’s layout viewport shrinks because its container got shorter; its visual viewport shrinks because the WebView applied ime(). The keyboard is subtracted twice, and the page is left with a band of empty, scrollable space between its content and the keyboard.

The double-count needs a host that pads by systemBars() on a device where that mask carries the keyboard. The launcher this post uses elsewhere does not pad, so it does not show this; I could not reproduce it there in any configuration, including matching Gadgetbridge’s target SDK, theme, soft-input mode and content padding.

Which of the two to remove

The two captures below are the same build, page, override (150/40/180/40) and keyboard; they differ by one line, whether the override zeroes ime() as well.

ime() passed throughime() zeroed
The debug page with the keyboard up, reading innerHeight 1192px and visualViewport 842.8px, with the input focused above the keyboardThe same page and keyboard, now reading innerHeight 1192px and visualViewport 1192.36px — the page never shrank, and the keyboard covers the lower part of it

Read the keyboard card rather than the inset bands; the bands read 77 21 92 21 in both:

ime() passed throughime() zeroed
innerHeight11921192
visualViewport.height842.81192.36
keyboardupup

Passing it through, the visual viewport sits 349.2px below the layout viewport, which is the keyboard height, and the page can scroll the focused field into view. Zeroing it, the WebView gets no ime() inset: the visual viewport stays at the layout viewport, and anything laid out against the bottom of the viewport is behind the keyboard with no event to say so. Chromium warns about exactly this in the same document:

If you decide to opt out, some features may not work as expected. The keyboard may cover inputs near the bottom of the page and in some cases, scrollIntoView may not work properly.

Zeroing a type without stopping the dispatch

There is a second way to get this wrong, and it is the tempting one. A host that removes the insets by returning WindowInsetsCompat.CONSUMED from a listener on the WebView’s parent is not replacing the values, it is stopping the dispatch. ViewGroup.dispatchApplyWindowInsets skips its children once the insets come back consumed, so the WebView is never handed anything at all, ime() included.

The two runs below are the same build, page and device, with the listener’s return value as the only difference:

Android Returning the insets instead of consuming them

Same build, same page, same device. The listener's return value is the only difference.

Returns a rebuilt instance
parent listener WebView gets sysBars 0, ime 686 page: env 0, visual viewport 842.8
The insets still flow, so the keyboard still arrives.
Returns WindowInsetsCompat.CONSUMED
parent listener WebView gets nothing (0 dispatches) page: env 0, visual viewport 1192.36
The page reads zero, which looks like success, but the keyboard is invisible to it.
1192.36 842.8 css px keyboard opens CONSUMED rebuilt 349.56 px of keyboard -4 s +2 s
WebView dispatchesenv()innerHeightvisualViewport.height
rebuilt30 0 0 011921192.36 → 842.8
CONSUMED00 0 0 011921192.36 (flat)

Both runs leave the page reading 0 0 0 0, so both look like a fix in a screenshot:

Two crops of the same page with the keyboard up. The rebuilt run reads visual viewport 842.8px, the CONSUMED run reads 1192.36px, and both read insets 0 0 0 0

What separates them is the keyboard. The rebuilt run steps the visual viewport down by the keyboard’s height; the consumed run does not move. A page that lays out against the bottom of the viewport ends up behind the keyboard, with the same missing event as zeroing ime() directly, except that nothing in the page’s env() values hints at it.

interactive-widget cannot help from the page

The obvious page-side answer is the viewport meta:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=overlays-content">

overlays-content promises that neither viewport is resized by the keyboard. Android WebView ignores it. Chrome for Android has honoured interactive-widget since Chrome 108, and that announcement says so outright:

Note: These changes do not affect WebView

Chromium’s issue tracker agrees, in the entry recording it: “There is no intended behavior change for Android WebView. The Android app is responsible for sizing the WebView and can implement either mode via windowSoftInputMode”, and its compatibility table reads Android Chrome: OK · iOS Safari: OK · iOS WKWebView: OK · Android Firefox: OK (>= 132.0) · **Android WebView: FAIL**.

Blink does parse it: ViewportDescription.virtual_keyboard_mode is populated from the meta and forwarded to the browser process, and the embedder does not act on it. The capture below declares overlays-content; the page reports it, and nothing else moves:

declared overlays-content
The same page declaring interactive-widget=overlays-content, shown in green in the keyboard card, still reading innerHeight 1192px and visualViewport 842.8px

innerHeight 1192, visualViewport 842.8, the same two numbers as the ime()-passed-through capture: the keyboard resized the visual viewport as if the property were absent.

Reading what the page sees

The page’s env() values are the only evidence that any of this worked, and the obvious way to read them reports a working override as broken. Every table above comes from a page that samples on a timer or on a frame rather than once. A single read from the same pages at load would have reported zeros.

A single read lands in the zero window

WebKit bug 191872, open since 2018 and still open:

These CSS variables are not available on the initial load of the app, or after the window load event, or the document DOMContentLoaded event. These CSS variables only seem to be available at some arbitrary time after the app is loaded.

A comment from 2023 notes the same on iOS 16, and that because it is non-deterministic it is “very hard to work around manually”. Measuring in webView(_:didFinish:) lands inside exactly the window where the values are guaranteed to be zero.

I hit this. My first test read once on didFinish, got 0px on all four edges, and I wrote it up as “the override does not work on iOS”. It does work. Sampling 12 times over 6s and recording the history:

label=edgeToEdge=true top=150.0 right=40.0 bottom=180.0 left=40.0
| native webview=t:150 r:40 b:180 l:40 window=t:62 r:0 b:34 l:0
settled={"top":"150px","right":"40px","bottom":"180px","left":"40px"}
distinctReadings=2
history:
[0] {"top":"0px","right":"0px","bottom":"0px","left":"0px"}
[1] {"top":"150px","right":"40px","bottom":"180px","left":"40px"}

Reading [0], which is what the single read returned, looks like total failure.

How many samples it takes is not predictable:

RuntimeDeviceDistinct readings
18.6iPhone 16 Pro2, zero then override
26.5iPhone 17 Pro1, override immediately
27.0iPhone 17 Pro2, zero then override
27.0iPhone 18 Pro2, zero then override
27.1iPhone Duo1 on the first run, 2 on a later one

Twelve samples, half a second apart, starting at page finish. A count of 1 means the very first sample was already correct.

The Duo is the row to read twice. The run behind this table found the override already correct on its first sample. A later run, same device and same runtime, read all four edges as zero first and needed the second sample. Identical hardware and identical runtime, two answers, which is the argument for sampling over time rather than for counting.

The same iPhone 17 Pro needed two samples on 27.0 and one on 26.5. Any test of this has to sample over time; a single read is a coin flip.

Android reads zero at the same moments and recovers faster. On the OnePlus the page reported 0 0 0 0 while its script ran, at the load event, and on the first animation frame, then 77 21 92 21 on the second frame about 40 ms later. So the rule is the same on both platforms and the reason differs. Android needs one frame; iOS needs a wait that is not predictable.

Resolving env() through a probe element

env() has no JavaScript API, so the only way to observe what a page sees is to resolve it through a probe element and read the computed style:

var p = document.createElement('div');
p.style.cssText = 'position:fixed;top:0;left:0;visibility:hidden;' +
'padding-top:env(safe-area-inset-top,0px)' /* … */;
document.body.appendChild(p);
var cs = getComputedStyle(p);
cs.paddingTop; // "150px"

Sample it per animation frame rather than once. One read can land in the zero window, and a per-frame sampler both avoids that and shows you when the value arrived. That is how the runtime section above was measured.

This is the probe from EnvProbe.readEnv, with the comment it carries explaining why the probe element exists at all: there is no JS API for env(), so reading the value out of the page is the only honest check.

Three guards make a reading trustworthy:

Prove env() is supported. Read it with a fallback. If env(safe-area-inset-top, 77px) returns 77px, env() is unsupported and every other number is void. Reading 0px proves it resolved to a genuine zero. Pick a fallback no override is likely to produce, because a sentinel that collides with a real value reports a working env() as unsupported. The viewport-fit=cover half of that guard is readViewportFit in the same file.

Capture the native value too, so the two can be compared:

let safe = webView.safeAreaInsets // t:150 r:40 b:180 l:40
let window = webView.window?.safeAreaInsets // t:62 r:0 b:34 l:0

If they diverge, the override never reached env(). If they agree and the page still reads zero, the reading is what is wrong, not the override.

Agreement is necessary but not sufficient, and condition 2 is the case that proves it. There, safeAreaInsets and env() matched exactly while the page was laid out in the wrong viewport. So a check that only compares inset numbers passes on a page that is visibly broken. Pair it with the geometry, which is the one reading that caught it:

[document.documentElement.clientWidth, document.documentElement.clientHeight];
// "402 x 874" under .never, "322 x 544" under .always, same override

Test matrix

iOS, all Simulator, Xcode 27.0 (27A266a), deployment target iOS 17.0. The device names are the ones the run used. On a current Xcode the device set differs, so “iPhone 18 Pro” is a 27.2 device there and 27.0 offers iPhone 18 Pro Max instead. Match on the runtime and the reported insets, not on the device name:

RuntimeDeviceOverridesBlank → realCondition 2
18.6iPhone 16 Pro150/40/180/40 ✓62/0/34/0 ✓all four values ✓
26.5iPhone 17 Pro150/40/180/40 ✓not recorded.never and .always ✓
27.0iPhone 17 Pro111/44/222/33 ✓62/0/34/0 ✓not run
27.0iPhone 18 Pro111/44/222/33 ✓62/0/34/0 ✓not run
27.1iPhone Duo150/40/180/40 ✓not recordednot run

Android, physical device on Android 15 (API 35), WebView 153.0.8010.36, dpr 1.96:

ConfigRequested (px)Page env()
noneno override41 0 0 0
override150/40/180/4077 21 92 21

The launcher screens

Both hosts expose the same controls: a URL, optional JavaScript, an edge-to-edge switch, and one field per edge. The Android build is captured with edge-to-edge off, which is its default. That is why the four inset fields are greyed out. The iOS build is captured with it on and 150 / 40 / 180 / 40 entered.

Android, edge-to-edge off (default)iOS, edge-to-edge on, override entered
The Android launcher with edge-to-edge off: the four safe-area inset fields are greyed out and the label says the override needs edge-to-edge, WebView M136+ and Android 11 or newerThe iOS launcher with edge to edge on and the values 150, 40 and 180 entered in the per-edge inset override fields

Configurations used for every screenshot in this post:

AndroidiOS
Buildcom.amoshydra.androidapp 1.0com.amoshydra.iosapp 1.0
WebView / SDKWebView 153.0.8010.36 (M153)iOS Simulator 18.6
Devicephysical, 1080×2340, dpr 1.96iPhone 16 Pro simulator, 402×874 pt, dpr 3
Edge-to-edgeonon
Inset unitspixelspoints
Pagedemo-viewport/?fit=coverdemo-viewport/?fit=cover
Override requested150 / 40 / 180 / 40150 / 40 / 180 / 40

Sources

The two hosts and the debug page that every number above comes from. File links are pinned to a commit, so the line numbers hold.

Appendix: iPhone Duo

The iPhone Duo is Apple’s first foldable, announced 9 September 2026, a 5.4” outer display (1398×2034) that opens to a 7.6” inner one (1878×2670). It ships with iOS 27.1, which is also the floor the simulator enforces for the device type.

Worth a look because its safe area is a different shape from every Pro model, and it changes shape twice over. No override set, edge-to-edge on, all four poses, measured on both SDKs:

The SDK decides whether you get edge-to-edge at all

Start with Xcode 27.0, because the number that comes back is not the display size. The Duo’s inner panel is 951 points wide, and an app built with the iOS 27.0 SDK does not get it: iOS gives the app a window of 871 points and leaves the remaining 80 in a black rail, so the page never learns the extra space exists.

The status bar is a vertical bar on the Duo, occupying a reserved region at the outer edge. Whether an app may extend underneath it is decided by the SDK it is built against, not by anything in the app:

Built againstOuter displayInner display
Pre-iOS 27 SDKLetterboxedLetterboxed
iOS 27.0 SDKLetterboxed871 of 951 points
iOS 27.1 SDKEdge to edgeEdge to edge

Same app, same code, same page, same pose — only the SDK differs. Each row is one orientation, the 27.0 build on the left and the 27.1 build on the right:

PoseXcode 27.0 — letterboxedXcode 27.1 — edge to edge
Outer, portraitOuter display in portrait, built with Xcode 27.0: the page reports a 386 by 678 viewport and insets 0 0 34 0, with a black rail carrying the status bar down the rightOuter display in portrait, built with Xcode 27.1: the same page now reports 466 by 678 and insets 0 84 34 0, filling the width with the status bar as a real 84 point inset
Outer, landscapeOuter display in landscape, built with Xcode 27.0: the page reports 678 by 386 rather than the panel's 678 by 466, letterboxed by the same 80 points, and the insets read 0 34 20 34Outer display in landscape, built with Xcode 27.1: the page reports the full 678 by 466 and insets 0 0 34 84, with the status bar's 84 point reserved region on the left where the camera cutout is
Inner, landscapeInner display in landscape, built with Xcode 27.0: the page reports 871 by 669 rather than the panel's 951, with a black rail at the rightInner display in landscape, built with Xcode 27.1: the page reports the full 951 by 669 and insets 0 84 34 0
Inner, portraitInner display in portrait, built with Xcode 27.0, showing the letterbox down one sideInner display in portrait, built with Xcode 27.1, edge to edge

Read the readouts across each row and one number dominates: the rail is 80 points in all four, on both displays, in both orientations. 386 is 466 less 80. 678 × 386 is 678 × 466 less 80. 871 is 951 less 80. 669 × 871 is 669 × 951 less 80. It is a fixed deduction, not a proportion of the display, which is why the outer display loses a quarter of its width while the inner one loses less than a tenth of its height.

The other number that matters is the 84, and it is the status bar’s real reserved region — but it moves. It is on the right in outer portrait (0 84 34 0), on the left in outer landscape (0 0 34 84, the edge with the camera cutout), on the right in inner landscape (0 84 34 0), and along the top in inner portrait (82 0 34 0). It follows the hardware, so a page cannot hardcode which edge it lands on; it has to read env() every layout.

That 34pt on a 27.0 build is the trap: it is the letterbox boundary being reported as if it were a safe area, not a system inset. A 27.1 build reports 0 there and instead gives a real 84pt for the status bar, which is the inset a page can actually lay out against.

Snapped to a side, unfolded

Split the inner display and the letterbox stops being a fixed 80pt deduction from a known display size — it comes out of whatever region iOS actually grants the app, and the page is told about it in a way that no longer adds up.

Unfolded, splitXcode 27.0Xcode 27.1
Snapped leftUnfolded with the app snapped to the left half, built with Xcode 27.0: the page reports a 469 by 669 window and insets 0 34 20 34, matching the 27.1 window widthThe same arrangement built with Xcode 27.1: the window is an identical 469 by 669, and the insets change to 0 0 34 0 because the status bar's reserved region no longer abuts the app
Snapped rightUnfolded with the app snapped to the right half, built with Xcode 27.0: the window shrinks to 389 by 669 and the insets stay 0 34 20 34, with a black rail carrying the status bar down the rightThe same arrangement built with Xcode 27.1: the window is 469 by 669, and the insets are 0 84 34 0 because the status bar's 84 point reserved region now sits inside the app's own half
Window469 × 669 → 389 × 669469 × 669 either way
Insets0 34 20 34 either way0 0 34 0 → 0 84 34 0

Three things fall out, and the last is the one that breaks a layout.

The 80pt comes out of the app’s own region. Snapped right, the 27.0 build gets 389 points where the 27.1 build gets 469 — the same 80 the letterbox took from the full-width case. It is not a fixed margin around the display; it is subtracted from whatever the app was given, so the narrower the region, the more of it the rail consumes.

Snapped left, 27.0 loses nothing. It reports the same 469 points as 27.1, which is what makes the bug easy to miss: a build can look correct in one arrangement and be 80 points short in another, with no change in code.

The reported inset is wrong by 50pt exactly where it matters. Snapped right on 27.0 the page reads 0 34 20 34. It needs 84 — that is the status bar’s real reserve, as the 27.1 build on the same row reports. So a page that pads its content by env(safe-area-inset-right) pads 34 against a real requirement of 84 and under-pads by half. The override does not help here: it can only add insets, and the value it would be correcting is one the page believes.

Relaunch before you measure. Rotating the Duo out of split view does not re-apply the letterbox. Snap the app to one side, turn the device to portrait, and the window comes back at the full 669 × 951 even on a 27.0 build — no rail, and the readout looks exactly like a 27.1 result. Hiding and reopening the app puts the 669 × 871 rail straight back. So a 27.0 window that looks edge-to-edge is stale geometry carried over from the split view rather than a change in behaviour, and a measurement taken in that state records a full-size window that no clean launch of that build will ever produce.

This matters for the override. The left and right insets it writes are per-edge, so it still works either way — but on a 27.0 build it is writing into a window the system already cropped, and any page compensating for env(safe-area-inset-right) is compensating for a boundary that would not exist on a 27.1 build. For the Duo, use Xcode 27.1 or later. Getting there is only DEVELOPER_DIR:

Terminal window
DEVELOPER_DIR=/path/to/Xcode-27.1.app/Contents/Developer \
xcodebuild -project WebViewLauncher.xcodeproj -scheme WebViewLauncher \
-sdk iphonesimulator build CODE_SIGNING_ALLOWED=NO

Note that the runtime is not the SDK. A 27.1 simulator runtime is enough to create the device and run on it, which is how the two SDKs can be compared on the same pose, but the app still compiles against whatever SDK the selected Xcode ships.

Two things fall out of that. No top inset, ever. Compare the Pro models at 62 0 34 0: the outer display has no notch and, in the simulator, no status-bar inset, so the top is 0 in all three states. A 150pt top override here is not restating an existing value the way it is on a Pro, it is manufacturing an inset the hardware does not have, which is the case where the override is most useful and hardest to test against real hardware.

The insets move with orientation and fold. In portrait the whole 34pt is at the bottom, for the home indicator. Rotate to landscape, or open the inner display, and it splits: 34pt on each side, 20pt at the bottom. Anything that hard-codes env(safe-area-inset-bottom) to size a footer is wrong in two of these three states, and nothing in CSS warns you.

With 150 / 40 / 180 / 40 requested, the outer display reported exactly those values back. Whether the very first sample already had them varies between runs, which the timing table above records.

back