Skip to content

Pro pre-sale is open. Installation opens January 2027. See Pro

Components

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.

  • npm: 1 direct, 3 total
  • registry: 3 direct, 3 total

Preview

Your workspace

Choose a destination below.

Props

  • showLabels
  • destinations
  • badges
  • count
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

Your workspace

Choose a destination below.

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

Your workspace

Choose a destination below.

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

Your workspace

Choose a destination below.

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>

Your workspace

Choose a destination below.

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.

Your workspace

Choose a destination below.

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

NameTypeValuesDefaultDescription
labelstring-MainOn 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.
valuestring--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 requiredstring--On BottomNavItem: destination URL and selection id. Required even with asChild.
icon requirednode--On BottomNavItem: the regular 20px icon.
activeIcon requirednode--On BottomNavItem: the filled icon for the active state. Supply the matching filled variant of the regular icon, at 20px.
activeboolean-falseOn BottomNavItem: marks this destination current when BottomNav has no value. Selection still uses normal link navigation.
countnumber--On BottomNavItem: a count above zero adds a badge and names the count through countLabel. Zero and negative counts are hidden.
asChildboolean-falseOn BottomNavItem: render the destination link behavior on its only child.
onClickReact.MouseEventHandler<HTMLAnchorElement>--On BottomNavItem: native link click handler. Use application navigation or preventDefault() for an in-place demonstration.
showLabelsboolean-trueOn BottomNav: show destination labels below their icons. Set false to visually hide labels while keeping every link named for assistive technology.

Slots

NameRequiredDescription
childrenyes3 to 5 BottomNavItem with href, icon, activeIcon and label, plus optional count and active.

Anatomy

PartDescription
rootTransparent fixed positioning wrapper, hidden from md up, with horizontal and bottom safe-area padding. Empty space around the capsule does not intercept pointer events.
itemOne equal-width destination link: 20px icon over a font-size-xs label, with a rounded selection pill and at least size-element-xl height.
iconThe 20px outline icon, replaced with the required filled activeIcon when selected.
labelThe destination text, visually hidden when showLabels is false. Long visible labels truncate.
badgeOptional solid danger count Badge on the icon, with text-on-danger.
listCentered floating capsule on material-thin, with a maximum panel width, rounded-full corners, inner padding and a shadow.
indicatorOne 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

KeysAction
TabMoves between destinations (links), in order.
EnterFollows 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-nav

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