Bottom Nav

Fixed bottom tab bar for phones, with a gradient that fades the page into it and an active item you hand it from your router

On a phone?

Open the full-page preview to try the bar against real routes, outside the frame below.

Home

import { useState } from "react";
import { useIcon } from "@/lib/icon-context";
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav";
import { PhoneFrame } from "@/lib/docs/phone-frame";
const LABELS: Record<string, string> = {
home: "Home",
search: "Search",
likes: "Likes",
profile: "Profile",
};
export default function BottomNavDemo() {
const [value, setValue] = useState("home");
const HomeIcon = useIcon("home");
const SearchIcon = useIcon("search");
const HeartIcon = useIcon("heart");
const UserIcon = useIcon("user");
return (
<PhoneFrame route={value}>
<p className="px-5 text-lg font-semibold">{LABELS[value]}</p>
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} aria-hidden className="mx-5 mt-3 h-16 rounded-xl bg-muted" />
))}
<BottomNav value={value} onValueChange={setValue}>
<BottomNavItem value="home" icon={<HomeIcon />}>
Home
</BottomNavItem>
<BottomNavItem value="search" icon={<SearchIcon />}>
Search
</BottomNavItem>
<BottomNavItem value="likes" icon={<HeartIcon />}>
Likes
</BottomNavItem>
<BottomNavItem value="profile" icon={<UserIcon />}>
Profile
</BottomNavItem>
</BottomNav>

The frame above drives the bar with local state. To try it against real routes, open the full-page preview on your phone: three pages, one bar, the router deciding what is active.

Installation

npx shadcn@latest add https://www.deltacomponents.dev/r/bottom-nav.json

Usage

1import Link from "next/link";
2import { usePathname } from "next/navigation";
3import { Home, Search, User } from "lucide-react";
4import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav";
5
6const pathname = usePathname();
7
8<BottomNav value={pathname} linkComponent={Link} className="md:hidden">
9 <BottomNavItem value="/" href="/" icon={<Home />}>Home</BottomNavItem>
10 <BottomNavItem value="/search" href="/search" icon={<Search />}>Search</BottomNavItem>
11 <BottomNavItem value="/profile" href="/profile" icon={<User />}>Profile</BottomNavItem>
12</BottomNav>

The nav never reads the URL itself: value is whatever your router says is current. linkComponent is how a Next app keeps client-side routing (omit it and items are plain <a> tags). The component carries no breakpoint, so md:hidden is the layout's call. Re-tapping the active item scrolls back to the top.

Basic

Home

import { useState } from "react";
import { useIcon } from "@/lib/icon-context";
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav";
import { PhoneFrame } from "@/lib/docs/phone-frame";
const LABELS: Record<string, string> = {
home: "Home",
search: "Search",
likes: "Likes",
profile: "Profile",
};
export default function BottomNavDemo() {
const [value, setValue] = useState("home");
const HomeIcon = useIcon("home");
const SearchIcon = useIcon("search");
const HeartIcon = useIcon("heart");
const UserIcon = useIcon("user");
return (
<PhoneFrame route={value}>
<p className="px-5 text-lg font-semibold">{LABELS[value]}</p>
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} aria-hidden className="mx-5 mt-3 h-16 rounded-xl bg-muted" />
))}
<BottomNav value={value} onValueChange={setValue}>
<BottomNavItem value="home" icon={<HomeIcon />}>
Home
</BottomNavItem>
<BottomNavItem value="search" icon={<SearchIcon />}>
Search
</BottomNavItem>
<BottomNavItem value="likes" icon={<HeartIcon />}>
Likes
</BottomNavItem>
<BottomNavItem value="profile" icon={<UserIcon />}>
Profile
</BottomNavItem>
</BottomNav>

Fade

The page dissolves into the background over the 160px above the bar. fade says where that lands: at the default 0.5 it is solid from the icons' top edge down; lower lets it run into the icons, 1 ends it at the bar's top edge. Scroll the screen a little and drag the slider.

fade
0.50

Line 1 of the page, scrolling up under the bar.

Line 2 of the page, scrolling up under the bar.

Line 3 of the page, scrolling up under the bar.

Line 4 of the page, scrolling up under the bar.

Line 5 of the page, scrolling up under the bar.

Line 6 of the page, scrolling up under the bar.

Line 7 of the page, scrolling up under the bar.

Line 8 of the page, scrolling up under the bar.

Line 9 of the page, scrolling up under the bar.

Line 10 of the page, scrolling up under the bar.

Line 11 of the page, scrolling up under the bar.

Line 12 of the page, scrolling up under the bar.

Line 13 of the page, scrolling up under the bar.

Line 14 of the page, scrolling up under the bar.

Line 15 of the page, scrolling up under the bar.

Line 16 of the page, scrolling up under the bar.

Line 17 of the page, scrolling up under the bar.

Line 18 of the page, scrolling up under the bar.

Line 19 of the page, scrolling up under the bar.

Line 20 of the page, scrolling up under the bar.

Line 21 of the page, scrolling up under the bar.

Line 22 of the page, scrolling up under the bar.

Line 23 of the page, scrolling up under the bar.

Line 24 of the page, scrolling up under the bar.

import { useState } from "react";
import { useIcon } from "@/lib/icon-context";
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav";
import { PhoneFrame } from "@/lib/docs/phone-frame";
import { Slider } from "@/app/components/ui/slider";
const LINES = Array.from(
{ length: 24 },
(_, i) => `Line ${i + 1} of the page, scrolling up under the bar.`
);
export default function BottomNavFade() {
const [value, setValue] = useState("home");
const [fade, setFade] = useState(0.5);
const HomeIcon = useIcon("home");
const SearchIcon = useIcon("search");
const HeartIcon = useIcon("heart");
const UserIcon = useIcon("user");
return (
<div className="flex flex-col items-center gap-5">
<div className="flex w-56 items-center gap-3 text-sm text-muted-foreground">
fade
<Slider
aria-label="fade"
min={0}
max={1}
step={0.05}
value={fade}
onValueChange={(v) => setFade(v as number)}
/>
<span className="w-8 tabular-nums text-foreground">{fade.toFixed(2)}</span>
</div>
<PhoneFrame route={value}>
<div className="px-5 pb-28">
{LINES.map((line, i) => (
<p key={i} className="mt-3 text-sm">
{line}
</p>

Icon Only

Icon-only keeps each label as screen-reader text.

Home

import { useState } from "react";
import { useIcon } from "@/lib/icon-context";
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav";
import { PhoneFrame } from "@/lib/docs/phone-frame";
const LABELS: Record<string, string> = {
home: "Home",
search: "Search",
likes: "Likes",
profile: "Profile",
};
export default function BottomNavIconOnly() {
const [value, setValue] = useState("home");
const HomeIcon = useIcon("home");
const SearchIcon = useIcon("search");
const HeartIcon = useIcon("heart");
const UserIcon = useIcon("user");
return (
<PhoneFrame route={value}>
<p className="px-5 text-lg font-semibold">{LABELS[value]}</p>
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} aria-hidden className="mx-5 mt-3 h-16 rounded-xl bg-muted" />
))}
<BottomNav value={value} onValueChange={setValue} labels={false}>
<BottomNavItem value="home" icon={<HomeIcon />}>
Home
</BottomNavItem>
<BottomNavItem value="search" icon={<SearchIcon />}>
Search
</BottomNavItem>
<BottomNavItem value="likes" icon={<HeartIcon />}>
Likes
</BottomNavItem>
<BottomNavItem value="profile" icon={<UserIcon />}>
Profile
</BottomNavItem>
</BottomNav>

Three Items

Four or more items fill the bar edge to edge, as iPhone's tab bar does. Two or three are drawn in toward the middle (three take 72% of the bar, two 56%), so they don't sit a thumb's width apart.

Home

import { useState } from "react";
import { useIcon } from "@/lib/icon-context";
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav";
import { PhoneFrame } from "@/lib/docs/phone-frame";
const LABELS: Record<string, string> = {
home: "Home",
search: "Search",
profile: "Profile",
};
export default function BottomNavThreeItems() {
const [value, setValue] = useState("home");
const HomeIcon = useIcon("home");
const SearchIcon = useIcon("search");
const UserIcon = useIcon("user");
return (
<PhoneFrame route={value}>
<p className="px-5 text-lg font-semibold">{LABELS[value]}</p>
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} aria-hidden className="mx-5 mt-3 h-16 rounded-xl bg-muted" />
))}
<BottomNav value={value} onValueChange={setValue}>
<BottomNavItem value="home" icon={<HomeIcon />}>
Home
</BottomNavItem>
<BottomNavItem value="search" icon={<SearchIcon />}>
Search
</BottomNavItem>
<BottomNavItem value="profile" icon={<UserIcon />}>
Profile
</BottomNavItem>
</BottomNav>
</PhoneFrame>
);
}

Solid

A blurred solid strip instead of the gradient, for pages whose content shouldn't bleed into the bar.

Home

import { useState } from "react";
import { useIcon } from "@/lib/icon-context";
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav";
import { PhoneFrame } from "@/lib/docs/phone-frame";
const LABELS: Record<string, string> = {
home: "Home",
search: "Search",
likes: "Likes",
profile: "Profile",
};
export default function BottomNavSolid() {
const [value, setValue] = useState("home");
const HomeIcon = useIcon("home");
const SearchIcon = useIcon("search");
const HeartIcon = useIcon("heart");
const UserIcon = useIcon("user");
return (
<PhoneFrame route={value}>
<p className="px-5 text-lg font-semibold">{LABELS[value]}</p>
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} aria-hidden className="mx-5 mt-3 h-16 rounded-xl bg-muted" />
))}
<BottomNav value={value} onValueChange={setValue} gradient={false}>
<BottomNavItem value="home" icon={<HomeIcon />}>
Home
</BottomNavItem>
<BottomNavItem value="search" icon={<SearchIcon />}>
Search
</BottomNavItem>
<BottomNavItem value="likes" icon={<HeartIcon />}>
Likes
</BottomNavItem>
<BottomNavItem value="profile" icon={<UserIcon />}>
Profile
</BottomNavItem>
</BottomNav>

API Reference

BottomNav

BottomNavItem