What you will build
Four tabs fixed to the bottom of the screen on phones: Home, Search, Inbox and Profile. The tab for the page you are on turns lime and gets aria-current="page", a short line slides under it when you switch, and Inbox carries an unread badge. From 768px wide the bar is hidden, so keep your normal header or sidebar there.
It needs Next.js 13 or newer with the App Router and Tailwind v4 (v3 works too). No packages.
The tabs in one list
Keep the tabs in one array so the bar, the active check and the indicator all count the same items. The icons are inline SVG paths with aria-hidden, because the label under each icon already names the tab.
import type { ReactNode } from "react";
export type NavItem = { href: string; label: string; icon: ReactNode };
const icon = (d: string) => (
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-6" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
export const navItems: NavItem[] = [
{ href: "/", label: "Home", icon: icon("M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z") },
{ href: "/search", label: "Search", icon: icon("M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm9 16-4.3-4.3") },
{ href: "/inbox", label: "Inbox", icon: icon("M4 5h16v14H4zm0 8h5l1.5 2h3l1.5-2h5") },
{ href: "/profile", label: "Profile", icon: icon("M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm-7 9a7 7 0 0 1 14 0") },
];Add a fifth tab by adding a fifth object. The indicator width comes from the array length, so nothing else changes.
The bar, the active tab and the sliding line
The bar is a client component because it reads the current path with usePathname. A tab is active when the path is its href or starts with its href plus a slash, so /inbox/42 still lights up Inbox. Home only matches / exactly, otherwise it would match every page.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { navItems } from "./nav-items";
function isActive(pathname: string, href: string) {
return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(href + "/");
}
export function BottomNav({ counts = {} }: { counts?: Record<string, number> }) {
const pathname = usePathname();
const index = navItems.findIndex((item) => isActive(pathname, item.href));
return (
<nav
aria-label="Main"
className="fixed inset-x-0 bottom-0 z-40 border-t border-zinc-800 bg-zinc-950/90 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
>
<div className="relative mx-auto flex max-w-md">
{index >= 0 && (
<span
aria-hidden="true"
className="absolute top-0 left-0 transition-transform duration-300 ease-out motion-reduce:transition-none"
style={{ width: `${100 / navItems.length}%`, transform: `translateX(${index * 100}%)` }}
>
<span className="mx-auto block h-0.5 w-8 rounded-full bg-lime-300" />
</span>
)}
{navItems.map((item) => {
const active = isActive(pathname, item.href);
const count = counts[item.href] ?? 0;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={`flex h-16 flex-1 flex-col items-center justify-center gap-1 text-[11px] font-medium transition-colors ${
active ? "text-lime-300" : "text-zinc-400 hover:text-zinc-100"
}`}
>
<span className="relative">
{item.icon}
{count > 0 && (
<span
aria-hidden="true"
className="absolute -top-1.5 -right-2.5 min-w-4 rounded-full bg-red-500 px-1 text-center text-[10px] leading-4 text-white"
>
{count > 9 ? "9+" : count}
</span>
)}
</span>
{item.label}
{count > 0 && <span className="sr-only">, {count} unread</span>}
</Link>
);
})}
</div>
</nav>
);
}The indicator is one element as wide as one tab. translateX(index * 100%) moves it by whole tab widths, because a percentage in translate is measured against the element's own width. Only transform animates, so the slide stays smooth on cheap phones, and motion-reduce:transition-none makes it jump for people who turned motion off.
The red badge is aria-hidden and screen readers hear the visually hidden text after the label instead, so the link reads as "Inbox, 3 unread". Pass real counts from your data through the counts prop.
Room for the home bar and the last row
On iPhones without a home button, the bottom edge of the screen belongs to the home indicator. viewportFit: "cover" lets the page use that area and makes env(safe-area-inset-bottom) return its height, which the bar adds as bottom padding. The main element gets the bar's 4rem plus the same inset, so the last row of a page never ends up under the bar.
import type { Viewport } from "next";
import { BottomNav } from "@/components/BottomNav";
import "./globals.css";
export const viewport: Viewport = { viewportFit: "cover" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-zinc-950 text-zinc-100">
<main className="pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-0">{children}</main>
<BottomNav counts={{ "/inbox": 3 }} />
</body>
</html>
);
}From the md breakpoint the padding drops to 0 together with the bar.
What we checked
In Chrome at 390px, in a Next.js 15.5 app with React 19 and Tailwind v4: the bar sat at the bottom, 65px tall with its border, with Home marked as the current page. A tap on Inbox slid the line from x 0 to x 195 (two tab widths of 97.5px) and marked Inbox, and /inbox/thread-1 kept Inbox active. Scrolled to the bottom, the last row ended at 756px and the bar started at 779px. The accessibility tree named the link "Inbox, 3 unread". At 1440px the bar was hidden. We did not test on a physical iPhone; desktop Chrome reports a safe area inset of 0.
Using Cursor, v0 or Lovable
Cursor: paste the three files and ask it to swap the four tabs for your routes. If it suggests pathname.includes(href) for the active check, keep startsWith(href + "/"), or a page like /profile-settings would light up Profile.
v0: generated bottom navs often keep the active tab in useState, which goes wrong on back and forward. Paste BottomNav.tsx and ask v0 to read the tab from the URL the same way.
Lovable: it is React with Vite and React Router, so use useLocation().pathname instead of usePathname, Link to instead of Link href, and put <BottomNav /> in App.tsx. Add viewport-fit=cover to the viewport meta tag in index.html.
Or use the ready-made piece
If you want it to feel like a native app, Liquid Glass Tab Bar is a five tab glass bar where a liquid lens magnifies the selected icon, stretches like a drop between tabs and wobbles when it lands. Scroll and it shrinks into a bubble next to a mini player, and the drop can burst into a full-screen player and fall back into the bar. It is a real tablist with arrow keys.
Everything above is free to copy. The piece is for when you want the finished, animated version with its source and a PROMPT.md for Cursor, v0 and Lovable.
Questions
- How do I make a bottom navigation bar in React?
- Fix a nav to the bottom of the screen with fixed inset-x-0 bottom-0, give each tab an equal share with flex-1, and mark the tab for the current URL with aria-current="page". Hide it on wide screens with md:hidden.
- How do I stop a bottom nav from covering the page content?
- Give the main element bottom padding equal to the bar's height plus the safe area, for example pb-[calc(4rem+env(safe-area-inset-bottom))].
- What does env(safe-area-inset-bottom) do on iPhone?
- It returns the height of the home indicator area at the bottom of the screen. It is only above 0 when the viewport has viewport-fit=cover, which you set in Next.js with export const viewport = { viewportFit: "cover" }.
- How do I highlight the active tab in Next.js?
- Read the path with usePathname from next/navigation in a client component and compare it to each tab's href. Match nested routes with startsWith(href + "/") and match the home tab only on /.