BottomNav
A floating capsule navigation bar below md, with 3 to 5 top-level destinations. The glass surface sits above the bottom safe area with a small gap. Labels are visible by default and can be visually hidden with showLabels. It is separate from Sidebar: an app that has both renders its own items in each. The page viewport needs viewport-fit=cover.
Preview
import { useState, type MouseEvent } from "react"
import { IconHome, IconHomeFilled, IconMail, IconMailFilled, IconSearch, IconSearchFilled } from "@tabler/icons-react"
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav"
const [destination, setDestination] = useState("#home")
const selectDestination = (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); setDestination(event.currentTarget.getAttribute("href") ?? "#home") }
<div data-slot="bottom-nav-preview" className="mx-auto flex h-80 w-[393px] shrink-0 flex-col justify-between overflow-hidden rounded-container border border-border bg-background-surface">
<div className="flex flex-col gap-xs p-lg">
<p className="text-sm font-medium">Your workspace</p>
<p className="text-xs text-text-secondary">Choose a destination below.</p>
</div>
<BottomNav label="Main" value={destination} className="relative md:block">
<BottomNavItem href="#home" label="Home" icon={<IconHome size={20} />} activeIcon={<IconHomeFilled size={20} />} onClick={selectDestination} />
<BottomNavItem href="#search" label="Search" icon={<IconSearch size={20} />} activeIcon={<IconSearchFilled size={20} />} onClick={selectDestination} />
<BottomNavItem href="#inbox" label="Inbox" icon={<IconMail size={20} />} activeIcon={<IconMailFilled size={20} />} count={3} onClick={selectDestination} />
</BottomNav>
</div>Usage
When to use
- Show a short list of top-level destinations at the bottom of a small screen.
When not to use
- Put actions such as Create or Share in the navigation. Use Button.
- Show the same bar next to a visible Sidebar.
Examples
Three destinations
import { useState, type MouseEvent } from "react"
import { IconHome, IconHomeFilled, IconMail, IconMailFilled, IconSearch, IconSearchFilled } from "@tabler/icons-react"
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav"
const [destination, setDestination] = useState("#home")
const selectDestination = (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); setDestination(event.currentTarget.getAttribute("href") ?? "#home") }
<div data-slot="bottom-nav-preview" className="mx-auto flex h-80 w-[393px] shrink-0 flex-col justify-between overflow-hidden rounded-container border border-border bg-background-surface">
<div className="flex flex-col gap-xs p-lg">
<p className="text-sm font-medium">Your workspace</p>
<p className="text-xs text-text-secondary">Choose a destination below.</p>
</div>
<BottomNav label="Main" value={destination} className="relative md:block">
<BottomNavItem href="#home" onClick={selectDestination} icon={<IconHome size={20} />} activeIcon={<IconHomeFilled size={20} />} label="Home" active />
<BottomNavItem href="#search" onClick={selectDestination} icon={<IconSearch size={20} />} activeIcon={<IconSearchFilled size={20} />} label="Search" />
<BottomNavItem href="#inbox" onClick={selectDestination} icon={<IconMail size={20} />} activeIcon={<IconMailFilled size={20} />} label="Inbox" count={3} />
</BottomNav>
</div>Five destinations
import { useState, type MouseEvent } from "react"
import { IconHome, IconHomeFilled, IconLayoutGrid, IconLayoutGridFilled, IconMail, IconMailFilled, IconSearch, IconSearchFilled, IconUser, IconUserFilled } from "@tabler/icons-react"
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav"
const [destination, setDestination] = useState("#home")
const selectDestination = (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); setDestination(event.currentTarget.getAttribute("href") ?? "#home") }
<div data-slot="bottom-nav-preview" className="mx-auto flex h-80 w-[393px] shrink-0 flex-col justify-between overflow-hidden rounded-container border border-border bg-background-surface">
<div className="flex flex-col gap-xs p-lg">
<p className="text-sm font-medium">Your workspace</p>
<p className="text-xs text-text-secondary">Choose a destination below.</p>
</div>
<BottomNav label="Main" value={destination} className="relative md:block">
<BottomNavItem href="#home" onClick={selectDestination} icon={<IconHome size={20} />} activeIcon={<IconHomeFilled size={20} />} label="Home" active />
<BottomNavItem href="#search" onClick={selectDestination} icon={<IconSearch size={20} />} activeIcon={<IconSearchFilled size={20} />} label="Search" />
<BottomNavItem href="#inbox" onClick={selectDestination} icon={<IconMail size={20} />} activeIcon={<IconMailFilled size={20} />} label="Inbox" count={3} />
<BottomNavItem href="#projects" onClick={selectDestination} icon={<IconLayoutGrid size={20} />} activeIcon={<IconLayoutGridFilled size={20} />} label="Projects" />
<BottomNavItem href="#profile" onClick={selectDestination} icon={<IconUser size={20} />} activeIcon={<IconUserFilled size={20} />} label="Profile" />
</BottomNav>
</div>Current route
import { useState, type MouseEvent } from "react"
import { IconHome, IconHomeFilled, IconLayoutGrid, IconLayoutGridFilled, IconMail, IconMailFilled, IconSearch, IconSearchFilled } from "@tabler/icons-react"
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav"
const [destination, setDestination] = useState("#home")
const selectDestination = (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); setDestination(event.currentTarget.getAttribute("href") ?? "#home") }
<div data-slot="bottom-nav-preview" className="mx-auto flex h-80 w-[393px] shrink-0 flex-col justify-between overflow-hidden rounded-container border border-border bg-background-surface">
<div className="flex flex-col gap-xs p-lg">
<p className="text-sm font-medium">Your workspace</p>
<p className="text-xs text-text-secondary">Choose a destination below.</p>
</div>
<BottomNav label="Main" value={destination} className="relative md:block">
<BottomNavItem href="#home" onClick={selectDestination} icon={<IconHome size={20} />} activeIcon={<IconHomeFilled size={20} />} label="Home" active />
<BottomNavItem href="#search" onClick={selectDestination} icon={<IconSearch size={20} />} activeIcon={<IconSearchFilled size={20} />} label="Search" />
<BottomNavItem href="#inbox" onClick={selectDestination} icon={<IconMail size={20} />} activeIcon={<IconMailFilled size={20} />} label="Inbox" count={3} />
<BottomNavItem href="#projects" onClick={selectDestination} icon={<IconLayoutGrid size={20} />} activeIcon={<IconLayoutGridFilled size={20} />} label="Projects" />
</BottomNav>
</div>Custom link elements
import { useState, type MouseEvent } from "react"
import { IconHome, IconHomeFilled, IconMail, IconMailFilled, IconSearch, IconSearchFilled } from "@tabler/icons-react"
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav"
const [destination, setDestination] = useState("#home")
const selectDestination = (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); setDestination(event.currentTarget.getAttribute("href") ?? "#home") }
<div data-slot="bottom-nav-preview" className="mx-auto flex h-80 w-[393px] shrink-0 flex-col justify-between overflow-hidden rounded-container border border-border bg-background-surface">
<div className="flex flex-col gap-xs p-lg">
<p className="text-sm font-medium">Your workspace</p>
<p className="text-xs text-text-secondary">Choose a destination below.</p>
</div>
<BottomNav label="Main" value={destination} className="relative md:block">
<BottomNavItem href="#home" onClick={selectDestination} icon={<IconHome size={20} />} activeIcon={<IconHomeFilled size={20} />} label="Home" active asChild>
<a href="#home">Home</a>
</BottomNavItem>
<BottomNavItem href="#search" onClick={selectDestination} icon={<IconSearch size={20} />} activeIcon={<IconSearchFilled size={20} />} label="Search" asChild>
<a href="#search">Search</a>
</BottomNavItem>
<BottomNavItem href="#inbox" onClick={selectDestination} icon={<IconMail size={20} />} activeIcon={<IconMailFilled size={20} />} label="Inbox" asChild>
<a href="#inbox">Inbox</a>
</BottomNavItem>
</BottomNav>
</div>Icons only
Hide visible labels while preserving destination names for assistive technology.
import { useState, type MouseEvent } from "react"
import { IconHome, IconHomeFilled, IconMail, IconMailFilled, IconSearch, IconSearchFilled } from "@tabler/icons-react"
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav"
const [destination, setDestination] = useState("#home")
const selectDestination = (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); setDestination(event.currentTarget.getAttribute("href") ?? "#home") }
<div data-slot="bottom-nav-preview" className="mx-auto flex h-80 w-[393px] shrink-0 flex-col justify-between overflow-hidden rounded-container border border-border bg-background-surface">
<div className="flex flex-col gap-xs p-lg">
<p className="text-sm font-medium">Your workspace</p>
<p className="text-xs text-text-secondary">Choose a destination below.</p>
</div>
<BottomNav label="Main" value={destination} showLabels={false} className="relative md:block">
<BottomNavItem href="#home" onClick={selectDestination} icon={<IconHome size={20} />} activeIcon={<IconHomeFilled size={20} />} label="Home" active />
<BottomNavItem href="#search" onClick={selectDestination} icon={<IconSearch size={20} />} activeIcon={<IconSearchFilled size={20} />} label="Search" />
<BottomNavItem href="#inbox" onClick={selectDestination} icon={<IconMail size={20} />} activeIcon={<IconMailFilled size={20} />} label="Inbox" count={3} />
</BottomNav>
</div>Do and don't
Do
- Use 3 to 5 destinations with short labels.
- Pad page content so the bar never covers the last row.
- In these embedded demos, relative md:block keeps the bar inside its cell and visible on desktop. Omit those demo classes for a fixed mobile bar in an application.
- Use the current route as value, or set active on one item. The component does not own navigation state.
- Keep labels visible for unfamiliar destinations. Set showLabels={false} only when the icons are familiar; always supply each item label.
- Leave room below page content for the capsule height, its bottom gap and the safe-area inset.
- Supply a filled activeIcon for every destination. Each embedded example uses independent local selection and a 393px mobile canvas; scroll the canvas horizontally when the documentation cell is narrower.
Don't
- Do not put actions (Create, Share) in the bar; use a Button.
- Do not show it next to an open Sidebar.
Props
| Name | Type | Values | Default | Description |
|---|---|---|---|---|
| label | string | - | Main | On BottomNav: accessible name of the navigation landmark. On BottomNavItem: required destination text, visible unless showLabels is false. |
| countLabel | (label: string, count: number) => string | - | - | Names an item that shows a count, replacing its accessible name: (label, count) => `{label}, {count} new` by default. Vietnamese: `{label}, {count} mục mới`. Optional. |
| value | string | - | - | On BottomNav: active destination href, usually supplied by the application router. Takes precedence over item active props. BottomNav does not change it on click. |
| href required | string | - | - | On BottomNavItem: destination URL and selection id. Required even with asChild. |
| icon required | node | - | - | On BottomNavItem: the regular 20px icon. |
| activeIcon required | node | - | - | On BottomNavItem: the filled icon for the active state. Supply the matching filled variant of the regular icon, at 20px. |
| active | boolean | - | false | On BottomNavItem: marks this destination current when BottomNav has no value. Selection still uses normal link navigation. |
| count | number | - | - | On BottomNavItem: a count above zero adds a badge and names the count through countLabel. Zero and negative counts are hidden. |
| asChild | boolean | - | false | On BottomNavItem: render the destination link behavior on its only child. |
| onClick | React.MouseEventHandler<HTMLAnchorElement> | - | - | On BottomNavItem: native link click handler. Use application navigation or preventDefault() for an in-place demonstration. |
| showLabels | boolean | - | true | On BottomNav: show destination labels below their icons. Set false to visually hide labels while keeping every link named for assistive technology. |
Slots
| Name | Required | Description |
|---|---|---|
| children | yes | 3 to 5 BottomNavItem with href, icon, activeIcon and label, plus optional count and active. |
Anatomy
| Part | Description |
|---|---|
| root | Transparent fixed positioning wrapper, hidden from md up, with horizontal and bottom safe-area padding. Empty space around the capsule does not intercept pointer events. |
| item | One equal-width destination link: 20px icon over a font-size-xs label, with a rounded selection pill and at least size-element-xl height. |
| icon | The 20px outline icon, replaced with the required filled activeIcon when selected. |
| label | The destination text, visually hidden when showLabels is false. Long visible labels truncate. |
| badge | Optional solid danger count Badge on the icon, with text-on-danger. |
| list | Centered floating capsule on material-thin, with a maximum panel width, rounded-full corners, inner padding and a shadow. |
| indicator | One rounded pill that slides between equal-width destinations over duration-medium. It resizes with the bar; reduced motion switches selection without movement. |
Accessibility
Role: navigation
| Keys | Action |
|---|---|
| Tab | Moves between destinations (links), in order. |
| Enter | Follows the link. |
- A nav landmark named by label. Items are links, not tabs: no role tab and no arrow-key roving.
- The active item has aria-current="page" and uses the Filled icon (DESIGN 9.1).
- Labels are visible by default. With showLabels=false, the label stays available to assistive technology through visually hidden text.
- A count Badge adds its number to the accessible name, through countLabel.
Install
npx werewolf-ui add bottom-navRun npx werewolf-ui init once in your app before adding components. Init sets up tokens, their CSS import and agent rules. Add installs this component and its dependencies while preserving your theme. See Installation for requirements and the alternative registry setup.