Bottom Tab Bar
A mobile tab bar with a sliding indicator, badges that clear on visit, a centre create button and tap-again-to-top.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/bottom-tab-bar.json'use client'
import { useLayoutEffect, useRef, useState } from 'react'
import './bottom-tab-bar.css'
const ICONS: Record<string, React.ReactNode> = {
home: <path d="M4 11 12 4l8 7v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z" />,
search: <><circle cx="11" cy="11" r="6" /><path d="m20 20-4.3-4.3" /></>,
inbox: <><path d="M4 13h4l1.5 3h5L16 13h4" /><path d="M5.5 6h13L20 13v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-5z" /></>,
profile: <><circle cx="12" cy="8.5" r="3.5" /><path d="M5 20c1-3.5 3.8-5 7-5s6 1.5 7 5" /></>,
}
export interface Tab { id: string; label: string; badge?: number }
export interface BottomTabBarProps {
tabs?: Tab[]
defaultValue?: string
onValueChange?: (id: string) => void
onCreate?: (kind: string) => void
create?: boolean
labels?: 'on' | 'active'
fill?: 'dark' | 'light'
className?: string
}
const DEF: Tab[] = [{ id: 'home', label: 'Home' }, { id: 'search', label: 'Search' }, { id: 'inbox', label: 'Inbox', badge: 3 }, { id: 'profile', label: 'Profile', badge: 1 }]
/** Mobile bottom navigation with badges and a centre create button. */
export function BottomTabBar({ tabs = DEF, defaultValue = 'home', onValueChange, onCreate, create = true, labels = 'on', fill = 'dark', className }: BottomTabBarProps) {
const [cur, setCur] = useState(defaultValue)
const [badges, setBadges] = useState(() => Object.fromEntries(tabs.map((t) => [t.id, t.badge ?? 0])))
const [sheet, setSheet] = useState(false)
const [toast, setToast] = useState(0)
const [ind, setInd] = useState({ left: 0, width: 0 })
const bar = useRef<HTMLDivElement>(null)
const view = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const el = bar.current?.querySelector<HTMLElement>(`[data-id="${cur}"]`)
if (el) setInd({ left: el.offsetLeft + el.offsetWidth * 0.3, width: el.offsetWidth * 0.4 })
}, [cur, create])
const go = (id: string) => {
setSheet(false)
if (id === cur) { if (view.current && view.current.scrollTop > 0) { view.current.scrollTo({ top: 0, behavior: 'smooth' }) } setToast((t) => t + 1); return }
setCur(id); setBadges((b) => ({ ...b, [id]: 0 })); onValueChange?.(id); view.current?.scrollTo({ top: 0 })
}
const order = tabs.map((t) => t.id)
const key = (e: React.KeyboardEvent) => {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0
if (!d) return
const next = order[(order.indexOf(cur) + d + order.length) % order.length]
go(next); bar.current?.querySelector<HTMLElement>(`[data-id="${next}"]`)?.focus()
}
const t = tabs.find((x) => x.id === cur)!
const tabEl = (x: Tab) => (
<button key={x.id} data-id={x.id} type="button" role="tab" className="sd-btb__tab" aria-selected={cur === x.id} tabIndex={cur === x.id ? 0 : -1}
aria-label={badges[x.id] ? `${x.label}, ${badges[x.id]} new` : x.label} onClick={() => go(x.id)}>
<svg viewBox="0 0 24 24" aria-hidden>{ICONS[x.id] ?? <circle cx="12" cy="12" r="7" />}</svg>
<span className="sd-btb__l">{x.label}</span>
{!!badges[x.id] && <span className="sd-btb__badge" aria-hidden>{badges[x.id]}</span>}
</button>
)
const half = Math.ceil(tabs.length / 2)
return (
<div className={['sd-btb', className].filter(Boolean).join(' ')} data-fill={fill} data-labels={labels}>
<div ref={view} className="sd-btb__view" role="tabpanel" aria-label={t.label}>
<h3>{t.label}</h3>
{Array.from({ length: 9 }, (_, i) => <div key={cur + i} className="sd-btb__row"><i />{t.label} item {i + 1}<span>{i + 2}m</span></div>)}
</div>
{toast > 0 && <div key={toast} className="sd-btb__toast" role="status">Back to top</div>}
{sheet && (
<div className="sd-btb__sheet" role="menu" aria-label="Create">
{['New post', 'New message', 'New list'].map((k) => <button key={k} type="button" role="menuitem" onClick={() => { setSheet(false); onCreate?.(k) }}>{k}</button>)}
</div>
)}
<div ref={bar} className="sd-btb__bar" role="tablist" aria-label="Main" onKeyDown={key}>
<span className="sd-btb__ind" style={{ left: ind.left, width: ind.width }} aria-hidden />
{tabs.slice(0, half).map(tabEl)}
{create && (
<button type="button" className="sd-btb__new" aria-label="Create" aria-expanded={sheet} onClick={() => setSheet(!sheet)}>
<svg viewBox="0 0 24 24" aria-hidden><path d="M12 5v14M5 12h14" /></svg>
</button>
)}
{tabs.slice(half).map(tabEl)}
</div>
</div>
)
}.sd-btb { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-btb[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-btb :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-btb :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-btb .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-btb { position: relative; width: min(100%, 330px); height: 400px; border-radius: 26px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; display: flex; flex-direction: column; }
.sd-btb__view { flex: 1; overflow-y: auto; padding: 18px 16px; }
.sd-btb__view h3 { margin: 0 0 12px; font-size: 22px; letter-spacing: -0.02em; }
.sd-btb__row { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; }
.sd-btb__row i { width: 32px; height: 32px; flex: none; border-radius: 10px; background: var(--soft); }
.sd-btb__row span { color: var(--mute); margin-left: auto; font-size: 12px; }
.sd-btb__bar { position: relative; display: flex; align-items: stretch; height: 64px; padding: 0 6px 4px; border-top: 1px solid var(--line); background: var(--bg); }
.sd-btb__ind { position: absolute; top: -1px; height: 2px; border-radius: 2px; background: var(--fg); transition: left 320ms cubic-bezier(0.3, 1.3, 0.4, 1), width 320ms; }
.sd-btb__tab { position: relative; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; color: var(--mute) !important; font-size: 10.5px; font-weight: 500; transition: color 150ms; }
.sd-btb__tab[aria-selected='true'] { color: var(--fg) !important; }
.sd-btb__tab svg { width: 23px; height: 23px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; transition: transform 220ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-btb__tab[aria-selected='true'] svg { transform: translateY(-1px) scale(1.06); }
.sd-btb[data-labels='active'] .sd-btb__tab:not([aria-selected='true']) .sd-btb__l { opacity: 0; }
.sd-btb__badge { position: absolute; top: 9px; left: calc(50% + 6px); min-width: 17px; height: 17px; padding: 0 4px; border-radius: 99px; background: var(--strong); color: var(--on); font-size: 10.5px; font-weight: 700; line-height: 17px; box-shadow: 0 0 0 2px var(--bg); animation: sd-btb-pop 300ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-btb__new { align-self: center; display: grid; place-items: center; width: 46px; height: 46px; margin: 0 4px; border-radius: 16px; background: var(--strong) !important; color: var(--on) !important; transition: transform 160ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-btb__new[aria-expanded='true'] { transform: rotate(45deg); }
.sd-btb__new svg { width: 20px; height: 20px; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; }
.sd-btb__sheet { position: absolute; left: 10px; right: 10px; bottom: 72px; padding: 6px; border-radius: 18px; background: var(--bg); box-shadow: 0 16px 40px -12px rgb(0 0 0 / 0.55), inset 0 0 0 1px var(--line); animation: sd-btb-up 240ms cubic-bezier(0.3, 1.3, 0.4, 1); }
.sd-btb__sheet button { display: block; width: 100%; padding: 11px 12px; border-radius: 12px; text-align: left; font-size: 14px; }
.sd-btb__sheet button:hover { background: var(--soft); }
.sd-btb__toast { position: absolute; left: 50%; top: 12px; transform: translateX(-50%); padding: 6px 12px; border-radius: 99px; background: var(--strong); color: var(--on); font-size: 12px; font-weight: 500; animation: sd-btb-up 220ms ease-out; }
@keyframes sd-btb-pop { from { transform: scale(0); } }
@keyframes sd-btb-up { from { opacity: 0; transform: translate(var(--tx, 0), 10px); } }
.sd-btb__toast { --tx: -50%; }
@media (prefers-reduced-motion: reduce) { .sd-btb *, .sd-btb *::before, .sd-btb *::after { animation: none !important; transition: none !important; } }Overview
Five destinations sit in thumb reach. The indicator slides to the active tab; a badge clears when you open its tab.
Tapping the active tab again scrolls its content back to the top, like native apps. The centre button opens a create sheet.
When to use
- Mobile apps and PWAs with three to five top-level destinations.
When not to use
- Desktop layouts: use a sidebar or top nav.
- More than five destinations.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/bottom-tab-bar.json$
npx shadcn@latest add https://sundaeui.dev/r/bottom-tab-bar.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/bottom-tab-bar.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/bottom-tab-bar.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy bottom-tab-bar.tsx and bottom-tab-bar.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { BottomTabBar } from '@/components/sundae/bottom-tab-bar'
<BottomTabBar />Props
| Prop | Type | Default |
|---|---|---|
tabsDestinations. | { id: string; label: string; badge?: number }[] | — |
value / onValueChangeActive tab. | string | — |
onCreateCentre button. | () => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A tablist with labelled tabs; badges are part of each label (“Inbox, 3 new”).
- Left/Right arrows move between tabs.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-btb; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Previous / next tab
FAQ
- Is Bottom Tab Bar free?
- Yes. Bottom Tab Bar is free for personal and commercial projects, with full source on this page.
- How do I install Bottom Tab Bar?
- Run npx shadcn@latest add https://sundaeui.dev/r/bottom-tab-bar.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Bottom Tab Bar support keyboard and screen readers?
- A tablist with labelled tabs; badges are part of each label (“Inbox, 3 new”). Left/Right arrows move between tabs.
- Does Bottom Tab Bar respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All navigation →Orbit Nav
Navigation · Playful- Pro
Collapsible Sidebar
Navigation · Refined Taffy Tabs
Navigation · PlayfulPagination
Navigation · RefinedUnderline Tabs
Navigation · RefinedBreadcrumbs
Navigation · Refined