リモート開発メインのソフトウェア開発企業のエンジニアブログです

Tauri 2 on iOS: A simple fix for WKWebView safe-area inset issues

I’ve been building a mobile app with Tauri 2 for the past few months, and one of the first rough edges I hit on iPhone and iPad is how the web layer behaves inside WKWebView. iOS adjusts the scroll view’s content insets automatically so content sits above the home indicator and below the status bar. It sounds reasonable, but you often get a colored band at the edges, usually the bottom, while the page still lays out as if it were full screen.

If you’re not already familiar with it, Tauri 2 is a framework for building cross-platform desktop and mobile apps using a Rust backend and a web frontend. On mobile, it wraps your web UI in a native WKWebView (iOS) or WebView (Android) and communicates with the Rust layer through a bridge. It’s a compelling alternative to React Native or Flutter if you want to ship to iOS, Android, macOS, Windows, and Linux from a single web-based UI, without shipping a JavaScript runtime.

Why this happens

On iOS 11+, UIScrollView.contentInsetAdjustmentBehavior defaults to .automatic. WKWebView’s internal scroll view picks up safe-area insets from the enclosing view controller and pushes the document down/up by those insets, while CSS still lays out against the full viewport. The uncovered strip then reveals the window’s backgroundColor, which often appears as a bottom safe area inset/gap.

This post summarizes the fix I landed on. It comes down to three moves:

  1. Opt into full-bleed layout: set viewport-fit=cover so the web view can draw under the safe-area regions.
  2. Stop automatic inset adjustment on the WKWebView scroll view: force contentInsetAdjustmentBehavior = .never so iOS does not add its own padding.
  3. Own padding in CSS: use env(safe-area-inset-*) to position toolbars, tab docks, and modals against the real device chrome.

The native side of step 2 is handled by a small Tauri plugin I wrote and published, tauri-plugin-ios-webview-insets. On load it sets WKWebView.scrollView.contentInsetAdjustmentBehavior = .never, so the web stack and my styles stay aligned instead of fighting each other.

What I wanted

  • The web app draws edge to edge (true fullscreen from the layout’s perspective).
  • Notches, Dynamic Island, and the home indicator are respected by explicit padding or positioning, not by an opaque stack of native insets fighting my styles.
  • Any sub-pixel gap between native chrome and the web surface matches the app background so it is not visually jarring.
Moba Pro

Step-by-step setup

1. Enable CSS safe-area environment variables

Add viewport-fit=cover to the viewport meta tag in the HTML shell. Without it, env(safe-area-inset-*) resolves to 0px on iOS because the web view lays out inside the safe area rather than under it.

<meta
  name="viewport"
  content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>

I also keep the following two meta tags in my shell, with a caveat: they only take effect when the page runs as a home-screen standalone web app. Inside a WKWebView embedded in a native Tauri host they are effectively no-ops—the status bar style is driven by the hosting view controller / Info.plist. I keep them so the browser preview and a standalone install still render consistently.

<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

2. Disable automatic WKWebView content inset adjustment (the Tauri plugin)

contentInsetAdjustmentBehavior is a property on UIScrollView that WKWebView exposes through its scrollView. Apple documents it in contentInsetAdjustmentBehavior; the .never case means the scroll view does not adjust its content insets automatically based on the safe area, exactly what I want when env(safe-area-inset-*) in CSS acts as the single source of truth for this approach.

I published tauri-plugin-ios-webview-insets to wire that behavior into Tauri 2. On load the plugin sets WKWebView.scrollView.contentInsetAdjustmentBehavior = .never, which stops iOS from applying automatic safe-area insets to the scroll view in a way that conflicts with a full-bleed web layout. If you are solving the same problem, you can depend on it like any other crate.

src-tauri/Cargo.toml — declared under the Tauri mobile target block. The same cfg covers iOS and Android because Tauri groups mobile dependencies together; on Android the plugin is a no-op, on iOS it applies the inset fix:

[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
tauri-plugin-ios-webview-insets = "0.1"

src-tauri/src/lib.rs — register the plugin only on mobile:

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let builder = tauri::Builder::default();
    #[cfg(any(target_os = "android", target_os = "ios"))]
    let builder = builder.plugin(tauri_plugin_ios_webview_insets::init());

    builder
        // ... other plugins and setup ...
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Rebuild the iOS project after dependency changes (cargo will pull the crate; Xcode should pick up the linked static library as usual for Tauri mobile).

3. Apply safe areas in CSS (I own the padding)

With viewport-fit=cover in place and automatic inset adjustment disabled, env(safe-area-inset-*) becomes the right tool for spacing chrome away from physical edges.

I use a fallback chain so the same styles work on desktop and in browsers that do not define the variables:

/* Example: top bar padding */
.pos-header {
  padding-top: calc(16px + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
}

/* Example: fixed bottom control, above home indicator */
.pos-tab-dock {
  bottom: calc(8px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
}

If I expose --safe-area-* myself (for example via another plugin), the pattern var(--safe-area-inset-*, env(safe-area-inset-*, 0px)) lets native-provided CSS variables win when present, and env() supplies values on device when they are not.

4. Align the native window background with the theme

I set Tauri’s window backgroundColor (in tauri.conf.json) to a color that matches the page background or gradient edge. That way, any tiny gap during transitions or compositing blends with the UI instead of flashing a mismatched strip.

Example value in my project: #0c1222 (this is the dark-theme color used in my app; the light-theme screenshots further below simply make the before/after contrast clearer).

"app": {
  "windows": [
    {
      "backgroundColor": "#0c1222"
    }
  ]
}

Omitting backgroundColor entirely is not a full fix: without it, the native window defaults to a transparent or white background, which can make the status-bar gap at the top disappear in a light-themed app, but the bottom gap at the home indicator remains, because that gap is caused by the scroll view’s automatic inset adjustment, not by the window color. Removing backgroundColor therefore only masks one side of the problem; setting it to a matching color (and applying the plugin) resolves both.

Visual comparison

Both captures are from the same screen on an iPhone 17 Pro, iOS 26.4, in the same light theme. The only change between them is the fix described above.

The same result on an actual device:

Based on the screenshots above, the web surface and native container finally line up on the same edges, and the env(safe-area-inset-*) padding I set in CSS is the spacing I see on the device.

Troubleshooting

If inset issue still shows up on device:

  • Rebuild the iOS target after touching the Rust side. A stale static library is the most common cause: run cargo clean in src-tauri/, then run your Tauri iOS build again (for example npm run tauri ios build) so the plugin is relinked.
  • Confirm the meta tag reached the bundle. Inspect the built index.html in dist/ — a missing or stripped viewport-fit=cover makes env(safe-area-inset-*) resolve to 0px.
  • Check on a notched device or a simulator profile with a notch. Simulators without a notch report 0px for safe-area-inset-top/bottom, which can be misread as “the plugin is not working.”
  • Verify tauri.conf.json backgroundColor matches the page’s top/bottom gradient stops. A mismatched color makes even sub-pixel gaps obvious.
  • Confirm the plugin registered. A quick sanity check is to log inside the conditional #[cfg(any(target_os = "android", target_os = "ios"))] block, or attach Safari Web Inspector and read getComputedStyle on an element that uses env(safe-area-inset-bottom).

Wrapping up

Tauri has worked well for me on mobile, but I still sometimes spend longer than I expect on small issues like this. I kept the plugin narrow and handled spacing in CSS so future UI tweaks stay straightforward. If this saves someone a bit of that time, sharing it was worth it.

← 前の投稿

systemd で運用しているサービスに AWS Secrets Manager からクレデンシャル情報を環境変数で渡す

次の投稿 →

コメントを残す