DEV Community

Cover image for Tailwind Safe Area Insets: Bottom Bars, FABs and Toasts
Serban Dan Rusu for Wingo UI

Posted on Originally published at wingo-ui.com

Tailwind Safe Area Insets: Bottom Bars, FABs and Toasts

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. 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). With cover, the viewport fills the whole display, and MDN's viewport reference 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, 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:

// app/layout.tsx
import type { Viewport } from "next";

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  viewportFit: "cover",
};
Enter fullscreen mode Exit fullscreen mode

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()), 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:

/* 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)));
}
Enter fullscreen mode Exit fullscreen mode

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

// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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:

// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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

"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 />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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. The original article embeds an interactive demo here (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 live on wingo-ui.com or open the FAB docs. Install: 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:

pb-[max(0px,calc(env(safe-area-inset-bottom,0px)-var(--bottom-bar-offset,0px)))]
Enter fullscreen mode Exit fullscreen mode

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.

// 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}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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. The original article embeds an interactive demo here (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 live on wingo-ui.com or open the Sticky CTA Bar docs. Install: 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 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:

// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Live demo: Toast. The original article embeds an interactive demo here (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 live on wingo-ui.com or open the Toast docs. Install: 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:

// 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");
});
Enter fullscreen mode Exit fullscreen mode

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


This article was originally published on the Wingo UI blog: Tailwind Safe Area Insets: Bottom Bars, FABs and Toasts. The original has the live, interactive demos and is kept up to date.

Top comments (0)