Shadcn Bottom Navigation for React and Tailwind
Fixed bottom nav for mobile-first apps.
Installation
bunx --bun shadcn@latest add https://kit.dev/r/bottom-navigation.jsonpnpm dlx shadcn@latest add https://kit.dev/r/bottom-navigation.jsonnpx shadcn@latest add https://kit.dev/r/bottom-navigation.jsonyarn shadcn@latest add https://kit.dev/r/bottom-navigation.jsonThis component is built on Ark UI Tabs (the same primitive as Tabs), with its own parts and styles. Install Tabs if you use both.
Install the following dependencies:
bun add @ark-ui/reactpnpm add @ark-ui/reactnpm install @ark-ui/reactyarn add @ark-ui/reactCopy and paste the following code into your project.
"use client";
import { ark } from "@ark-ui/react/factory";
import {
Tabs as ArkTabs,
useTabs as useArkTabs,
useTabsContext as useArkTabsContext,
} from "@ark-ui/react/tabs";
import type React from "react";
import { cn } from "@/lib/utils";
export const useBottomNavigation = useArkTabs;
export const useBottomNavigationContext = useArkTabsContext;
export const BottomNavigationContext = ArkTabs.Context;
export const BottomNavigation = (
props: React.ComponentProps<typeof ArkTabs.Root>
) => {
const { lazyMount = true, unmountOnExit = true, className, ...rest } = props;
return (
<ArkTabs.Root
className={cn(
"w-full",
"min-h-[calc(var(--spacing)*14+env(safe-area-inset-bottom,0px))]",
className
)}
data-slot="bottom-navigation"
lazyMount={lazyMount}
unmountOnExit={unmountOnExit}
{...rest}
/>
);
};
export const BottomNavigationRootProvider = (
props: React.ComponentProps<typeof ArkTabs.RootProvider>
) => {
const { lazyMount = true, unmountOnExit = true, className, ...rest } = props;
return (
<ArkTabs.RootProvider
className={cn(
"w-full",
"min-h-[calc(var(--spacing)*14+env(safe-area-inset-bottom,0px))]",
className
)}
data-slot="bottom-navigation"
lazyMount={lazyMount}
unmountOnExit={unmountOnExit}
{...rest}
/>
);
};
export const BottomNavigationList = (
props: React.ComponentProps<typeof ArkTabs.List>
) => {
const { "aria-label": ariaLabel = "Main", className, ...rest } = props;
return (
<ArkTabs.List
aria-label={ariaLabel}
className={cn(
"fixed inset-x-0 bottom-0 z-10",
"flex w-full items-center justify-around",
"min-h-14 shrink-0",
"border-t bg-background/60 backdrop-blur-sm",
"pb-[env(safe-area-inset-bottom,0px)]",
className
)}
data-slot="bottom-navigation-list"
{...rest}
/>
);
};
export const BottomNavigationItem = (
props: React.ComponentProps<typeof ArkTabs.Trigger>
) => {
const { className, ...rest } = props;
return (
<ArkTabs.Trigger
className={cn(
"relative",
"min-w-0",
"flex flex-1 flex-col items-center justify-center gap-0.5",
"p-2",
"text-muted-foreground",
"cursor-pointer",
"transition-colors",
"hover:text-foreground",
"aria-selected:text-primary",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"data-disabled:pointer-events-none data-disabled:opacity-64",
"[&_svg:not([class*='size-'])]:size-5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"has-data-[slot=bottom-navigation-item-label]:[&_svg:not([class*='size-'])]:size-4",
"pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11",
"motion-reduce:transition-none!",
className
)}
data-slot="bottom-navigation-item"
{...rest}
/>
);
};
export const BottomNavigationItemIcon = (
props: React.ComponentProps<typeof ark.span>
) => {
const { className, ...rest } = props;
return (
<ark.span
aria-hidden
className={cn("flex items-center justify-center", className)}
data-slot="bottom-navigation-item-icon"
{...rest}
/>
);
};
export const BottomNavigationItemLabel = (
props: React.ComponentProps<typeof ark.span>
) => {
const { className, ...rest } = props;
return (
<ark.span
className={cn("truncate font-medium text-xs", className)}
data-slot="bottom-navigation-item-label"
{...rest}
/>
);
};
export const BottomNavigationContent = (
props: React.ComponentProps<typeof ArkTabs.Content>
) => {
const { className, ...rest } = props;
return (
<ArkTabs.Content
className={cn("min-h-0 flex-1 outline-none", className)}
data-slot="bottom-navigation-content"
{...rest}
/>
);
};Update the import paths to match your project setup.
Anatomy
BottomNavigation
├── BottomNavigationContent
└── BottomNavigationList
└── BottomNavigationItem
├── BottomNavigationItemIcon
└── BottomNavigationItemLabelUsage
import {
BottomNavigation,
BottomNavigationContent,
BottomNavigationList,
BottomNavigationItem,
BottomNavigationItemIcon,
BottomNavigationItemLabel,
} from "@/components/ui/bottom-navigation";<BottomNavigation defaultValue="home">
<BottomNavigationContent value="home">{/* page */}</BottomNavigationContent>
<BottomNavigationList>
<BottomNavigationItem value="home">
<BottomNavigationItemIcon>
<HomeIcon />
</BottomNavigationItemIcon>
<BottomNavigationItemLabel>Home</BottomNavigationItemLabel>
</BottomNavigationItem>
</BottomNavigationList>
</BottomNavigation>Keep three to five destinations. The list is position: fixed to the viewport so it stays on screen while the page scrolls. The root’s min-height reserves space so content is not hidden behind the bar.
In docs previews the bar is taken out of fixed with className="static" so it sits in the phone frame. Do not copy static into a full-screen app unless you want an in-flow footer.
Controlled
Use value and onValueChange to control the selected destination.
Root Provider
Use useBottomNavigation with BottomNavigationRootProvider when you need the API outside the tree.
States
Disabled
Examples
Icon only
Omit BottomNavigationItemLabel and set aria-label on each item.
With links
Use asChild on BottomNavigationItem with Link. Pair with navigate on the root for in-app routing.
With badge
Overlay a Badge on the icon for counts.
Guides
Fixed bar in the app
BottomNavigationList is fixed inset-x-0 bottom-0 and includes pb-[env(safe-area-inset-bottom)] for home-indicator inset. The root min-height is 3.5rem plus that inset so the last content is not covered.
Do not add absolute on the list in production — that was only used in older previews. For an embedded mock, use className="static" (or absolute inset-x-0 bottom-0 inside a relative frame).
Tabs
Bottom Navigation is Ark Tabs: same value / defaultValue / onValueChange, same keyboard model (horizontal). Use Tabs for in-page panels with a top tablist. Do not nest one inside the other.
Router links
<BottomNavigation
navigate={(details) => {
router.push(details.node.href);
}}
>API Reference
shadcn.io wraps Ark UI Tabs. Defaults below are shadcn.io values. lazyMount and unmountOnExit default to true (Ark: false).
asChild merges props onto a single child element.
BottomNavigation
Root. Renders a div. Reserves min-height for the fixed list.
| Prop | Type | Default | Description |
|---|---|---|---|
activationMode | "automatic" | "manual" | "automatic" | automatic selects on focus. manual selects on click / Enter / Space. |
asChild | boolean | false | Render the child element instead of a div. |
className | string | - | Class names on the root. |
composite | boolean | - | Treat as composed with other composite widgets. |
defaultValue | string | null | - | Uncontrolled selected item. |
deselectable | boolean | - | Allow clicking the active item to clear selection. |
hideMode | "display-none" | "activity" | "display-none" | How to hide mounted-but-inactive content. activity needs React 19+. |
id | string | - | Unique id for the machine. |
ids | Partial<{ root: string; trigger: (value: string) => string; list: string; content: (value: string) => string; indicator: string }> | - | Element ids for composition. |
lazyMount | boolean | true | Mount a content panel the first time it is selected. |
loopFocus | boolean | true | Loop keyboard focus from last to first item. |
navigate | (details: NavigateDetails) => void | - | Called when a link item is chosen. { value, node, href }. |
onFocusChange | (details: FocusChangeDetails) => void | - | Called when focus moves. { focusedValue: string }. |
onValueChange | (details: ValueChangeDetails) => void | - | Called when the selected item changes. { value: string }. |
orientation | "horizontal" | "vertical" | "horizontal" | Keep horizontal for a bottom bar. |
translations | { listLabel?: string } | - | Localized strings for the tablist. |
unmountOnExit | boolean | true | Unmount a panel after it is hidden. |
value | string | null | - | Controlled selected item. |
| Attribute | Description |
|---|---|
data-slot | bottom-navigation |
data-scope | tabs |
data-part | root |
data-orientation | "horizontal" or "vertical" |
data-focus | Present when the list is focused |
BottomNavigationList
Fixed bar at the bottom of the viewport. Renders a div with role="tablist".
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | "Main" | Accessible name of the tablist. |
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the list. Use static to undo fixed in embedded previews. |
| Attribute | Description |
|---|---|
data-slot | bottom-navigation-list |
data-scope | tabs |
data-part | list |
data-orientation | "horizontal" or "vertical" |
BottomNavigationItem
One destination. Renders a button with role="tab".
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | required | Id that must match a BottomNavigationContent when panels are used. |
disabled | boolean | - | Disable this item. |
asChild | boolean | false | Merge onto a single child (Link, a). |
className | string | - | Class names on the item. |
Icon-only items need aria-label. When a label is present, icons inside the item render at size-4; icon-only items use size-5. Coarse pointers get a 44×44 hit area.
| Attribute | Description |
|---|---|
data-slot | bottom-navigation-item |
data-scope | tabs |
data-part | trigger |
data-selected | Present when this item is selected |
data-disabled | Present when disabled |
data-focus | Present when focused |
data-orientation | "horizontal" or "vertical" |
aria-selected | "true" or "false" |
BottomNavigationItemIcon
Icon slot. Renders a span with aria-hidden.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the icon wrapper. |
| Attribute | Description |
|---|---|
data-slot | bottom-navigation-item-icon |
BottomNavigationItemLabel
Visible label. Renders a span. Truncates with text-xs.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the label. |
| Attribute | Description |
|---|---|
data-slot | bottom-navigation-item-label |
BottomNavigationContent
Panel for the selected destination. Renders a div with role="tabpanel". Optional if you drive the page with a router instead of panels.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | required | Id that must match a BottomNavigationItem. |
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the panel. |
| Attribute | Description |
|---|---|
data-slot | bottom-navigation-content |
data-scope | tabs |
data-part | content |
data-selected | Present when this panel is selected |
BottomNavigationRootProvider
Root alternative that takes the API from useBottomNavigation. Renders a div.
| Prop | Type | Default | Description |
|---|---|---|---|
value | UseTabsReturn | required | Return value of useBottomNavigation(). |
asChild | boolean | false | Render the child element instead of a div. |
className | string | - | Class names on the root. |
hideMode | "display-none" | "activity" | "display-none" | How to hide mounted-but-inactive content. |
lazyMount | boolean | true | Mount a panel the first time it is selected. |
unmountOnExit | boolean | true | Unmount a panel after it is hidden. |
Pass defaultValue and other machine options to useBottomNavigation(), not to the provider.
| Attribute | Description |
|---|---|
data-slot | bottom-navigation |
data-scope | tabs |
data-part | root |
useBottomNavigation
Creates the tabs API for BottomNavigationRootProvider. Same options as BottomNavigation except layout-only props.
const navigation = useBottomNavigation({ defaultValue: "home" });
navigation.setValue("profile");BottomNavigationContext / useBottomNavigationContext
Render-prop or hook access to selection. Use inside BottomNavigation or BottomNavigationRootProvider.
| Property | Type | Description |
|---|---|---|
value | string | null | Selected item. |
focusedValue | string | null | Focused item. |
setValue | (value: string) => void | Select an item. |
clearValue | () => void | Clear the selection. |
focus | () => void | Focus the selected item. |
selectNext | (fromValue?: string) => void | Select the next item. |
selectPrev | (fromValue?: string) => void | Select the previous item. |
BottomNavigationContext children: (context) => ReactNode.
Accessibility
Complies with the Tabs WAI-ARIA design pattern. The list defaults to aria-label="Main". Icon-only items need aria-label on BottomNavigationItem. BottomNavigationItemIcon is aria-hidden.
Keyboard support
| Key | Description |
|---|---|
Tab | Moves into the bar (selected item) or out to the next control. |
ArrowRight | Next item. Selects it when activationMode is automatic. |
ArrowLeft | Previous item. |
Home | First item. |
End | Last item. |
Enter / Space | Select the focused item (manual activation, and always via pointer). |