Liquid Glass Tab Bar in Next.js and Tailwind

4 min read

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.

On this page
  1. Step 1: put the bar in a layout
  2. Step 2: the glass
  3. Step 3: links and the active tab
  4. Step 4: slide the indicator with one CSS variable
  5. Step 5: the squish
  6. Step 6: turn it off for reduced motion
  7. Testing it on a phone
  8. More animated pieces
  9. FAQ
  10. How do I make a liquid glass effect in Tailwind?
  11. Should a bottom tab bar use role tablist?
  12. How do I animate the active tab indicator in Next.js?
  13. Why does my tab bar hide behind the iPhone home indicator?

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.

Step 1: put the bar in a layout

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.tsx
import 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.

Step 2: the glass

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:

css
/* app/globals.css */
.glass {
  background: rgb(23 23 23 / 0.92);
  border: 1px solid rgb(255 255 255 / 0.14);
  box-shadow:
    inset 0 1px 0 rgb(255 255 255 / 0.25),
    0 12px 32px rgb(0 0 0 / 0.35);
}

@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .glass {
    background: rgb(255 255 255 / 0.08);
    -webkit-backdrop-filter: blur(20px) saturate(160%);
    backdrop-filter: blur(20px) saturate(160%);
  }
}

@media (prefers-reduced-transparency: reduce) {
  .glass {
    background: rgb(23 23 23 / 0.96);
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

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.

tsx
// components/glass-tab-bar.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import type { CSSProperties } from "react";

const tabs = [
  { href: "/", label: "Home", icon: "⌂" },
  { href: "/search", label: "Search", icon: "⌕" },
  { href: "/inbox", label: "Inbox", icon: "✉" },
  { href: "/profile", label: "Profile", icon: "◉" },
];

function activeIndex(pathname: string) {
  return tabs.findIndex((t) =>
    t.href === "/" ? pathname === "/" : pathname === t.href || pathname.startsWith(t.href + "/"),
  );
}

export function GlassTabBar() {
  const pathname = usePathname();
  const index = activeIndex(pathname);

  return (
    <nav
      aria-label="Main"
      className="fixed inset-x-0 bottom-0 z-50 flex justify-center px-4 pb-[max(1rem,env(safe-area-inset-bottom))]"
    >
      <div
        className="glass relative grid w-full max-w-sm grid-cols-4 rounded-full p-1.5"
        style={{ "--count": tabs.length, "--index": Math.max(index, 0) } as CSSProperties}
      >
        {index >= 0 && (
          <span aria-hidden className="tab-indicator">
            <span key={index} className="tab-blob" />
          </span>
        )}
        {tabs.map((tab, i) => {
          const active = i === index;
          return (
            <Link
              key={tab.href}
              href={tab.href}
              prefetch={false}
              aria-current={active ? "page" : undefined}
              className="relative z-10 flex flex-col items-center gap-0.5 rounded-full py-2 text-xs text-white/70 outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-white/70 aria-[current=page]:text-lime-300"
            >
              <span
                aria-hidden
                className={`text-lg leading-none transition-transform duration-300 ${active ? "scale-110" : "scale-100"}`}
              >
                {tab.icon}
              </span>
              {tab.label}
            </Link>
          );
        })}
      </div>
    </nav>
  );
}

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.

Step 4: slide the indicator with one CSS variable

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:

css
.tab-indicator {
  position: absolute;
  inset-block: 0.375rem;
  left: 0.375rem;
  width: calc((100% - 0.75rem) / var(--count));
  transform: translateX(calc(var(--index) * 100%));
  transition: transform 420ms cubic-bezier(0.34, 1.4, 0.64, 1);
}

.tab-blob {
  display: block;
  height: 100%;
  border-radius: 9999px;
  background: rgb(255 255 255 / 0.16);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.3);
  animation: squish 420ms cubic-bezier(0.34, 1.4, 0.64, 1);
}

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.

Step 5: the squish

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.

css
@keyframes squish {
  0% { transform: scale(1, 1); }
  35% { transform: scale(1.22, 0.86); }
  70% { transform: scale(0.95, 1.05); }
  100% { transform: scale(1, 1); }
}

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.

Step 6: turn it off for reduced motion

css
@media (prefers-reduced-motion: reduce) {
  .tab-indicator { transition: none; }
  .tab-blob { animation: none; }
}

The active tab still gets the indicator and the lime label. It just appears in place.

Testing it on a phone

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.

More animated pieces

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.

FAQ

How do I make a liquid glass effect in Tailwind?

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.

Should a bottom tab bar use role tablist?

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.

How do I animate the active tab indicator in Next.js?

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.

Why does my tab bar hide behind the iPhone home indicator?

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.