Glass Tab Bar
A frosted-glass mobile bottom tab bar React component. Icons pop in one by one on mount, the active pill slides between tabs and the active label expands, with an optional raised centre button and unread badges.
A frosted-glass tab bar for the bottom of a mobile app. On mount each icon pops in turn on a slightly bouncy spring; when you switch tabs, the white active pill slides from the previous tab to the new one while the active tab expands from icon-only to icon + label. With an odd number of items you can turn on raisedCenter to make the middle item a raised round primary button (for example "New"); badges support unread counts (99+ above 99) and a small dot, and scale once whenever the count changes. The active value supports controlled and uncontrolled modes.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add glass-tab-bar.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<GlassTabBar/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/glass-tab-bar.en.jsonOr, once registries are configured in components.json, install it as @webberui/glass-tab-bar.
Usage
import { Compass, House, MessageCircle, Plus, User } from "lucide-react";
import { GlassTabBar } from "@/components/ui/glass-tab-bar";
const [tab, setTab] = React.useState("home");
<GlassTabBar
position="fixed"
raisedCenter
value={tab}
onValueChange={setTab}
onCenterPress={() => openComposer()}
items={[
{ id: "home", icon: <House />, label: "Home" },
{ id: "explore", icon: <Compass />, label: "Find" },
{ id: "create", icon: <Plus />, label: "New" },
{ id: "messages", icon: <MessageCircle />, label: "Inbox", badge: 3 },
{ id: "me", icon: <User />, label: "Me" },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | GlassTabBarItem[] | — | The list of tabs |
value | string | — | Controlled active id; when omitted the component manages it internally (uncontrolled) |
defaultValue | string | first item's id | Initial active id in uncontrolled mode |
onValueChange | (value: string) => void | — | Called when the active tab changes, with the new id |
raisedCenter | boolean | false | With an odd number of items (at least 3), turn the middle one into a raised round primary button |
onCenterPress | () => void | — | Called when the centre button is pressed |
centerSelectable | boolean | false | Whether the centre button also changes the value; by default it only fires onCenterPress |
blur | number | 18 | Glass backdrop blur (px); falls back to --wb-gtb-blur |
stagger | number | 60 | Delay between icons popping in on mount (ms); falls back to --wb-gtb-stagger |
position | "fixed" | "static" | "static" | fixed pins it to the bottom of the viewport with safe-area padding; static keeps it in normal flow |
ariaLabel | string | "Main navigation" | Accessible name of the nav |
className | string | — | Forwarded to the outer nav |
GlassTabBarItem
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique id, also used as the value |
icon | React.ReactNode | — | Tab icon; a 20px lucide icon is recommended |
label | string | — | Tab name; shown beside the icon when active, used as the accessible name otherwise |
badge | number | boolean | — | A number shows an unread count (99+ above 99); true shows a small dot |
CSS variables
| Field | Type | Default | Description |
|---|---|---|---|
--wb-gtb-blur | length | 18px | Glass backdrop blur |
--wb-gtb-stagger | time | 60ms | Delay between icon pops (the stagger prop wins) |
--wb-gtb-highlight | color | rgba(255,255,255,0.6) | 1px inner highlight along the top edge |
--wb-gtb-accent | color | #171717 (dark: #f5f5f5) | Centre button fill |
--wb-gtb-badge | color | #ef4444 | Badge fill |
How it works
- Glass material: a translucent white fill (neutral-900/55 in dark mode) with
backdrop-blurandbackdrop-saturate-150, a 1px inner highlight along the top edge, a faint 1px border and a soft outer shadow, all in a fully rounded pill. - The active pill slides between tabs with motion's
layoutId; thelayoutIdis prefixed withuseId, so several bars on one page never interfere with each other. - The active tab's width expands with a layout animation and its label fades in after a 60ms delay while sliding 4px in from the left; on first render it is shown directly, without the expand animation.
- The centre button is 56px across, floats 18px up and does not take part in the pill slide; it is not a tab, and pressing it only calls
onCenterPressunlesscenterSelectableis on. - The mount pop waits until the component has mounted, when the user's motion preference and the CSS variable values are both known, so the SSR first render matches the client.
Accessibility
- The outer element is a
nav(defaultaria-label"Main navigation") containing arole="tablist"; each tab is arole="tab"witharia-selected - Each tab's
aria-labelis its label, with ", 3 unread" or ", new activity" appended when it has a badge; the visual badge and expanded label are hidden from assistive technology to avoid double announcements - Roving tabindex: only the active tab is in the Tab order; ← / → cycle, Home / End jump to the ends, and moving also selects (automatic activation)
- The centre button is a plain
button(outside the tablist) using its item label as itsaria-label; withcenterSelectableon and selected it setsaria-current="page" - Every button is
type="button"and has a focus-visible ring - When the user has reduce motion enabled at the system level, icons do not pop in, the pill jumps straight into place, badges do not scale and the label appears directly
Menubar
A desktop-style menubar: once a menu is open, hovering a neighbour switches instantly and the panel slides to its new position instead of closing and reopening, with full arrow-key navigation.
Tilt Card
A 3D perspective tilt card — the card tilts with the cursor, a glare sweeps across its surface, it springs back on leave, and content can float up with translateZ.