Tailwind Safe Area Insets: Bottom Bars, FABs and Toasts
You let the page fill the screen with viewport-fit=cover, pin a tab bar to bottom-0, open it on an iPhone, and the home indicator sits on top of your labels. Turn the phone sideways and the notch clips the start of every row. In Tailwind CSS v4, Tailwind safe area handling is up to you, because core ships no classes for it: everything pinned to an edge has to pad itself with env(safe-area-inset-*). This tutorial builds the bottom of a phone screen, with a tab bar, a buy bar, a floating action button and toasts, so that none of them hides under the notch or the home indicator and none of them doubles the gap when they stack.
Safe areas are one of the nine rules in Mobile-first React components. This post is the step by step version, with the arithmetic for bars that sit on top of each other.
What does viewport-fit cover change?
It tells the browser that your page handles the screen's cutouts itself. With the default, viewport-fit=auto, Safari on an iPhone insets the page so nothing lands under the notch or the rounded corners, and fills the leftover strips with the background color of <html> or <body> (WebKit, 2017 (opens in a new tab)). With cover, the viewport fills the whole display, and MDN's viewport reference (opens in a new tab) says it is "highly recommended" to use the safe area inset variables so that important content stays visible.
Android joined in 2025. From Chrome 135 (opens in a new tab), Chrome on Android phones draws edge to edge. By default it shows a "chin" over the gesture bar that slides away as you scroll, and a position: fixed; bottom: 0 bar ends up behind the navigation bar once the chin is gone. A page that sets viewport-fit=cover goes edge to edge on load and never shows the chin. Either way, the insets now matter on Android too.
In the Next.js App Router the meta tag comes from a viewport export in the root layout, which has to be a Server Component:
Next.js renders it as <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">. Outside Next.js, write that tag yourself. Treat cover as a promise: from now on, every element you pin to an edge pads itself.
Why does env safe-area-inset-bottom return 0?
Usually because the page has no viewport-fit=cover, or because nothing covers that edge on the device you are testing. Without cover, Safari keeps the page inside the safe area itself, so on an iPhone there is no inset left to report. Desktop browsers report 0 as well (MDN on env() (opens in a new tab)), so a layout that looks right in a narrow desktop window proves nothing. An iPhone with a Home button, like the iPhone SE, has no home indicator and reports 0 at the bottom, which is why every rule below pairs the inset with a minimum instead of trusting it alone.
Check the rendered page, not your source: open the head in the inspector and make sure there is exactly one viewport tag and that it carries viewport-fit=cover. A second tag added by a plugin or a hand-written <head> can override the first. Variable names are case-sensitive too, so env(SAFE-AREA-INSET-BOTTOM) silently falls back.
How do you write a Tailwind safe area inset bottom utility?
Use an arbitrary value for a one-off, and define a few @utility classes once it shows up in more than two places. Tailwind CSS v4 has no safe area utilities in core: as of version 4.3.3, the only -safe classes are alignment ones like justify-center-safe, which map to the CSS safe overflow keyword and have nothing to do with the notch.
The arbitrary value works anywhere: pb-[max(env(safe-area-inset-bottom),12px)]. For a project, put a small set in your stylesheet:
The -* at the end of a name makes a utility functional, and --value(integer) reads the number after it, so pb-safe-or-3 compiles to padding-bottom: max(env(safe-area-inset-bottom, 0px), calc(var(--spacing) * 3)), and variants like md:pb-safe work as they do for any other class. If you would rather install the whole set (margins, scroll padding, h-dvh-safe, and -offset- and -or- forms for each), the tailwindcss-safe-area (opens in a new tab) package adds it with @import "tailwindcss-safe-area"; (version 1.3.0, MIT license, as of October 2026).
The two functions do different jobs. Use max() when the inset replaces your normal spacing, like a bar's bottom padding or a toast's gutter: on a phone with a home indicator the inset wins, and on a laptop your 12px does. Use calc() when you need a gap on top of the inset, like a button that floats 16px above the indicator. WebKit's original post uses the max() form for side padding for the same reason.
How do you pad a fixed bottom bar?
Pad the inside of the bar and leave the bar itself at bottom-0. Its background then runs under the home indicator, and the padding lifts the tappable row above it. The iPhone home indicator CSS fix is one class on the bar, pb-safe:
The row stays 64px tall everywhere. On a phone with a home indicator the bar grows by the inset; on a desktop the inset is 0 and nothing changes. px-safe-or-2 keeps the first and last tab clear of the notch in landscape. Each tab is a quarter of the screen wide and 64px tall, comfortably above the minimum touch target size.
Moving the bar up with bottom-[env(safe-area-inset-bottom)] looks similar in a screenshot, but the page then scrolls through a visible strip under the bar. Chrome's guide shows the same effect on Android.
The content behind the bar needs the same space, or the last row of a list never scrolls into view:
What about Chrome's warning against padding with the inset?
It applies to pages that keep the chin. Chrome's guide says not to set padding-bottom to env(safe-area-inset-bottom), because without cover the inset changes while the chin slides away, the padding forces a layout on every step, and Chrome stops sliding the chin away when it detects that pattern. With viewport-fit=cover there is no chin to slide.
If you cannot opt in, use Chrome's alternative: reserve the largest inset up front and move the bar with bottom, a calc(env(safe-area-inset-bottom, ...) - ...) pattern that Chrome has a fast path for. Tailwind's preflight sets box-sizing: border-box, so the height has to include the padding:
safe-area-max-inset-bottom arrived in Chrome 135. The 36px fallback is the value Chrome's guide uses for browsers without it.
How do you stack a FAB above a tab bar without doubling the gap?
Put the bar's full height, inset included, in a CSS variable, then place the button at the larger of that variable and the inset, plus your gap. Adding them double-counts: the bar's height already contains the inset, so bar + inset + 16px floats the button a whole home indicator strip too high.
For the static bar above, the variable can be pure CSS. Set it on a wrapper so the button inherits it, and reset it where the bar is hidden:
From md up the variable is 0px, so max() falls back to the inset and the button floats 16px above the bottom edge, or above the inset where the device has one. The same wrapper gives main its bottom padding, so the variable is the only place the bar's height is written down.
When the height is not known up front (a bar with a different size per page, a buy bar that slides in after you scroll), measure it instead: a ResizeObserver on the bar writes its height to the variable on <html>. The Wingo UI Bottom Tab Bar writes --bottom-bar-offset that way when it is fixed, and its value drops to 0 while md:hidden hides the bar. The Sticky CTA Bar writes --cta-bar-offset, its visible height, which falls to 0 while it is hidden. The FAB reads both:
So the components need no props to cooperate. Render them side by side and the button stays above whichever bars are on screen:
One catch in Next.js: without a LinkProvider the tabs render plain <a> elements, so every tap reloads the page and the bar only knows its active tab from its own state. Wrap the app once in <LinkProvider component={Link} currentPath={usePathname()}> from @/lib/navigation, inside a client component, and the tabs navigate through next/link and highlight the current route.
$ npx wingo-ui@latest add fabThe demo has no tab bar, and on a desktop the inset is 0, so the button sits 16px above the bottom of the frame. We emulated a 34px bottom inset in Chromium with the test at the end of this post: the FAB's rule computed to bottom: 50px (34 + 16), and the Bottom Tab Bar's bottom padding to 34px.
How do you put a buy bar on top of a tab bar?
Position it at bottom: var(--bottom-bar-offset) and pad only the part of the inset the tab bar does not already cover. The Sticky CTA Bar uses this class for its bottom padding:
With a tab bar below, the subtraction goes negative and max() clamps it to 0, so the buy bar adds no gap of its own. On a product page without a tab bar the variable is 0 and the buy bar pads the whole inset itself. Its sides use pl-[max(1rem,env(safe-area-inset-left,0px))] and the matching right class, so a landscape phone keeps the price clear of the notch.
Render it next to the BottomTabBar from the previous section and it sits on top of the tab bar; render it alone and it sits on the bottom edge. While the promise from onAction is pending, the button shows a spinner.
$ npx wingo-ui@latest add sticky-cta-barThe demo pins the bar inside a phone frame with position="sticky", because fixed would pin it to your browser window. The safe area padding is the same in both modes, and hideFrom="md" hides the bar with CSS on wide screens, where the page shows its own buy button.
Where should toasts go on a phone with a home indicator?
Pin the stack to the larger of the inset and your gutter, and if the app has a bottom tab bar, show toasts at the top on phones. The Wingo UI Toast does the first part for you. Below 768px toasts span the width with 16px gutters (mobileOffset), and each edge uses max(env(safe-area-inset-*), 16px), so a bottom stack clears the home indicator and a top stack clears the notch and the status bar. Our Sonner alternatives comparison shows which other toast libraries pad the safe area for you.
The Toaster does not read --bottom-bar-offset, so on a phone a bottom stack would cover a tab bar. In an app with one, move phone toasts to the top. With the viewport export from the first section, the root layout ends up like this:
$ npx wingo-ui@latest add toastDesktop placement keeps its own position and offset props, so the corner stack on a laptop stays where it was.
How do you test safe areas without an iPhone?
On a Mac, Xcode's iOS Simulator runs Safari with each simulated device's real insets. In CI, Chromium can fake them: the Chrome DevTools Protocol has an experimental Emulation.setSafeAreaInsetsOverride command that overrides the env(safe-area-inset-*) and env(safe-area-max-inset-*) values, and Playwright can send it through a CDP session:
We ran this pattern against the Bottom Tab Bar, FAB and Sticky CTA Bar previews with Playwright 1.63 and its bundled Chromium in October 2026. Pick the inset values of the devices you care about, and add a second case with bottom: 0 to catch bars that lose their minimum padding on phones without a home indicator. The command is marked experimental, so pin your Playwright version.
Where should you start?
Add viewportFit: "cover" and the five utilities above, then fix the bars from the bottom up: the tab bar first, then everything that floats above it. Safe areas reach past bars, too: in Wingo UI 1.7.0, 38 component files read env(safe-area-inset-*), from sheets and dialogs to the cookie consent banner and the image lightbox. The bottom sheet guide covers how a sheet's footer handles the home indicator.
If you want the toast placement from this post without writing it, the Toast is free: run npx wingo-ui@latest add toast and mount the Toaster once in your root layout. The Bottom Tab Bar, Sticky CTA Bar and FAB are part of Wingo UI Pro. More guides like this one live in Mobile UI in React.
FAQ
Does Tailwind CSS have safe area utilities?
Not in core. As of Tailwind CSS 4.3.3 (October 2026) there is no pb-safe class, so you use arbitrary values like pb-[max(env(safe-area-inset-bottom),12px)], define a few classes with @utility, or install the tailwindcss-safe-area package.
Why is env(safe-area-inset-bottom) always 0?
On an iPhone the usual cause is a page without viewport-fit=cover, so Safari keeps the page inside the safe area itself and has no inset to report. Desktop browsers and iPhones with a Home button also report 0 at the bottom, so pair the inset with a minimum through max().
Do I need viewport-fit=cover on Android?
Not to go edge to edge: since Chrome 135, Chrome on Android phones draws edge to edge anyway, with a chin over the gesture bar that slides away as you scroll. With viewport-fit=cover the page reaches the bottom edge from the start and the chin never shows, so fixed bottom bars need the safe area inset on Android just as on an iPhone.
Should a bottom bar use padding or bottom: env(safe-area-inset-bottom)?
Padding, on a page that sets viewport-fit=cover. The bar stays on the bottom edge with its background under the home indicator, while bottom: env() leaves a strip where the page shows through. Pages that keep Chrome's chin should use Chrome's pattern with safe-area-max-inset-bottom instead.
How do I test safe area insets without an iPhone?
Use Xcode's iOS Simulator on a Mac, or emulate the insets in Chromium with the experimental DevTools Protocol command Emulation.setSafeAreaInsetsOverride, which Playwright can send through a CDP session.
- Tailwind CSS
- Safe area
- Mobile
- iOS
- CSS