# Tailwind Safe Area Insets: Bottom Bars, FABs and Toasts

> A Tailwind safe area guide for v4: opt in with viewport-fit=cover, pad bars with env(safe-area-inset-bottom) and keep FABs and toasts off the home indicator.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 12 min
- Canonical: https://wingo-ui.com/blog/tailwind-safe-area-insets

## 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.

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](https://wingo-ui.com/blog/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](https://webkit.org/blog/7929/designing-websites-for-iphone-x/)). With `cover`, the viewport fills the whole display, and [MDN's viewport reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/viewport) 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](https://developer.chrome.com/docs/css-ui/edge-to-edge), 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()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/env)), 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](https://www.npmjs.com/package/tailwindcss-safe-area) 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](https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind).

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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/sticky-cta-bar) writes `--cta-bar-offset`, its visible height, which falls to 0 while it is hidden. The [FAB](https://wingo-ui.com/components/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.

> Live demo (FAB): 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. Try it at [FAB](https://wingo-ui.com/components/fab) and install it with `npx wingo-ui@latest add fab`.

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.

> Live demo (Sticky CTA Bar): 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. Try it at [Sticky CTA Bar](https://wingo-ui.com/components/sticky-cta-bar) and install it with `npx wingo-ui@latest add sticky-cta-bar`.

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](https://wingo-ui.com/components/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](https://wingo-ui.com/blog/sonner-alternative-react-toast) 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>
  );
}
```

> Live demo (Toast): 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. Try it at [Toast](https://wingo-ui.com/components/toast) and install it with `npx wingo-ui@latest add toast`.

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](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web) 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](https://wingo-ui.com/pricing). More guides like this one live in [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui).

## Components in this post

- [Sticky CTA Bar](https://wingo-ui.com/components/sticky-cta-bar) (Pro): The bottom action bar of product, listing and checkout pages on phones: the price on one side, the main action on the other. Install: `npx wingo-ui@latest add sticky-cta-bar`
- [Bottom Tab Bar](https://wingo-ui.com/components/bottom-tab-bar) (Pro): 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. Install: `npx wingo-ui@latest add bottom-tab-bar`
- [FAB](https://wingo-ui.com/components/fab) (Pro): 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. Install: `npx wingo-ui@latest add fab`
- [Toast](https://wingo-ui.com/components/toast) (Free): Toast notifications: a Toaster mounted once and a toast() you call from anywhere, stacked, swipeable and paused while you read. Install: `npx wingo-ui@latest add toast`

## 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.

---

Source: https://wingo-ui.com/blog/tailwind-safe-area-insets
