TL;DR Render the bar once in the layout, read the route with usePathname and pass the active index to CSS as --index. Build the glass from a translucent background, backdrop-filter blur and saturate, a thin border and an inset highlight, with an opaque fallback. Slide the indicator by its own width times the index, and remount an inner span with key={index} to play a squish.
A liquid glass tab bar is a frosted, floating bar at the bottom of the screen with a soft blob that slides to the active tab and squishes a little as it lands. Apple's newer iOS design made the look popular, and now every second v0 or Lovable prompt asks for it. What comes back is usually a blurred rectangle with a background-color swap on the active link.
This post builds the real thing in Next.js App Router and Tailwind, step by step, with no animation library: the glass, a sliding indicator driven by the current route, the squish, safe-area padding for phones, and fallbacks for people who turned off transparency or motion.
I make qodesign, and one of our paid pieces is a Liquid Glass Tab Bar with a magnifying lens and a drop that bursts into a player. The code below is a simpler version written for this post, not that piece's source.
A tab bar has to survive navigation. If it lives in a page, it unmounts on every route change and the indicator can't slide from the old tab to the new one. Put it in the layout that wraps the tabbed routes, so it mounts once and only reads the new pathname:
tsx
// app/(tabs)/layout.tsximport type { ReactNode } from "react";import type { Viewport } from "next";import { GlassTabBar } from "@/components/glass-tab-bar";export const viewport: Viewport = { viewportFit: "cover" };export default function TabsLayout({ children }: { children: ReactNode }) { return ( <> <main className="pb-28">{children}</main> <GlassTabBar /> </> );}
viewportFit: "cover" lets the page draw under the iPhone home indicator, which is what makes env(safe-area-inset-bottom) return a real value in step 2. The pb-28 keeps the last bit of content from hiding under the bar.
Liquid Glass Tab Bar running live. A glass drop magnifies the tab under it, stretches between tabs and bursts into the player.
Glass in CSS is a translucent background, backdrop-filter with blur and a bit of saturation, a thin light border, and an inner highlight along the top edge. I keep it in plain CSS because the fallbacks are media and feature queries, and those read better there than as long Tailwind variants:
The default is nearly opaque, and the see-through version only turns on where blur is supported, so text stays readable in every browser. The saturate is what separates glass from frosted plastic: colors behind the bar get a bit richer instead of going grey. If you want the edge to actually bend what's behind it, that takes an SVG filter that only Chromium renders today. Liquid glass in React and Tailwind covers that part.
These tabs change the URL, so they're links. role="tablist" is for tabs that switch panels inside one page, and it makes screen readers announce the wrong thing here. A nav with links and aria-current="page" on the active one is correct.
The icons are plain characters so the snippet runs anywhere. In a shadcn project, swap them for lucide-react icons. The prefetch={false} is optional. Leave prefetching on if your tab pages are light.
The bar sets --index and --count. The indicator is one column wide and moves by its own width times the index, so there's no measuring and no layout effect:
The 0.375rem matches the p-1.5 padding on the bar. The curve's second value goes above 1, which makes the indicator overshoot slightly and settle back. That small overshoot is most of what people describe as liquid.
A drop of liquid stretches while it moves and wobbles when it stops. The outer span handles the travel, and the inner span runs a short keyframe on every change. The trick is key={index} on the inner span: React remounts it when the tab changes, and a newly mounted element plays its animation from the start.
Keep it in two layers. If you put the squish on the same element as the slide, the keyframe's transform replaces the translate while it runs and the indicator jumps.
Three checks catch most problems. Open it on an iPhone in Safari and confirm the bar sits above the home indicator instead of under it. Tab through it with a keyboard and check that the focus ring shows on each link. Emulate prefers-reduced-transparency in Chrome DevTools (Rendering panel) and check the bar goes solid and the labels are still readable. Browser support for that media query is still uneven, which is why the opaque default matters.
If you'd rather use Motion's layoutId for the slide, animated tabs with Framer Motion in Next.js shows that route. It's a good choice when the indicator has to change size between tabs of different widths. For equal columns, the CSS version above is lighter.
The qodesign-free repo has 5 open-source (MIT) components and dashboards for Next.js, each with a PROMPT.md you can hand to Cursor, v0 or Lovable. The site has 1 free piece and a paid catalog at $3 a piece. The full Liquid Glass Tab Bar adds a lens that magnifies the active icon, a compact bar on scroll and a mini player.
Use a translucent background, backdrop-filter blur with a little saturate, a thin light border and an inset highlight. Keep an opaque fallback for browsers without backdrop-filter and for people who set prefers-reduced-transparency.
Not when each tab is a separate page. Use a nav element with links and aria-current="page" on the active one. Tablist is for tabs that switch panels within one page.
Render the bar in a layout so it stays mounted, read the route with usePathname, and pass the active index to CSS as a variable. Translate the indicator by its own width times the index, with a CSS transition.
Set viewportFit to cover in the viewport export and add padding of env(safe-area-inset-bottom) to the bar. Without viewport-fit cover, the inset is always 0.