Mobile Bottom Tab Bar with Liquid Drop Indicator

A five-tab bottom navigation bar shown inside a phone frame for a plant care app. The active tab lifts its icon into a green drop that sits half above the bar, and the white bar bulges up around it. Choosing another tab sends the drop across: a lead blob arrives first, a smaller one trails behind, and an SVG filter melts them into one stretching shape. Each icon then plays a short move of its own (the bell swings, the compass needle spins, the water drop lands) and its label fades in below. The screen above swaps with a quick directional slide. The preview steps through the tabs until someone taps one.

Code

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";

// The five tabs and what each screen shows. `tone` colours the small plant tile on each row.
const tabs = [
  { id: "today", label: "Today", title: "Good morning, Ines", sub: "3 plants are thirsty", rows: [{ name: "Boston fern", note: "Water 200 ml", tone: "#34a36f" }, { name: "Rubber plant", note: "Wipe the leaves", tone: "#1f7a5a" }, { name: "String of pearls", note: "Move to the sill", tone: "#8fbf5a" }] },
  { id: "find", label: "Find", title: "Find a plant", sub: "214 species nearby", rows: [{ name: "Low light heroes", note: "18 plants", tone: "#1f7a5a" }, { name: "Safe around cats", note: "32 plants", tone: "#e0a23a" }, { name: "Hard to kill", note: "11 plants", tone: "#34a36f" }] },
  { id: "water", label: "Water", title: "Watering log", sub: "1.4 litres this week", rows: [{ name: "Monday", note: "Fern, calathea", tone: "#3a9fd6" }, { name: "Wednesday", note: "Everything upstairs", tone: "#2f7fc0" }, { name: "Friday", note: "Rubber plant", tone: "#6cc0e8" }] },
  { id: "alerts", label: "Alerts", title: "Reminders", sub: "2 new since yesterday", rows: [{ name: "Frost tonight", note: "Bring the lemon tree in", tone: "#e0703a" }, { name: "Feed day", note: "Half strength, all pots", tone: "#e0a23a" }, { name: "Repot soon", note: "Monstera is root bound", tone: "#8fbf5a" }] },
  { id: "me", label: "You", title: "Ines Calloway", sub: "27 plants, 2 lost", rows: [{ name: "Longest streak", note: "64 days", tone: "#34a36f" }, { name: "Rooms", note: "Kitchen, study, stairs", tone: "#1f7a5a" }, { name: "Shared with", note: "Tomas", tone: "#e0a23a" }] },
];

const DEMO_MS = 1300; // time on each tab while the preview plays on its own
const LEAD = { type: "spring", stiffness: 420, damping: 30 } as const; // the bump and drop that arrive first
const TRAIL = { type: "spring", stiffness: 130, damping: 19 } as const; // the smaller blob that follows: softer means a longer stretch
const DROP = "#0b8457"; // colour of the liquid drop behind the active icon

/** Stroke icons drawn here so each one can do its own small move when its tab is chosen. */
function TabIcon({ id, on, still }: { id: string; on: boolean; still: boolean }) {
  const state = on && !still ? "on" : "off";
  const common = { fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
  if (id === "today")
    return (
      <motion.svg viewBox="0 0 24 24" className="h-6 w-6" aria-hidden="true" animate={state} variants={{ on: { scaleY: [1, 0.78, 1.14, 1], transition: { duration: 0.5, delay: 0.1 } }, off: { scaleY: 1 } }} style={{ originY: 1 }} {...common}>
        <path d="M4 11.2 12 4l8 7.2V19a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z" />
        <motion.path d="M10 20v-5h4v5" variants={{ on: { pathLength: [0, 1], transition: { duration: 0.45, delay: 0.25 } }, off: { pathLength: 1 } }} />
      </motion.svg>
    );
  if (id === "find")
    return (
      <svg viewBox="0 0 24 24" className="h-6 w-6" aria-hidden="true" {...common}>
        <circle cx="12" cy="12" r="8.5" />
        <motion.path d="m15.5 8.5-2 5-5 2 2-5z" animate={state} variants={{ on: { rotate: [0, 250, 150, 180], transition: { duration: 0.8, delay: 0.1 } }, off: { rotate: 0 } }} style={{ originX: "12px", originY: "12px", transformBox: "view-box" }} />
      </svg>
    );
  if (id === "water")
    return (
      <motion.svg viewBox="0 0 24 24" className="h-6 w-6" aria-hidden="true" animate={state} variants={{ on: { y: [-7, 0, 0], scaleY: [1.15, 0.8, 1], scaleX: [0.9, 1.15, 1], transition: { duration: 0.5, delay: 0.1 } }, off: { y: 0, scaleX: 1, scaleY: 1 } }} style={{ originY: 1 }} {...common}>
        <path d="M12 3.5c3 3.6 6 6.700 6 10.200a6 6 0 0 1-12 0c0-3.500 3-6.600 6-10.200z" />
        <motion.path d="M9.2 14.300a2.900 2.900 0 0 0 2.300 2.700" variants={{ on: { pathLength: [0, 1], transition: { duration: 0.4, delay: 0.4 } }, off: { pathLength: 1 } }} />
      </motion.svg>
    );
  if (id === "alerts")
    return (
      <motion.svg viewBox="0 0 24 24" className="h-6 w-6" aria-hidden="true" animate={state} variants={{ on: { rotate: [0, -22, 18, -12, 7, 0], transition: { duration: 0.8, delay: 0.1 } }, off: { rotate: 0 } }} style={{ originY: 0.1 }} {...common}>
        <path d="M6 16.500V11a6 6 0 0 1 12 0v5.500l1.500 1.500h-15z" />
        <motion.path d="M10 20.500a2.200 2.200 0 0 0 4 0" variants={{ on: { x: [0, 3, -3, 2, 0], transition: { duration: 0.8, delay: 0.15 } }, off: { x: 0 } }} />
      </motion.svg>
    );
  return (
    <svg viewBox="0 0 24 24" className="h-6 w-6" aria-hidden="true" {...common}>
      <motion.circle cx="12" cy="8.500" r="3.800" animate={state} variants={{ on: { scale: [0.2, 1.25, 1], transition: { duration: 0.5, delay: 0.1 } }, off: { scale: 1 } }} style={{ originX: "12px", originY: "8.500px", transformBox: "view-box" }} />
      <motion.path d="M4.500 20a7.500 7.500 0 0 1 15 0" animate={state} variants={{ on: { pathLength: [0, 1], transition: { duration: 0.5, delay: 0.2 } }, off: { pathLength: 1 } }} />
    </svg>
  );
}

export default function MobileBottomTabBarWithLiquidDropIndicator() {
  const reduce = useReducedMotion();
  const still = !!reduce;
  const root = useRef<HTMLElement>(null);
  const inView = useInView(root, { amount: 0.3 });
  const [active, setActive] = useState(0);
  const [prev, setPrev] = useState(0);
  const [demo, setDemo] = useState(true);

  useEffect(() => {
    if (!demo || still || !inView) return;
    const id = window.setInterval(() => {
      setActive((a) => {
        setPrev(a);
        return (a + 1) % tabs.length;
      });
    }, DEMO_MS);
    return () => window.clearInterval(id);
  }, [demo, still, inView]);

  const choose = (i: number) => {
    setDemo(false);
    setPrev(active);
    setActive(i);
  };

  const tab = tabs[active];
  const dir = active >= prev ? 1 : -1;
  const slot = { x: `${active * 100}%` };
  return (
    <section ref={root} className="overflow-hidden bg-[#e4f1e8] px-6 py-14 text-[#0f2a1e] sm:py-20">
      {/* Goo filter: blur the shapes together, then harden the alpha so the blur becomes a liquid edge. */}
      <svg aria-hidden="true" className="absolute h-0 w-0">
        <defs>
          <filter id="liquid-tab-bar-goo" x="-20%" y="-50%" width="140%" height="200%">
            <feGaussianBlur in="SourceGraphic" stdDeviation="7" result="blur" />
            <feColorMatrix in="blur" type="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9" />
          </filter>
        </defs>
      </svg>

      <div className="mx-auto grid max-w-5xl items-center gap-12 lg:grid-cols-[1fr_auto] lg:gap-20">
        <div className="max-w-md">
          <p className="font-mono text-xs uppercase tracking-[0.25em] text-[#0b8457]">Fernery / plant care</p>
          <h2 className="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">Keep every plant in the house alive.</h2>
          <p className="mt-5 text-base text-[#35584a] sm:text-lg">Watering days, light checks and frost warnings for each pot, in an app you can run with one thumb.</p>
          <div className="mt-8 flex flex-wrap gap-3">
            <a href="#" className="inline-flex h-12 items-center rounded-full bg-[#0f2a1e] px-6 text-sm font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0b8457] focus-visible:ring-offset-2 focus-visible:ring-offset-[#e4f1e8]">Get the app</a>
            <a href="#" className="inline-flex h-12 items-center rounded-full border border-[#0f2a1e]/25 px-6 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0b8457] focus-visible:ring-offset-2 focus-visible:ring-offset-[#e4f1e8]">See the plant library</a>
          </div>
        </div>

        <div className="relative mx-auto h-[660px] w-[330px] max-w-full rounded-[52px] bg-[#0f2a1e] p-2.5 shadow-2xl shadow-emerald-900/30">
          <div className="relative h-full overflow-hidden rounded-[42px] bg-[#f4f8f2]">
            <div aria-hidden="true" className="absolute left-1/2 top-2.5 z-10 h-6 w-24 -translate-x-1/2 rounded-full bg-[#0f2a1e]" />

            {/* Screen for the active tab. */}
            <div className="px-5 pt-14">
              <AnimatePresence mode="wait" initial={false} custom={dir}>
                <motion.div key={tab.id} custom={dir} variants={{ enter: (d: number) => (still ? { opacity: 0 } : { opacity: 0, x: 24 * d }), centre: { opacity: 1, x: 0 }, exit: (d: number) => (still ? { opacity: 0 } : { opacity: 0, x: -24 * d }) }} initial="enter" animate="centre" exit="exit" transition={{ duration: still ? 0 : 0.18, ease: "easeOut" }}>
                  <p className="text-sm text-[#35584a]">{tab.sub}</p>
                  <h3 className="mt-1 text-2xl font-semibold tracking-tight">{tab.title}</h3>
                  <div className="relative mt-5 h-44 overflow-hidden rounded-3xl bg-gradient-to-br from-[#bfe3c8] to-[#8fcfa6]">
                    <span aria-hidden="true" className="absolute bottom-0 left-1/2 h-14 w-20 -translate-x-1/2 rounded-t-xl bg-[#c9673f]" />
                    <span aria-hidden="true" className="absolute bottom-12 left-1/2 h-16 w-10 origin-bottom -translate-x-[85%] -rotate-[28deg] rounded-bl-full rounded-tr-full bg-[#0b8457]" />
                    <span aria-hidden="true" className="absolute bottom-12 left-1/2 h-20 w-11 origin-bottom -translate-x-[15%] rotate-[20deg] rounded-br-full rounded-tl-full bg-[#12683f]" />
                    <span aria-hidden="true" className="absolute bottom-12 left-1/2 h-24 w-9 origin-bottom -translate-x-1/2 -rotate-3 rounded-bl-full rounded-tr-full bg-[#1f9a68]" />
                    <span className="absolute left-4 top-4 rounded-full bg-white/80 px-3 py-1 text-xs font-semibold text-[#0f2a1e]">{tab.label}</span>
                  </div>
                  <ul className="mt-4 space-y-2">
                    {tab.rows.map((r) => (
                      <li key={r.name} className="flex items-center gap-3 rounded-2xl bg-white p-3">
                        <span aria-hidden="true" className="h-9 w-9 shrink-0 rounded-xl" style={{ backgroundColor: r.tone }} />
                        <span className="min-w-0">
                          <span className="block truncate text-sm font-semibold">{r.name}</span>
                          <span className="block truncate text-xs text-[#35584a]">{r.note}</span>
                        </span>
                      </li>
                    ))}
                  </ul>
                </motion.div>
              </AnimatePresence>
            </div>

            <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-[#f4f8f2] via-[#f4f8f2]/90 to-transparent" />

            {/* The bar. Layer 1 (white) and layer 2 (green) are filtered shapes; layer 3 is the real buttons. */}
            <div className="absolute inset-x-3 bottom-3 h-[68px]">
              <div aria-hidden="true" className="absolute inset-x-0 -top-10 bottom-0" style={{ filter: "url(#liquid-tab-bar-goo) drop-shadow(0 10px 18px rgba(15,42,30,0.16))" }}>
                <div className="absolute inset-x-0 bottom-0 top-10 rounded-[26px] bg-white" />
                <motion.div className="absolute bottom-0 left-0 top-10 w-1/5" initial={false} animate={slot} transition={still ? { duration: 0 } : LEAD}>
                  <span className="absolute left-1/2 top-[-20px] h-[60px] w-[60px] -translate-x-1/2 rounded-full bg-white" />
                </motion.div>
                <motion.div className="absolute bottom-0 left-0 top-10 w-1/5" initial={false} animate={slot} transition={still ? { duration: 0 } : TRAIL}>
                  <span className="absolute left-1/2 top-[-10px] h-[44px] w-[44px] -translate-x-1/2 rounded-full bg-white" />
                </motion.div>
              </div>
              <div aria-hidden="true" className="absolute inset-x-0 -top-10 bottom-0" style={{ filter: "url(#liquid-tab-bar-goo)" }}>
                <motion.div className="absolute bottom-0 left-0 top-10 w-1/5" initial={false} animate={slot} transition={still ? { duration: 0 } : LEAD}>
                  <span className="absolute left-1/2 top-[-13px] h-[46px] w-[46px] -translate-x-1/2 rounded-full" style={{ backgroundColor: DROP }} />
                </motion.div>
                <motion.div className="absolute bottom-0 left-0 top-10 w-1/5" initial={false} animate={slot} transition={still ? { duration: 0 } : TRAIL}>
                  <span className="absolute left-1/2 top-[-3px] h-[26px] w-[26px] -translate-x-1/2 rounded-full" style={{ backgroundColor: DROP }} />
                </motion.div>
              </div>

              <nav aria-label="App sections" className="relative h-full">
                <ul className="flex h-full">
                  {tabs.map((t, i) => {
                    const on = active === i;
                    return (
                      <li key={t.id} className="flex-1">
                        <button type="button" aria-current={on ? "page" : undefined} aria-label={t.label} onClick={() => choose(i)} className="relative flex h-full w-full items-center justify-center rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#0b8457]">
                          <motion.span className="block" initial={false} animate={{ y: on ? -24 : 0, color: on ? "#ffffff" : "#5f7d70" }} transition={still ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 24, delay: on ? 0.06 : 0 }}>
                            <TabIcon id={t.id} on={on} still={still} />
                          </motion.span>
                          <motion.span aria-hidden="true" className="absolute bottom-2 text-[11px] font-semibold text-[#0b6b47]" initial={false} animate={{ opacity: on ? 1 : 0, y: on ? 0 : 6 }} transition={still ? { duration: 0 } : { duration: 0.25, delay: on ? 0.12 : 0 }}>
                            {t.label}
                          </motion.span>
                        </button>
                      </li>
                    );
                  })}
                </ul>
              </nav>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/mobile-bottom-tab-bar-with-liquid-drop-indicator.json

When to use it

How to customize it

Accessibility

Questions

How does the liquid indicator work?

Two circles are animated to the same tab with different springs, so one arrives before the other. Both sit inside an element with an SVG filter that blurs them and then pushes the alpha contrast up hard. Where the blurred circles overlap, the filter fills the gap, which reads as liquid stretching and snapping back.

Why does the bar bulge around the drop?

The white bar is in a filtered layer too, with its own pair of white circles sitting half above its top edge. The filter joins them to the bar with a smooth curve, so the bar looks as if it swells where the drop is.

How are the icon animations triggered?

Each icon is an inline SVG with framer-motion variants named `on` and `off`. When a tab becomes active its icon switches to `on`, which runs a short keyframe list such as a rotation for the bell or a redraw of a path using `pathLength`.

Does the SVG filter hurt performance?

The filtered layers hold only a rectangle and four circles and cover a 68px strip, so the cost is small. Keep text and icons out of those layers: filtering them would blur their edges and cost more.

How do I connect it to real routes?

Replace `choose(i)` with your router's navigate call and derive `active` from the current path instead of state. Remove the `demo` interval so tabs only change when the route does.

Related: Gooey Tabs with Liquid Merging Active Tab, Habit Tracker Phone Screen with Tab Bar, Magnifying Dock with Tooltips and Idle Wave.

More tabs and navigation or all free UI components. Start for free to have a full site built around it.

More in Navigation

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap