# Dock Bottom Nav Mobile — React Menu Component

> A mobile tab bar where a blob slides under the active tab, squashing as it travels, while the icon hops up into it and its label fades in below.

- Category: [Docks & Menus](https://pixelpapa.com/components/docks.md)
- Tags: navigation, mobile, tab-bar, blob, spring
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/docks/dock-bottom-nav-mobile

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/dock-bottom-nav-mobile.json"
```

The file is written to `components/pixelpapa/dock-bottom-nav-mobile.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Bell, Compass, Heart, House, User, type LucideIcon } from "lucide-react";
import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

export type NavTab = { id: string; label: string; icon: LucideIcon; badge?: number };

type BottomNavProps = {
  tabs: NavTab[];
  value: string;
  onValueChange: (id: string) => void;
  /** Colour behind the bar; the blob's ring uses it to fake a notch. Defaults to the page background. */
  surface?: string;
  className?: string;
};

export function BottomNav({ tabs, value, onValueChange, surface = "var(--background)", className }: BottomNavProps) {
  const reduce = useReducedMotion();
  const blob = useRef<HTMLSpanElement>(null);
  const index = Math.max(0, tabs.findIndex((t) => t.id === value));
  const first = useRef(true);

  // Squash and stretch while travelling: wider and flatter mid-flight, round on arrival.
  useEffect(() => {
    if (first.current) {
      first.current = false;
      return;
    }
    if (reduce || !blob.current) return;
    const a = animate(blob.current, { scaleX: [1, 1.45, 1], scaleY: [1, 0.72, 1] }, { duration: 0.42, times: [0, 0.35, 1], ease: "easeOut" });
    return () => a.stop();
  }, [index, reduce]);

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
    e.preventDefault();
    const next = (index + (e.key === "ArrowRight" ? 1 : tabs.length - 1)) % tabs.length;
    onValueChange(tabs[next].id);
    e.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus();
  }

  return (
    <div
      role="tablist"
      aria-label="Main navigation"
      onKeyDown={onKeyDown}
      className={cn("relative flex h-16 rounded-[1.4rem] border border-border bg-card shadow-lg shadow-black/10", className)}
    >
      {/* The track is one tab wide, so translating it by 100% steps exactly one tab. */}
      <motion.span
        aria-hidden
        initial={false}
        animate={{ x: `${index * 100}%` }}
        transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 32 }}
        style={{ width: `${100 / tabs.length}%` }}
        className="pointer-events-none absolute top-0 left-0 flex justify-center"
      >
        <span ref={blob} className="-mt-4 size-12 rounded-full bg-primary shadow-lg shadow-primary/40" style={{ boxShadow: `0 0 0 6px ${surface}` }} />
      </motion.span>

      {tabs.map((tab, i) => {
        const selected = i === index;
        return (
          <button
            key={tab.id}
            role="tab"
            aria-selected={selected}
            aria-label={tab.badge ? `${tab.label}, ${tab.badge} new` : tab.label}
            tabIndex={selected ? 0 : -1}
            onClick={() => onValueChange(tab.id)}
            className="relative flex flex-1 flex-col items-center justify-center rounded-2xl outline-offset-[-4px]"
          >
            <motion.span
              initial={false}
              animate={{ y: selected ? -20 : 0, scale: selected ? 1.05 : 1 }}
              transition={{ type: "spring", stiffness: 440, damping: 22 }}
              className={cn("relative transition-colors duration-200", selected ? "text-primary-foreground" : "text-muted-foreground")}
            >
              <tab.icon className="size-[22px]" strokeWidth={selected ? 2.3 : 1.9} />
              {tab.badge && !selected ? (
                <span className="absolute -top-1.5 -right-2 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-white ring-2 ring-card">
                  {tab.badge}
                </span>
              ) : null}
            </motion.span>
            <motion.span
              initial={false}
              animate={{ opacity: selected ? 1 : 0, y: selected ? 0 : 6 }}
              transition={{ duration: 0.2 }}
              className="absolute bottom-2 text-[11px] font-medium text-foreground"
            >
              {tab.label}
            </motion.span>
          </button>
        );
      })}
    </div>
  );
}

const tabs: NavTab[] = [
  { id: "home", label: "Home", icon: House },
  { id: "explore", label: "Explore", icon: Compass },
  { id: "saved", label: "Saved", icon: Heart },
  { id: "inbox", label: "Inbox", icon: Bell, badge: 3 },
  { id: "me", label: "Profile", icon: User },
];

const screens: Record<string, { title: string; sub: string; rows: string[] }> = {
  home: { title: "Good morning, Mira", sub: "3 walks planned this week", rows: ["Harbour loop · 5.2 km", "Old town stairs · 2.8 km", "Pine ridge · 9.4 km"] },
  explore: { title: "Explore", sub: "Trails near Bergen", rows: ["Fløyen summit · easy", "Ulriken ridge · hard", "Lakeside path · easy"] },
  saved: { title: "Saved", sub: "12 routes in 3 lists", rows: ["Weekend hikes · 5", "With the dog · 4", "Rainy days · 3"] },
  inbox: { title: "Inbox", sub: "3 new since yesterday", rows: ["Ines invited you to a walk", "Your October recap is ready", "Tobias liked Pine ridge"] },
  me: { title: "Mira Solberg", sub: "148 km walked this year", rows: ["Goals · 200 km", "Badges · 9 earned", "Units · kilometres"] },
};

export default function DockBottomNavMobileDemo() {
  const [tab, setTab] = useState("explore");
  const screen = screens[tab];

  return (
    <div className="h-[436px] w-[290px] rounded-[2.6rem] border border-border bg-card p-2 shadow-2xl shadow-black/15">
      <div className="relative size-full overflow-hidden rounded-[2.1rem] border border-border bg-background">
        <span className="absolute top-2.5 left-1/2 h-5 w-20 -translate-x-1/2 rounded-full bg-black ring-1 ring-white/10" />
        <AnimatePresence mode="wait" initial={false}>
          <motion.div
            key={tab}
            initial={{ opacity: 0, y: 10 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -6 }}
            transition={{ duration: 0.16 }}
            className="px-5 pt-12"
          >
            <p className="text-xl font-semibold tracking-tight text-foreground">{screen.title}</p>
            <p className="text-xs text-muted-foreground">{screen.sub}</p>
            <ul className="mt-4 grid gap-2">
              {screen.rows.map((row, i) => (
                <li key={row} className="flex items-center gap-3 rounded-2xl border border-border bg-card p-2.5 text-[13px] font-medium text-card-foreground">
                  <span className="size-9 shrink-0 rounded-xl" style={{ background: `var(--chart-${((i + tab.length) % 5) + 1})`, opacity: 0.55 }} />
                  {row}
                </li>
              ))}
            </ul>
          </motion.div>
        </AnimatePresence>
        <BottomNav tabs={tabs} value={tab} onValueChange={setTab} className="absolute inset-x-2.5 bottom-2.5" />
      </div>
    </div>
  );
}
```

## AI prompt

```text
Add the "Dock Bottom Nav Mobile" component from PixelPapa UI to this project.

What it is: A mobile tab bar where a blob slides under the active tab, squashing as it travels, while the icon hops up into it and its label fades in below.
Category: Docks & Menus. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/dock-bottom-nav-mobile.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/dock-bottom-nav-mobile.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/dock-bottom-nav-mobile" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More docks & menus

- [Dock Expandable FAB](https://pixelpapa.com/components/docks/dock-expandable-fab.md): A floating action button that turns its plus into a cross and fires a speed-dial of labelled actions upward with staggered springs.
- [Dock Floating Toolbar](https://pixelpapa.com/components/docks/dock-floating-toolbar.md) (Pro): A text-format toolbar that pops over a selection: toggles spring into their active state and the whole bar morphs into a link field and back.
- [Dock Magnify](https://pixelpapa.com/components/docks/dock-magnify.md): A dock whose icons swell by their distance to the cursor, lift their labels, bounce on launch and respond to keyboard focus the same way.
- [Menu Bar App](https://pixelpapa.com/components/docks/menu-bar-app.md) (Pro): A desktop-style File / Edit / View menubar: click once, then glide between menus as the highlight follows, with check items, shortcuts and full arrow-key navigation.
- [Menu Context Right Click](https://pixelpapa.com/components/docks/menu-context-right-click.md) (Pro): A custom context menu that opens at the pointer, flips to stay inside its area, scales from the corner nearest the click and is fully keyboard driven.
- [Menu Dropdown Nested](https://pixelpapa.com/components/docks/menu-dropdown-nested.md) (Pro): A dropdown with submenus to any depth: they open on hover intent or ArrowRight, scale out from their parent row and close level by level with ArrowLeft and Escape.
