shadcn bottom navigation for a mobile web app

shadcn has no bottom navigation component yet. Here's the free way to build one from shadcn Buttons, and how to run the Liquid Glass Tab Bar in a shadcn app without it touching your theme.

Liquid Glass Tab Bar, preview
One payment$3

By buying you agree to the Terms.

Ready forCursorv0Lovableshadcn CLI

Full product pageReact · Tailwind · TypeScript

Use it in Next.js

app/page.tsx

import { MobileShell } from "@/components/mobile-shell";

export default function Page() {
  return (
    <>
      {/* Phones: the glass tab bar and its five screens fill the viewport. */}
      <div className="h-dvh md:hidden">
        <MobileShell />
      </div>

      {/* md and up: your usual shadcn layout (sidebar, navigation menu). */}
      <main className="hidden min-h-svh p-10 md:block">
        <h1 className="text-2xl font-semibold">Dashboard</h1>
      </main>
    </>
  );
}

components/mobile-shell.tsx

"use client";
import { LiquidGlassTabBar, TabScreens } from "@/components/LiquidGlassTabBar";
import NW, { type NorthwindData } from "@/data/northwind";
import { Button } from "@/components/ui/button";

// Labels are yours. Keep the five ids: each id picks its icon in the component's ICON map.
const data: NorthwindData = {
  ...NW,
  music: {
    ...NW.music,
    tabs: [
      { id: "home", label: "Home" },
      { id: "explore", label: "Search" },
      { id: "library", label: "Saved" },
      { id: "inbox", label: "Inbox", badge: 2 },
      { id: "profile", label: "Account" },
    ],
  },
};

// One screen per tab, in tab order. "dark" gives shadcn's dark tokens on the glass.
function Screen({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <section className="dark space-y-4 text-foreground">
      <h2 className="text-3xl font-bold tracking-tight">{title}</h2>
      {children}
    </section>
  );
}

const screens = [
  <Screen key="home" title="Home">
    <div className="grid grid-cols-2 gap-3">
      {["Runs", "Streak", "Distance", "Pace"].map((label) => (
        <div key={label} className="rounded-xl border bg-card p-4 text-card-foreground">
          <p className="text-sm text-muted-foreground">{label}</p>
          <p className="text-2xl font-semibold">12</p>
        </div>
      ))}
    </div>
    <Button className="w-full">Log a run</Button>
  </Screen>,
  <Screen key="search" title="Search">
    <p className="text-muted-foreground">Your search screen.</p>
  </Screen>,
  <Screen key="saved" title="Saved">
    <p className="text-muted-foreground">Saved items.</p>
  </Screen>,
  <Screen key="inbox" title="Inbox">
    <p className="text-muted-foreground">Two new messages.</p>
  </Screen>,
  <Screen key="account" title="Account">
    <Button variant="outline" className="w-full">Sign out</Button>
  </Screen>,
];

export function MobileShell() {
  return (
    <div className="qd-tabbar relative h-full">
      <div className="native">
        <TabScreens value={screens}>
          <LiquidGlassTabBar
            data={data}
            autoplayDemo={false}
            // The bar swaps its own screens; use this for analytics or to load a tab's data.
            onTabChange={(id) => console.info("tab", id)}
            onOpenPlayer={() => console.info("mini player opened")}
          />
        </TabScreens>
      </div>
    </div>
  );
}

Good to know

  • The tab bar switches its own five screens, so you don't route on each tap. onTabChange still gives you the tab id, for analytics or to load that tab's data.
  • Below md the shell fills the screen and your normal layout is hidden, and from md up it's the other way round. On desktop the shell is display: none, but its animation frame loop keeps running. If that matters to you, mount it only on phones.
  • Your screens sit inside a .qd-screen div that the scoped CSS doesn't reach, so shadcn components there look like they do anywhere else. Their text inherits the piece's font and light text colour, and the Screen wrapper adds shadcn's dark class so your tokens match the dark glass.
  • Scrolling inside a screen is handled by the piece (wheel and touch move the screen and shrink the bar), not by the browser's own scroll.
  • What we ran on Oct 25 2026: this exact code in a scratch Next.js 16.4.3 app with React 19.3.1, Tailwind CSS 4.3.3, framer-motion 14.6.0, radix-ui 1.8.1, class-variance-authority 0.7.1, cn 0.5.0, lucide-react 1.61.0, tw-animate-css 1.4.0, shadcn 4.22.1 (for shadcn/tailwind.css) and TypeScript 7.0.2, plus the shipped LiquidGlassTabBar files with the edits above. We copied shadcn's Button from its registry JSON by hand instead of running the CLI. tsc and next build passed, and an onTabChange typed (id: number) failed tsc as it should. In headless Chromium at 390 x 844 all five labels were read out ("Inbox, 2 new" for the badge), tapping Inbox showed the Inbox screen and logged "tab inbox", a shadcn Button inside a screen took the click, and the mini player opened the player sheet. Neither 390 nor 1440 scrolled sideways, and the free bottom nav marked Search with aria-current="page" on /search.
  • What we didn't run: Safari, Firefox, a real phone, a screen reader, a real data source, or the shadcn CLI itself.

shadcn bottom navigation: what shadcn ships today

  • shadcn has no bottom navigation component. We read its components page and its blocks page on October 25, 2026: no bottom navigation, no tab bar, and the blocks are sidebar, login and signup.
  • The request is open on GitHub as issue #8847, "[feat]: Add Bottom Navigation component (mobile-friendly, accessible, themeable)", opened November 19, 2025. It had 25 reactions and no comments when we read it today.
  • The two components people try first are the wrong shape for it. The docs describe Navigation Menu as "A collection of links for navigating websites." It opens dropdown panels from a bar at the top. Tabs is "A set of layered sections of content" that swaps panels inside one page, not routes.
  • If shadcn adds a bottom navigation component later, start with that one.

The free shadcn bottom navigation routes, and when they fit better

  • For most apps the right answer is a fixed nav with five shadcn Buttons used as links. It sits under the routes you already have, it uses your theme tokens, and it's free. The file below is exactly that: Next.js Link, usePathname for the active tab, and lucide-react icons, the icon set shadcn's own components import.
  • Put pb-16 or more on your page content so the bar doesn't cover the last row. On iPhone, env(safe-area-inset-bottom) only gets a value when the page uses viewport-fit=cover. In Next.js that's export const viewport = { viewportFit: "cover" } in your layout.
  • There are two free community versions you can copy. On 21st.dev, Bottom Nav Bar by arunachalam calls itself "A highly animated, touch-friendly mobile/navigation bar component" and is built with Next.js, Tailwind v4 and Framer Motion. shadcn.io is a community site, not part of shadcn/ui, and its navbar-mobile-bottom block is "A bottom tab bar for a running-diary app."
  • If you want a plain row of icons under pages you already have, use one of these. That describes most shadcn apps, and for them Liquid Glass Tab Bar is the worse fit.

components/bottom-nav.tsx

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Bookmark, House, Inbox, Search, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

const items = [
  { href: "/", label: "Home", icon: House },
  { href: "/search", label: "Search", icon: Search },
  { href: "/saved", label: "Saved", icon: Bookmark },
  { href: "/inbox", label: "Inbox", icon: Inbox },
  { href: "/account", label: "Account", icon: User },
];

export function BottomNav() {
  const pathname = usePathname();

  return (
    <nav
      aria-label="Main"
      className="fixed inset-x-0 bottom-0 z-50 border-t bg-background/90 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
    >
      <ul className="mx-auto grid max-w-md grid-cols-5">
        {items.map(({ href, label, icon: Icon }) => {
          const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
          return (
            <li key={href}>
              <Button
                asChild
                variant="ghost"
                className={cn("h-16 w-full flex-col gap-1 rounded-none text-xs", active ? "text-foreground" : "text-muted-foreground")}
              >
                <Link href={href} aria-current={active ? "page" : undefined}>
                  <Icon className="size-5" />
                  {label}
                </Link>
              </Button>
            </li>
          );
        })}
      </ul>
    </nav>
  );
}

Liquid Glass Tab Bar as a shadcn bottom navigation bar

  • Liquid Glass Tab Bar draws a whole phone screen: the glass bar, five screens that slide when you switch tabs, a glass drop that magnifies the tab under it and stretches while it moves, a mini player above the bar, and a player sheet the drop bursts into. Your screens go inside it.
  • It fits when the phone view of your app is five top-level screens and you want them to feel like one native app, for example a media app, a habit or fitness tracker, or the phone side of a dashboard. The snippet at the top of this page shows that, with shadcn Buttons and cards on the glass.
  • It has exactly five tabs, and each tab's icon comes from its id (home, explore, library, inbox, profile). The data prop changes the labels and the badge. A different icon means editing the ICON map in LiquidGlassTabBar.tsx. The mini player shows data.music.nowPlaying, and no prop turns it off.
  • It's React for the web, not React Native. If you build with Expo, React Navigation's bottom tabs are the place to look.
  • If the iOS 26 glass look is what brought you here rather than shadcn, our liquid glass navbar guide covers that side. This page stays on the shadcn side.

Keep the tab bar's CSS out of your shadcn theme

  • tabbar-liquid.css is unlayered and has a few global rules. It gives html and body the piece's dark background and font, resets every button, puts a --muted colour on :root, and gives .grid two columns with a 14px gap.
  • Tailwind v4 keeps its utilities and shadcn's base styles in cascade layers, and unlayered CSS beats layered CSS whatever the selector. We measured this in a scratch shadcn app with the file imported as shipped. A default shadcn Button lost its background and its padding (transparent, 0px). A div with grid gap-2 split into two 689px columns with a 14px gap. bg-muted turned #9AA0B4, and the body turned #0A0A0D in Space Grotesk.
  • The fix is two edits to your copy of tabbar-liquid.css, shown below. Wrap the file in @scope (.qd-tabbar) to (.qd-screen) and turn :root into :scope. The rules then apply only inside the wrapper and stop at the div that holds your screens. The html, body and global button rules no longer match anything. The extra .sb line hides the demo's fake 9:41 status bar, which you don't want on a real phone.
  • With both edits the same scratch app measured clean. The shadcn Button kept its colour and its 8px 16px padding. A grid grid-cols-2 gap-3 inside a tab screen kept its 173px columns and 12px gap. The body kept shadcn's white background, and the bar kept its blur(16px) saturate(1.9) glass.
  • Put qd-tabbar on the outer div and native on the div inside it, as in the snippet. Inside @scope a plain class selector doesn't match the scope root itself, so .native .screen needs .native one level down. Our first try put both classes on one div, and the screen kept its 390 x 844 phone frame with rounded corners.
  • MDN's compatibility data, read today, lists @scope in Chrome and Edge 118+, Firefox 146+ and Safari 17.4+. Safari 26.0 to 26.3 had a partial version that skips inputs and textareas, and the bar has neither. Older browsers skip the whole block, so the bar shows up unstyled there. We only ran Chromium.

components/tabbar-liquid.css

/* components/tabbar-liquid.css (your copy). Edit 1: the file's first rule was ":root{". */
@scope (.qd-tabbar) to (.qd-screen) {
:scope{font-family:var(--font-ui);color:var(--text);
  --ink:#0A0A0D;--surface:#111218; /* ...every variable that was on :root stays here... */
}
/* ...the rest of the file stays as it is... */

/* Edit 2: hide the demo's 9:41 status bar, then close the @scope block. */
.sb{display:none}
}

components/LiquidGlassTabBar.tsx

// components/LiquidGlassTabBar.tsx (your copy): three small edits.

// 1. The React import gains createContext, useContext and ReactNode.
import { createContext, useContext, useEffect, useLayoutEffect, useRef, type CSSProperties, type ReactNode } from 'react';

// 2. Above Pages: a context for your screens. Pages renders them when a provider is set.
export const TabScreens = createContext<ReactNode[] | null>(null);

function Pages({ data }: { data: NorthwindData }) {
  const screens = useContext(TabScreens);
  if (screens) return <>{screens.map((s, i) => <div className="pg" data-pg={i} key={i}><div className="pgc"><div className="qd-screen">{s}</div></div></div>)}</>;
  // ...the demo screens below stay as they are...
}

// 3. In LensOrb: the drop draws a copy of the screens, so keep that copy out of the tab order.
<div className="orb" data-k="orb" aria-hidden="true" inert>

shadcn bottom navigation on Tailwind v4 and React 19

  • Our Tailwind v4 check from October 25, 2026 lists this piece as "works as-is" and says "v4 actually renders as designed." Under Tailwind 3, the .ring utility painted over the piece's own lime glow during the tab-switch pulse. The check's words: "Under v4 the component CSS wins because utilities sit in a cascade layer."
  • That check ran the piece alone on its own page. Next to shadcn the same unlayered CSS also wins over shadcn's utilities, so the scope edit above matters more for a shadcn app than the Tailwind version does.
  • The piece's package.json asks for React ^18.3.1 and framer-motion ^11.11.0. Our scratch app ran it on React 19.3.1 and framer-motion 14.6.0 (published October 22, 2026), the latest on npm today, and tsc and next build both passed.

Install

  1. Copy from the zipPut react/src/components + react/src/data into src/, then npx shadcn add button && npm i framer-motion.
  2. Or ask your AIPaste PROMPT.md into Cursor, Lovable or v0.

Props

data
your tab labels and badge, now playing and covers
onTabChange
called with the tab id
onOpenPlayer
the drop opened into the player
autoplayDemo
false in a real app
TabScreens
your five screens (a context the edit above adds, not a prop of the shipped file)

How qodesign compares

All comparisons, with prices read on each library's own site.

Questions

Does shadcn have a bottom navigation component?

Not as of October 25, 2026. The request is open as GitHub issue #8847. Today you build one from shadcn Buttons (the free file above) or copy a community block.

Is Liquid Glass Tab Bar React Native?

No. It's React for the web, built with Tailwind and Framer Motion, and it runs in a phone browser or a PWA. For Expo apps, look at React Navigation's bottom tabs.

Can I have four or six tabs?

Not without changing the component. The code assumes five tabs (the keyboard wrap, the per-tab scroll and the bar geometry all use five).

Will it break my shadcn theme?

Imported as shipped, yes: we measured shadcn Buttons losing their background and padding. With the @scope edit on this page, the same app measured clean.

Can Cursor or v0 add it for me?

Yes. Each download has a PROMPT.md. Paste it into Cursor, Lovable, v0 or Claude Code and it fits the piece into your app.

Can I use it in client work?

Yes, in personal and client projects. You can't resell the code itself as a kit.