RailsFast Native - Safe areas & fixed docks
Native shells run your Rails HTML edge-to-edge β the WebView paints under the system bars (Android's navigation bar / status bar, iOS's home indicator / notch). Anything pinned to the bottom (a "slide to start", a submit dock, an "Iniciar sesiΓ³n" pill, an active-ride banner) has to reserve exactly the right amount of space to clear whatever system UI sits beneath it β no more, no less, on every device.
RailsFast handles this with one CSS contract β a four-sided --safe-t / --safe-r / --safe-b / --safe-l and a --dock-add mode toggle β fed by a tiny native shell bridge on Android. Bottom (and a little top) is where the pain first shows up, so most of this page is about that; the horizontal pair is the Android 15 edge-to-edge follow-up covered further down. This page explains the contract, why env(safe-area-inset-*) alone isn't enough on Android, how to build a dock that's correct everywhere, and the edge-to-edge realities (four-sided insets + the unavoidable Play Console warning) you'll hit on targetSdk 35+.
If you just want to ship a dock: wrap your screen in the railsfast/native/dock_screen component (or reach for a .native-* utility in your own markup) and you get all of this for free. The rest of this page is the why.
The one rule
Clear every device inset through a named .native-* utility β never inline the max()/calc() arithmetic in a view, and never hand-roll env(safe-area-inset-*). One utility per job:
| Surface | Utility |
|---|---|
| fixed dock β a button that must float above the opaque Android bar | .native-dock-safe |
| scroll-body bottom β the last row clears the bar, sitting flush | .native-safe-b |
| top clearance under the status bar / notch | .native-safe-t |
| horizontal edge-pinned chrome (landscape cutout / waterfall) | .native-safe-x |
Each utility is one line of CSS in application.css; a view just adds the class (plus a --*-base var when the gutter isn't the 1rem default). Under the hood the dock utility is:
padding-bottom: max(calc(var(--safe-b) + var(--dock-add) * BASE), BASE)
BASE is the breathing room you want when there's no system inset (e.g. 1.125rem), set per-call via the utility's --*-base var. And never pull a dock down with a negative bottom β Android's nav bar is opaque and tappable, so anything under it is buried and un-tappable.
A native_safe_area_contract_test fails CI if a view re-inlines a padding safe-area formula, so "the arithmetic lives in one place" is enforced, not just advised β see The contract test below.
Why not just use env(safe-area-inset-bottom)?
On iOS you can β WKWebView's env() is context-correct, and RailsFast leaves it as the default. On Android env() is wrong in two ways, so the native shell overrides the vars:
1. Version floor. Chromium only started mapping the navigation bar into a WebView's env(safe-area-inset-bottom) in M136 (2025-04, fullscreen) / M144 (2026-01, all WebViews). Below that it returns 0 β so a full-bleed CTA would fall back to only BASE and get buried under the opaque ~48dp bar. The Android shell only nudges users to update their WebView (a dismissible dialog), so 120β139 is a real, un-updatable tail. (108, sometimes cited, is display-cutout/notch support β not the nav bar.)
2. Spurious inset on embedded WebViews. Even on a current WebView, env() reports the whole window nav-bar inset (~48px) even when the WebView is laid out above the native tab bar (activity_main.xml constrains each tab's FragmentContainerView bottom_toTopOf @id/bottom_nav; the tab bar itself carries the nav-bar inset). The nav bar doesn't touch that WebView, so ~48px of dock padding is spurious β a visible gap above the tab bar. Chromium does not bounds-check env() to the WebView's on-screen rect (measured: env=48 on both a 795px tab-embedded WebView and a 923px full-bleed one).
How the vars get their values
app/assets/tailwind/application.css defines the defaults:
:root {
--safe-t: env(safe-area-inset-top, 0px);
--safe-r: env(safe-area-inset-right, 0px);
--safe-b: env(safe-area-inset-bottom, 0px);
--safe-l: env(safe-area-inset-left, 0px);
--dock-add: 0;
}
All four sides are defined even though bottom/top do the heavy lifting β see The four-sided contract below for why --safe-l / --safe-r exist and which surfaces read them.
- iOS / desktop / mobile web use these as-is β
env()is correct, and--dock-add: 0collapses the dock formula tomax(var(--safe-b), BASE). - Android β
railsfast-android'sSafeAreaInsetBridgecomputes the real overlap of each safe-drawing band (systemBars() or displayCutout()) with the WebView's window rectangle and sets all four--safe-*vars ondocumentElement(inline style beats the:rootdefault), plus--dock-add: 1. Each side is0when a native bar (tab bar / toolbar) sits between the WebView and that system inset, and the full inset when the WebView reaches the screen edge β a numeric, version-independent replacement for the bounds-check Chromium doesn't do.
The bridge installs from WebFragment / FreezableWebBottomSheetFragment on onWebViewAttached, re-applies on onColdBootPageCompleted / onVisitCompleted (a cold boot resets documentElement's inline style), and on a passive layout listener (rotation, gestureβ3-button nav-mode switch, tab show/hide, keyboard adjustResize). It deliberately does not use setOnApplyWindowInsetsListener on the WebView β that would replace the WebView's own inset handling and break the env() fallback.
Additive (Android) vs max() (iOS/web): what --dock-add is for
The two platforms' bottom insets are physically different, so one arithmetic can't be identical:
| Platform | Bottom inset | Desired dock position | --dock-add |
Formula collapses to |
|---|---|---|---|---|
| Android 3-button | opaque, tappable ~48dp bar | BASE above the bar |
1 |
var(--safe-b) + BASE |
| Android gesture | thin pill | same (inset is tiny) | 1 |
var(--safe-b) + BASE |
| iOS | thin home-indicator pill | at the zone top | 0 |
max(var(--safe-b), BASE) |
| Web | browser inset / none | at the inset top | 0 |
max(var(--safe-b), BASE) |
On an opaque bar you must clear it and leave a margin (additive). On iOS's thin indicator, sitting a further BASE above the whole 34pt zone reads as a floating gap β so you sit at the zone top (max).
Behaviour matrix (full-bleed vs tab-embedded)
| Context | --safe-b |
--dock-add |
padding-bottom |
Result |
|---|---|---|---|---|
| Android full-bleed (onboarding / welcome) | 48px | 1 | 48 + BASE |
dock BASE above the opaque bar |
| Android tab-embedded (a pushed settings screen) | 0 | 1 | BASE |
dock BASE above the tab bar |
| iOS full-bleed | 34px | 0 | max(34, BASE) |
dock at the home-indicator top |
| iOS tab-embedded | 34px | 0 | max(34, BASE) |
same, no floating gap |
| Web | env |
0 | max(env, BASE) |
at the inset top |
Android 15 edge-to-edge: the four-sided contract
Everything above is framed around the bottom (and a little top) because that's where the pain first shows up β a CTA buried under the nav bar. But the safe-area contract is four-sided: --safe-t, --safe-r, --safe-b, --safe-l. The horizontal pair is the edge-to-edge follow-up, and it exists because of a hard Android 15 reality.
Edge-to-edge is enforced for targetSdk 35+. On Android 15+, an app targeting SDK 35 or higher is laid out edge-to-edge by default β the system bars go transparent and your content draws under them. There's no opt-out for targeted apps (Android 16 removed the last opt-out flag entirely). Every Activity must call enableEdgeToEdge() and then consume the insets itself.
enableEdgeToEdge() is only the window-mode switch β it doesn't pad anything, it just says "you own the whole window now, including the parts under the bars." App-owned content still has to reserve space for the real insets. In RailsFast that's a two-part job: the native shell reads WindowInsets, and the web layer reserves space via the --safe-* vars. Neither half is optional on SDK 35+.
Why horizontal matters. In portrait, left/right insets are 0 β a phone's status/nav bars are top/bottom β so --safe-l / --safe-r are a no-op and every max(var(--safe-l), X) collapses to X (byte-identical to a plain X padding). Horizontal insets go non-zero only with:
- a display cutout / notch in landscape β the camera hole moves to a side edge when the device rotates,
- a waterfall display β curved side edges the system reserves,
- 3-button nav in landscape β the bar can sit on a side.
These are reachable whenever the app is not orientation-locked. RailsFast's Android manifest sets no screenOrientation, so a user can rotate an onboarding / auth / map screen into landscape and hit a side cutout β which is exactly why the contract went four-sided. (iOS ships portrait-only, so its left/right insets are physically 0; see iOS: nothing to do.)
On the native side, "safe drawing" is systemBars() or displayCutout(). Android's own guidance is that critical content should clear the union of the system bars and the display cutout, not the system bars alone. The bridge computes per-view overlap against that union and injects all four --safe-* vars. Native full-bleed surfaces (the Maps camera padding, floating map controls, bottom-sheet content) apply the same systemBars() or displayCutout() insets on left/right too, so a landscape cutout never clips a map control or a floating back button.
Do not "simplify" the native inset source back to systemBars() only. That fixes portrait top/bottom but silently drops landscape / cutout / waterfall clearance β the exact gap that shipped in the first pass and had to be re-opened.
Consume horizontal insets only on edge-pinned chrome. You do not need to sweep every px-4 / px-5 in the app. Content in a centered, constrained column (mx-auto max-w-md) already clears any phone cutout β in an ~800px landscape viewport the ~175px/side centering margin dwarfs a ~30β50px cutout, so its horizontal padding never bites. Only fixed / full-bleed / edge-pinned surfaces can touch a physical side edge, so those are the only ones that read --safe-l / --safe-r:
- the edge-pinned app header (its
justify-betweenbell/avatar row) - full-bleed hero / onboarding progress headers
- the native auth entry screen and the onboarding submit dock
- fixed action docks and the active-ride banner
absolute leftback overlays on the trip-map header and live-ride screen- the full-screen chat attachment overlay + its
absolute rightclose button
Each edge-pinned surface reaches for the horizontal (and top) utilities β max() against the design gutter under the hood, so portrait is byte-identical:
<header class="... native-safe-x native-safe-t" style="--safe-x-base: 1.25rem; --safe-t-base: 1.25rem">
An absolute-positioned overlay is the one exception: it pins an offset (top: / left: / right:), which a padding utility can't express, so it keeps reading the vars inline β left-[max(var(--safe-l),1rem)], right-[max(var(--safe-r),0.75rem)] β instead of a bare left-4 / right-3. The contract test's guard is padding-only, so these positional reads are exempt.
The Play Console "deprecated window APIs" warning
Once you ship edge-to-edge, Google Play Console will likely flag the release for deprecated window APIs:
android.view.Window.setStatusBarColorandroid.view.Window.setNavigationBarColorLAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
It's tempting to chase this to zero warnings. Don't. Here's the honest picture, verified by bytecode disassembly:
- Deprecated β removed. These APIs still exist and still function; on Android 15 they only no-op for gesture-nav bar color. A RailsFast app draws behind the bars and pads with insets β it never relied on bar colors β so runtime behavior is unaffected. The warning is a Play Console advisory, not a rejection; apps ship with it in production.
- It's almost always upstream library bytecode, not your code. The start points Play names are AndroidX (AppCompat, WorkManager), Material Components'
EdgeToEdgeUtils, and the Google Places SDK β not app source. A RailsFast app's own code and theme call none of these (it usesenableEdgeToEdge()+WindowInsetsCompat). Ajavapdisassembly of Material 1.14.0 (current at time of writing) showsEdgeToEdgeUtilsstill callsWindow.setStatusBarColor/setNavigationBarColorviainvokevirtual. Material is the app's design system (Theme.Material3.DayNight.NoActionBar) and can't be dropped. See the open upstream issue: material-components-android#4732. - It is therefore effectively unresolvable β and non-blocking. Short of the entire AndroidX/Material ecosystem removing these calls upstream, you can't reach zero. Static scanners (Play,
dexdump) report the presence of a symbol in bytecode without reachability or runtime-guard analysis, so they keep flagging it.
Keep the edge-to-edge-sensitive dependency stack reasonably current and move on. Review these as a group before a Play upload (not one-by-one), because they're precisely the surface Play names:
| Dependency | Role |
|---|---|
androidx.activity |
owns enableEdgeToEdge() |
androidx.core |
owns WindowInsetsCompat.Type (systemBars, displayCutout) |
androidx.appcompat |
flagged compat bytecode |
com.google.android.material |
EdgeToEdgeUtils β keeps the warning alive |
androidx.work |
flagged startup/compat bytecode |
com.google.android.libraries.places |
widget bytecode (if bundled) |
When pinning, verify the real constraint: e.g. androidx.core 1.19.0 needs compileSdk 36.1 β not a mythical "SDK 37" β so it's fine on a 36.1 app. Don't invent version blockers.
Downstream note: the Google Places SDK
Worth documenting because it bites edge-to-edge audits specifically. If your app bundles the Places Android SDK but only uses the programmatic client (FindAutocompletePredictionsRequest / fetchPlace) β never Google's bundled autocomplete/photo widget UI β the SDK still merges its widget Activity entries into your manifest. Those unused activities expand the edge-to-edge audit surface: Play can name an SDK activity as a deprecated-API start point even though your product flow never launches it.
Strip them from the merged manifest with tools:node="remove":
<activity android:name="com.google.android.libraries.places.widget.AutocompleteActivity" tools:node="remove" />
<activity android:name="com.google.android.libraries.places.widget.BasicPlaceAutocompleteActivity" tools:node="remove" />
<activity android:name="com.google.android.libraries.places.widget.PlaceAutocompleteActivity" tools:node="remove" />
<activity android:name="com.google.android.libraries.places.widget.internal.placedetails.photoviewer.PlacesLightboxActivity" tools:node="remove" />
This shrinks the manifest-reachable surface (a legit win for the separate edge-to-edge-attribution warning), but it does not clear the deprecated-API warning β Material keeps that alive regardless (see above). The only way to fully drop the Places widget bytecode is to remove the SDK entirely and proxy autocomplete/details through a server-side Places web-service API β a much larger API-key/billing/latency/abuse-control tradeoff, not worth it just to silence a static warning. If you ever adopt the widget UI, delete these removals and audit that UI as a first-party edge-to-edge screen.
Checklist: shipping on targetSdk 35+
- Call
enableEdgeToEdge()in everyActivity. It's the window-mode switch; without it you aren't honoring the SDK 35+ contract. - Consume the four
--safe-*vars through the.native-*utilities on fixed / full-bleed / edge-pinned web chrome β bottom+top always, left+right on the edge-pinned surfaces above. Never hand-rollenv(safe-area-inset-*)or inline themax()/calc()formula in a view; a contract test fails CI if you do. - Add the
SafeAreaInsetBridgeso Android injects real per-viewWindowInsetsoverlap into those vars βenv()alone is unreliable on WebView below ~M136/M144 and over-reports on tab-embedded WebViews. - Use
systemBars() or displayCutout()as the native inset source (notsystemBars()alone), and apply it to native full-bleed surfaces (map padding, floating controls, sheet content) too. - Keep the edge-to-edge-sensitive deps current (activity / core / appcompat / material / work / places), reviewed as a group before each Play upload.
- Accept the residual deprecated-API warning. It's upstream library bytecode (Material et al.), non-blocking, and effectively unresolvable. Don't chase zero, and don't drop the Places SDK expecting it to help β it won't.
Components
Two components cover the common cases β both read the safe-area SSOT for you, so you never hand-roll env().
railsfast/native/dock_screen β whole-screen dock
The screen IS a dock: a non-scrolling, viewport-filling flex column (fixed header / scrollable body / bottom dock). The dock is a flex child, never position: fixed, so it can't rubber-band with iOS WKWebView overscroll. Use it whenever the primary action must stay pinned while a body scrolls.
<%= render layout: "components/railsfast/native/dock_screen",
locals: { title: "Checkout", dock: (capture { render "checkout/pay_button" }) } do %>
...scrollable content...
<% end %>
railsfast/native/fixed_dock β fixed bar over content
A position: fixed bar pinned to the viewport bottom β a persistent CTA or a floating banner over normal, scrollable page content. Safe-area-aware via .native-dock-safe.
<%= render "components/railsfast/native/fixed_dock" do %>
<%= button_to "Continue", checkout_path, class: "btn btn-primary w-full" %>
<% end %>
<%# transparent floating banner, larger gap, non-blocking %>
<%= render "components/railsfast/native/fixed_dock",
base: "0.75rem", surface: false, class: "pointer-events-none" do %>
...banner...
<% end %>
Locals: base (breathing room above the inset, default 1rem), surface (white bar + hairline top border, default true), class (extra classes).
A position: fixed dock rubber-bands with iOS WKWebView overscroll on a document-scrolling page (it visibly "jumps" near the bottom). Use fixed_dock for overlays/banners and for pages whose document doesn't meaningfully scroll; when the whole screen should be a pinned dock, use dock_screen instead.
The .native-* utilities β the raw building blocks
The components apply these for you, but you can use them directly for bespoke chrome. Four utilities, one per job β each is the single home of its arithmetic, and each takes a per-call base var (default 1rem) for the gutter when there's no inset:
| Utility | Pads | Use for | Base var |
|---|---|---|---|
.native-dock-safe |
bottom, additive over the inset on Android | a fixed button dock that must float above the opaque nav bar | --dock-base |
.native-safe-b |
bottom, flush at the inset | a scroll body's last row (content resting at the bar is fine) | --safe-b-base |
.native-safe-t |
top | a sticky/fixed header under the status bar / notch | --safe-t-base |
.native-safe-x |
left and right | edge-pinned chrome that can meet a landscape cutout / waterfall edge | --safe-x-base |
<%# bespoke fixed dock β additive over the Android bar %>
<div class="fixed inset-x-0 bottom-0 native-dock-safe" style="--dock-base: 1.5rem">...</div>
<%# top-sticky header on a full-bleed screen %>
<header class="sticky top-0 native-safe-t" style="--safe-t-base: 1rem">...</header>
<%# clear all four sides (e.g. a full-screen overlay) %>
<div class="native-safe-t native-safe-b native-safe-x">...</div>
The dock is additive (.native-dock-safe); a scroll body sits flush (.native-safe-b) β pick by whether an opaque bar would bury the content or merely sit under it. Only absolute-positioned overlays stay inline: a padding utility can't express top: / left: / right:, so those keep top-[max(var(--safe-t),1rem)] / left-[max(var(--safe-l),1rem)] and are exempt from the contract test's padding guard.
The contract test (guardrail)
test/integration/native_safe_area_contract_test.rb is what keeps the SSOT from rotting as the app grows. It:
- asserts all four
--safe-*vars +--dock-addexist, and that each.native-*utility carries its exact formula; - fails CI if any view re-inlines a padding safe-area read (
p*-[β¦var(--safe-*)β¦]) β the whole point of the utilities is that the arithmetic lives once, so a re-inlinedpb-[max(var(--safe-b),β¦)]is a regression, not a style nit; - exempts positional (
top-/left-/right-) reads, which legitimately stay inline β asserted against the guard regex directly so a future cutout-aware overlay never trips it; - carries a per-file allowlist for deliberate
hotwire-native:-prefixed overrides a plain utility can't express (e.g. an app-header whose top pad is additive on native butmax()on web). It's empty in the base template β add your own as you need them, and a companion test prunes dead entries.
This is what lets "never inline env() or the formula" be a rule the build enforces, not a convention reviewers have to remember.
iOS: nothing to do
There is no iOS twin of SafeAreaInsetBridge, and the four-sided contract needs no iOS change. WKWebView's env(safe-area-inset-*) is context-correct (via viewport-fit=cover in every native layout, plus a .never/.automatic content-inset policy) β it reflects the WebView's actual safe area, including the tab bar and any additionalSafeAreaInsets the shell mirrors for the keyboard β so all four --safe-* vars keep their env() default and --dock-add: 0 gives the max() behaviour. iOS also ships portrait-only, so --safe-l / --safe-r are physically 0 there regardless. See Native iOS for the stable_fixed_dock / contentInsetAdjustmentBehavior viewport policy that keeps bottom: 0 landing at the device edge.
References
- Understand window insets in WebView
- Android 15 edge-to-edge enforcement + deprecated window APIs Β· Android 16 (opt-out removed)
- Android edge-to-edge guide Β· Display cutout insets
- Material still calls deprecated window APIs (upstream, open)
- Places programmatic autocomplete Β· Manifest merge rule markers
- capacitor-community/safe-area β documents Chromium
<140returning0 - Chrome edge-to-edge Β· CSS
env()