Glassmorphism Mobile Navigation: Bottom Tab Bar With Blur
Build a glassmorphism bottom tab bar with backdrop-filter blur, safe-area insets, and React — full CSS and component code inside.
Why Bottom Tab Bars and Glassmorphism Are a Natural Pair
Bottom tab bars sit directly on top of your content. They float there, always visible, always in the way — unless you treat them as part of the visual layer rather than a separate UI block. That's exactly what glassmorphism does. The blur and translucency let the page breathe underneath the nav, so it reads as depth rather than obstruction.
Honestly, most mobile nav bars look terrible because they're either fully opaque (kills immersion) or fully transparent (kills legibility). The frosted-glass middle ground — backdrop-filter: blur(12px) over a semi-transparent background — fixes both problems at once. You get context from the content behind while keeping the tab labels crisp.
Worth noting: this pattern has been mainstream in iOS since iOS 13 (2019), but CSS finally caught up to making it reliable across Android Chrome and Samsung Internet around 2023. As of mid-2026, backdrop-filter sits at ~97% global support according to caniuse. You're not being experimental anymore — this is just how mobile nav works now.
If you want to see what the effect looks like before writing a single line, the glassmorphism generator lets you tune blur, opacity, and border values in real time. Start there, grab the CSS, then come back here to wire it into a component.
The Core CSS: Blur, Background, and Safe Areas
Let's get the stylesheet out first, because the React part is actually the easier half. The two properties doing all the visual work are backdrop-filter and a semi-transparent background-color. Everything else is positioning and spacing.
.bottom-tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
align-items: center;
height: 64px;
/* The glass effect */
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
/* Subtle border at the top edge */
border-top: 1px solid rgba(255, 255, 255, 0.15);
/* iPhone notch / Android nav bar padding */
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
}The env(safe-area-inset-bottom) line is non-negotiable. Skip it and on an iPhone 15 your icons will disappear behind the home indicator bar — a 34px dead zone at the very bottom. On devices without a notch it evaluates to 0, so there's no downside to always including it.
One more thing — saturate(180%) in the backdrop-filter value. It boosts the colors bleeding through from background content, making the glass feel more vivid and less like a grey fog. You can dial it down to 120% for a subtler look, or push to 200% on dark UIs where you want more color punch. Quick aside: combining blur() and saturate() in a single backdrop-filter declaration is actually faster than two separate filters because the browser only needs one compositing pass.
For dark backgrounds (which is where glassmorphism really shines), shift the background color from white to black: rgba(0, 0, 0, 0.3) with a slightly higher blur of 20px reads beautifully. The glassmorphism components in Empire UI default to this dark variant for exactly that reason.
Building the React Component
The component itself is straightforward. Five tabs, an active state, icons, labels. The interesting design decision is how you signal the active tab — a glow, a pill, a top/bottom indicator? I'll show the pill approach because it layers naturally with the glass aesthetic.
import { useState } from 'react'
import { Home, Search, Bookmark, Bell, User } from 'lucide-react'
const TABS = [
{ id: 'home', Icon: Home, label: 'Home' },
{ id: 'search', Icon: Search, label: 'Search' },
{ id: 'saved', Icon: Bookmark, label: 'Saved' },
{ id: 'alerts', Icon: Bell, label: 'Alerts' },
{ id: 'profile', Icon: User, label: 'Profile' },
]
export function GlassBottomNav() {
const [active, setActive] = useState('home')
return (
<nav className="glass-bottom-nav">
{TABS.map(({ id, Icon, label }) => {
const isActive = active === id
return (
<button
key={id}
onClick={() => setActive(id)}
className={`tab-btn ${isActive ? 'tab-btn--active' : ''}`}
aria-current={isActive ? 'page' : undefined}
>
<span className="tab-icon">
<Icon size={22} strokeWidth={isActive ? 2.5 : 1.5} />
</span>
<span className="tab-label">{label}</span>
</button>
)
})}
</nav>
)
}Notice aria-current="page" on the active tab. Screen readers understand this attribute — it's the semantic equivalent of marking a nav item as the current location. Don't use aria-selected here; that's for tabs inside a tablist role, which is a different pattern.
The strokeWidth change (1.5 → 2.5) on active icons is a small trick that communicates selection through weight rather than just color. On small 22px icons, a bold stroke reads faster than a tint shift.
.glass-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
align-items: center;
height: 64px;
padding-bottom: env(safe-area-inset-bottom);
background: rgba(10, 10, 20, 0.35);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border-top: 1px solid rgba(255, 255, 255, 0.1);
z-index: 100;
}
.tab-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 8px 12px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 12px;
transition: background 0.2s ease;
color: rgba(255, 255, 255, 0.5);
}
.tab-btn--active {
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
}
.tab-label {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
}Adding the Active Indicator Pill
The pill background on .tab-btn--active works, but it's a bit static. Adding a subtle animation makes the tab switch feel reactive — not flashy, just alive. There are two common approaches: CSS transition on the background (what we have), or a sliding pill that physically moves between tabs.
The sliding pill is more complex but looks significantly better. It requires knowing the pixel position of each tab, which means either JavaScript measurement or CSS tricks with a CSS custom property. Here's the JS-driven version, which is the most reliable across different device widths:
import { useRef, useLayoutEffect } from 'react'
export function GlassBottomNavAnimated() {
const [active, setActive] = useState('home')
const [pillStyle, setPillStyle] = useState({ left: 0, width: 0 })
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({})
useLayoutEffect(() => {
const el = tabRefs.current[active]
if (!el) return
const parent = el.closest('nav')!.getBoundingClientRect()
const btn = el.getBoundingClientRect()
setPillStyle({
left: btn.left - parent.left,
width: btn.width,
})
}, [active])
return (
<nav className="glass-bottom-nav" style={{ position: 'relative' }}>
{/* Sliding pill */}
<span
className="tab-pill"
style={{
transform: `translateX(${pillStyle.left}px)`,
width: pillStyle.width,
}}
/>
{TABS.map(({ id, Icon, label }) => (
<button
key={id}
ref={el => { tabRefs.current[id] = el }}
onClick={() => setActive(id)}
className={`tab-btn ${active === id ? 'tab-btn--active' : ''}`}
>
<Icon size={22} strokeWidth={active === id ? 2.5 : 1.5} />
<span className="tab-label">{label}</span>
</button>
))}
</nav>
)
}.tab-pill {
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 44px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.12);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}In practice, useLayoutEffect is the right hook here — not useEffect. Because we're reading layout measurements and immediately writing visual state, doing it in useEffect causes a flash of the pill in the wrong position for one frame. useLayoutEffect runs synchronously after DOM updates, before paint.
Performance: When Blur Gets Expensive
On high-end phones, backdrop-filter: blur(20px) is completely fine. On a mid-range Android from 2022 — say a Snapdragon 695 device — sustained blur on a full-width element that updates every frame can push you into 40fps territory. The nav bar is position: fixed, so it's not re-rendering every scroll, but it is being composited constantly.
Two mitigations. First, add will-change: transform to the nav element. This tells the browser to promote it to its own GPU layer at paint time, which removes it from the main thread composite step. Second, set contain: paint layout on the nav — this tells the browser that nothing inside the nav can visually overflow, which speeds up paint invalidation.
.glass-bottom-nav {
/* ... your existing styles ... */
will-change: transform;
contain: paint layout;
}Look, these are micro-optimisations that you probably don't need until you're profiling. But if you're targeting a PWA with an aggressive performance budget, they're worth adding from the start. The backdrop-filter CSS guide on the blog has a deeper breakdown of compositing cost by blur radius — worth a read if you're shipping to emerging markets with lower-spec hardware.
One last thing: backdrop-filter forces a stacking context. Any child with position: absolute and a high z-index won't escape the nav's layer. This occasionally bites people building tooltip or dropdown menus that try to pop up from a nav icon. The fix is to render those portals in a separate DOM node outside the nav — React portals with document.body as the target.
Tailwind Version (For Those Who Don't Write Raw CSS Anymore)
If your project is Tailwind-first, you don't need the custom CSS file at all. Tailwind v3.3+ ships backdrop-blur-* and bg-white/[.08] (arbitrary opacity) utilities that cover this exactly. The safe-area inset needs a tiny plugin or inline style, but everything else is utility-class native.
export function GlassBottomNavTailwind() {
const [active, setActive] = useState('home')
return (
<nav
className="fixed bottom-0 left-0 right-0 z-50 flex justify-around items-center h-16
bg-white/[.08] backdrop-blur-xl backdrop-saturate-150
border-t border-white/10"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
{TABS.map(({ id, Icon, label }) => {
const isActive = active === id
return (
<button
key={id}
onClick={() => setActive(id)}
className={`flex flex-col items-center gap-0.5 px-3 py-2 rounded-xl transition-colors duration-200
${ isActive
? 'bg-white/10 text-white'
: 'text-white/50 hover:text-white/70'
}`}
>
<Icon size={22} strokeWidth={isActive ? 2.5 : 1.5} />
<span className="text-[10px] font-medium tracking-wide">{label}</span>
</button>
)
})}
</nav>
)
}The bg-white/[.08] arbitrary value syntax is doing the same job as rgba(255, 255, 255, 0.08). If you're targeting a dark UI, swap to bg-black/30 — that's rgba(0, 0, 0, 0.3), which tends to feel more natural on dark backgrounds where you're not blending into white page content.
That said, the style prop for safe-area insets is the one thing you can't avoid in pure Tailwind without a plugin. You could abstract this into a <SafeBottomNav> wrapper component that always applies that inline style, so you never forget it in a refactor.
Testing It on Real Devices (Not Just Chrome DevTools)
DevTools device emulation doesn't simulate env(safe-area-inset-bottom). In the emulator, that value always resolves to 0 — so your layout looks fine right up until an iPhone user opens your app and sees a nav bar clipped by the home indicator. Always test on real hardware before shipping.
For a quick real-device test without a full deployment, ngrok or localhost.run can expose your dev server to your phone on the same WiFi network in under 30 seconds. Way faster than building a production bundle and deploying.
What about the notch on the sides in landscape mode? That's env(safe-area-inset-left) and env(safe-area-inset-right). Bottom tab bars are usually hidden in landscape on mobile (the screen is too short), so this is often moot — but if you're keeping it visible, add left/right padding to the nav container using those env values.
If you want to skip building from scratch and start from a pre-tested baseline, browse components on Empire UI — the mobile nav components there have already gone through the safe-area and blur-performance gauntlet. The glassmorphism components section in particular has a bottom-bar variant ready to drop into your project.
FAQ
Yes, support is at ~97% globally as of mid-2026. The only gaps are very old Android WebViews and some niche browsers. The -webkit-backdrop-filter prefix still needed for some older Safari builds.
Add padding-bottom: env(safe-area-inset-bottom) to your nav element. This evaluates to 34px on notched iPhones and 0 on devices without one — no conditional logic needed.
It can. Adding will-change: transform and contain: paint layout to the nav element helps by promoting it to its own GPU layer. On truly constrained hardware, drop blur radius to 8px or 10px.
Absolutely — swap the <button> for <Link> from your routing library. Use usePathname() in Next.js App Router or useLocation() in React Router to derive the active tab from the current URL instead of local state.