WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Mobile UI in React
  4. Tailwind Safe Area Insets: Bottom Bars, FABs and Toasts
  1. Blog
  2. Mobile UI in React
  3. Tailwind Safe Area Insets: Bottom Bars, FABs and Toasts
Mobile UI in React
Mobile UI in React

Tailwind Safe Area Insets: Bottom Bars, FABs and Toasts

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 12 min read

On this page

0%
  1. What does viewport-fit cover change?
  2. Why does env safe-area-inset-bottom return 0?
  3. How do you write a Tailwind safe area inset bottom utility?
  4. How do you pad a fixed bottom bar?
    1. What about Chrome's warning against padding with the inset?
  5. How do you stack a FAB above a tab bar without doubling the gap?
  6. How do you put a buy bar on top of a tab bar?
  7. Where should toasts go on a phone with a home indicator?
  8. How do you test safe areas without an iPhone?
  9. Where should you start?
  10. FAQ
    1. Does Tailwind CSS have safe area utilities?
    2. Why is env(safe-area-inset-bottom) always 0?
    3. Do I need viewport-fit=cover on Android?
    4. Should a bottom bar use padding or bottom: env(safe-area-inset-bottom)?
    5. How do I test safe area insets without an iPhone?

TL;DR

Tailwind CSS v4 has no safe area utilities, so you set viewport-fit=cover and pad every element pinned to a screen edge with env(safe-area-inset-*), either as arbitrary values like pb-[max(env(safe-area-inset-bottom),12px)] or as a few @utility classes. Keep bottom bars at bottom-0 and pad them on the inside, so their background runs under the home indicator. Place a floating button at the larger of the bar height and the inset, plus a gap, and let a second bar pad only the part of the inset the first one leaves uncovered, so the gap is never doubled.

Published Oct 9, 2026

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:

tsx
// app/layout.tsx
import type { Viewport } from "next";
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};

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:

css
/* app/globals.css */
@import "tailwindcss";
@utility pt-safe {
padding-top: env(safe-area-inset-top, 0px);
}
@utility pb-safe {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* the inset, but never less than a spacing step: pb-safe-or-3 */
@utility pb-safe-or-* {
padding-bottom: max(env(safe-area-inset-bottom, 0px), --spacing(--value(integer)));
}
/* landscape: the notch side gets the inset, the other side keeps the gutter: px-safe-or-4 */
@utility px-safe-or-* {
padding-left: max(env(safe-area-inset-left, 0px), --spacing(--value(integer)));
padding-right: max(env(safe-area-inset-right, 0px), --spacing(--value(integer)));
}
/* a gap on top of the inset, for floating elements: bottom-safe-offset-4 */
@utility bottom-safe-offset-* {
bottom: calc(env(safe-area-inset-bottom, 0px) + --spacing(--value(integer)));
}

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:

tsx
// components/tab-bar.tsx
import Link from "next/link";
import { House, Search, ShoppingBag, UserRound } from "lucide-react";
const tabs = [
{ href: "/", label: "Home", icon: House },
{ href: "/search", label: "Search", icon: Search },
{ href: "/cart", label: "Cart", icon: ShoppingBag },
{ href: "/account", label: "Account", icon: UserRound },
];
export function TabBar() {
return (
<nav
aria-label="Primary"
className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-background/90 pb-safe px-safe-or-2 backdrop-blur md:hidden"
>
<ul className="flex h-16">
{tabs.map(({ href, label, icon: Icon }) => (
<li key={href} className="flex-1">
<Link
href={href}
className="flex h-full flex-col items-center justify-center gap-1 text-[11px] font-medium touch-manipulation"
>
<Icon aria-hidden className="size-6" />
{label}
</Link>
</li>
))}
</ul>
</nav>
);
}

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:

tsx
<main className="pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-0">{children}</main>

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:

tsx
<nav className="fixed inset-x-0 [--safe-max-b:env(safe-area-max-inset-bottom,36px)] h-[calc(4rem+var(--safe-max-b))] pb-(--safe-max-b) bottom-[calc(env(safe-area-inset-bottom,0px)-var(--safe-max-b))]">
{/* tabs */}
</nav>

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:

tsx
// components/mobile-shell.tsx
import type { ReactNode } from "react";
import { Plus } from "lucide-react";
import { TabBar } from "@/components/tab-bar";
export function MobileShell({ children }: { children: ReactNode }) {
return (
<div className="[--bottom-bar-offset:calc(4rem+env(safe-area-inset-bottom,0px))] md:[--bottom-bar-offset:0px]">
<main className="pb-[calc(var(--bottom-bar-offset)+1rem)]">{children}</main>
<button
type="button"
aria-label="New listing"
className="fixed right-[calc(1rem+env(safe-area-inset-right,0px))] bottom-[calc(max(var(--bottom-bar-offset),env(safe-area-inset-bottom,0px))+1rem)] z-40 grid size-14 place-items-center rounded-full bg-foreground text-background shadow-lg touch-manipulation"
>
<Plus aria-hidden className="size-6" />
</button>
<TabBar />
</div>
);
}

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:

css
bottom: calc(max(var(--bottom-bar-offset, 0px) + var(--cta-bar-offset, 0px), env(safe-area-inset-bottom, 0px)) + 16px);

So the components need no props to cooperate. Render them side by side and the button stays above whichever bars are on screen:

tsx
"use client";
import { FileText, House, Inbox, UserPlus, UserRound, Users } from "lucide-react";
import { BottomTabBar, type BottomTabBarItem } from "@/components/ui/bottom-tab-bar";
import { Fab, type FabAction } from "@/components/ui/fab";
const tabs: BottomTabBarItem[] = [
{ label: "Home", href: "/", icon: <House /> },
{ label: "Clients", href: "/clients", icon: <Users /> },
{ label: "Inbox", href: "/inbox", icon: <Inbox />, badge: 4 },
{ label: "Profile", href: "/profile", icon: <UserRound /> },
];
const actions: FabAction[] = [
{ id: "client", label: "New client", icon: <UserPlus />, href: "/clients/new" },
{ id: "invoice", label: "New invoice", icon: <FileText />, href: "/invoices/new" },
];
export function MobileChrome() {
return (
<>
<Fab label="Add" actions={actions} hideOnScroll />
<BottomTabBar items={tabs} className="md:hidden" hideOnScroll />
</>
);
}

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.

Tap Add to open the speed dial, then scroll the client list: the label folds into the icon on the way down and comes back on the way up
$ npx wingo-ui@latest add fab
ProFAB docs

The 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:

text
pb-[max(0px,calc(env(safe-area-inset-bottom,0px)-var(--bottom-bar-offset,0px)))]

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.

tsx
// components/buy-bar.tsx
import { StickyCtaBar } from "@/components/ui/sticky-cta-bar";
export function BuyBar({ onAddToCart }: { onAddToCart: () => Promise<void> }) {
return (
<StickyCtaBar
price={349}
compareAtPrice={449}
locale="en-US"
actionLabel="Add to cart"
tone="brand"
hideFrom="md"
onAction={onAddToCart}
/>
);
}

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.

Scroll the listing: the bar stays pinned to the bottom with the agent on the left and Call on the right. Press Call and the button shows a spinner without changing its width
$ npx wingo-ui@latest add sticky-cta-bar
ProSticky CTA Bar docs

The 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:

tsx
// app/layout.tsx
import type { ReactNode } from "react";
import type { Viewport } from "next";
import { Toaster } from "@/components/ui/toast";
import "./globals.css";
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Toaster mobilePosition="top" />
</body>
</html>
);
}
Press Save, then Failed payment: on a wide screen the toasts stack in the bottom right corner, and below 768px they span the width at the top. Swipe one away to dismiss it
$ npx wingo-ui@latest add toast
FreeToast docs

Desktop 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:

ts
// e2e/safe-area.spec.ts
import { test, expect } from "@playwright/test";
test.use({ viewport: { width: 390, height: 844 } });
test("the tab bar pads the home indicator", async ({ page, context, browserName }) => {
test.skip(browserName !== "chromium", "the override is a Chrome DevTools Protocol command");
const cdp = await context.newCDPSession(page);
await cdp.send("Emulation.setSafeAreaInsetsOverride", {
insets: { top: 47, bottom: 34, left: 0, right: 0 },
});
await page.goto("/"); // baseURL comes from playwright.config.ts
const nav = page.getByRole("navigation", { name: "Primary" });
await expect(nav).toHaveCSS("padding-bottom", "34px");
});

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.

Components in this post

  • Sticky CTA Bar

    The bottom action bar of product, listing and checkout pages on phones: the price on one side, the main action on the other.

    Pro
  • Bottom Tab Bar

    The phone's primary navigation: 3 to 5 tabs at the thumb with an animated pill, badges, a raised center action and hide on scroll.

    Pro
  • FAB

    The floating action button for a phone screen's main action, with a labelled or radial speed dial and a label that folds while you scroll.

    Pro
  • Toast

    Toast notifications: a Toaster mounted once and a toast() you call from anywhere, stacked, swipeable and paused while you read.

    Free

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

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

Mobile-First React Components: 9 Rules With Code

Mobile-first React components, rule by rule: bottom sheets instead of popovers, 44px touch targets, safe areas, dvh, keyboard-aware bars and tables as cards.

SRSerban Rusu·Oct 9, 2026·20 min read
Mobile UI in React

React Bottom Navigation Bar in Next.js: Safe Areas, Badges

Build a React bottom navigation bar in Next.js: 3 to 5 link tabs, safe areas, badges screen readers announce, hide on scroll, and when a sidebar fits better.

SRSerban Rusu·Oct 9, 2026·8 min read
Comparisons and alternatives

Best React Component Library for Mobile in 2026: 9 Compared

The best React component library for mobile web apps in 2026: 9 libraries checked at 390px for bottom sheets, touch targets, safe areas and gestures.

SRSerban Rusu·Oct 9, 2026·12 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI