Navigation
BubbleDock
A tab bar where the active icon rises in a bubble that melts out of the bar, and the bubble rolls to whichever tab you pick.
Download
- React
- Tailwind CSS v4
- motion
components/ui/bubble-dock.tsx
"use client";
import { useId, useState, type MouseEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
export type BubbleDockItem = {
label: string;
href: string;
/** Icon (an svg works best). */
icon: ReactNode;
};
type BubbleDockProps = {
items: BubbleDockItem[];
/** href of the active link at start. Defaults to the first item. */
active?: string;
/** Called on click. Call e.preventDefault() to stop the browser following the link. */
onSelect?: (item: BubbleDockItem, e: MouseEvent<HTMLAnchorElement>) => void;
/** Bar height in pixels. The bubble is the same size. */
size?: number;
/** Bar and bubble colour. */
color?: string;
/** Colour of the icon inside the bubble. */
activeColor?: string;
/** Name read out by screen readers. */
label?: string;
/** Icon / label colour on the bar. Width comes from the parent (full width). */
className?: string;
};
/* BubbleDock: a tab bar where the active icon rises in a bubble that melts out of the bar, and the bubble rolls to whichever tab you pick. */
export function BubbleDock({
items,
active,
onSelect,
size = 60,
color = "#241e44",
activeColor = "#a78bfa",
label = "Tabs",
className = "text-white/60",
}: BubbleDockProps) {
const reduce = useReducedMotion();
const filterId = `bubble-${useId().replace(/:/g, "")}`;
const [current, setCurrent] = useState(active ?? items[0]?.href);
const index = Math.max(0, items.findIndex((i) => i.href === current));
const n = items.length || 1;
const rise = size * 0.5;
const roll = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 260, damping: 24 };
return (
<nav aria-label={label} className={`relative w-full ${className}`} style={{ height: size + rise, paddingTop: rise }}>
<svg aria-hidden className="absolute h-0 w-0">
<filter id={filterId}>
<feGaussianBlur in="SourceGraphic" stdDeviation={size / 6} result="blur" />
<feColorMatrix in="blur" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 24 -10" result="goo" />
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
</svg>
{/* Bar + bubble share one goo filter so they melt together. */}
<div aria-hidden className="absolute inset-x-0 bottom-0" style={{ top: 0, filter: `url(#${filterId})` }}>
<div className="absolute inset-x-0 bottom-0 rounded-[22px]" style={{ height: size, background: color }} />
<motion.div
className="absolute rounded-full"
initial={false}
animate={{ left: `${((index + 0.5) / n) * 100}%` }}
transition={roll}
style={{ width: size, height: size, top: rise * 0.15, marginLeft: -size / 2, background: color }}
/>
</div>
{/* Coloured core inside the bubble, kept outside the filter so it stays crisp. */}
<motion.div
aria-hidden
className="absolute rounded-full"
initial={false}
animate={{ left: `${((index + 0.5) / n) * 100}%` }}
transition={roll}
style={{
width: size * 0.74,
height: size * 0.74,
top: rise * 0.15 + size * 0.13,
marginLeft: -size * 0.37,
background: activeColor,
boxShadow: `0 6px 18px -4px ${activeColor}`,
}}
/>
<ul className="relative grid h-full" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>
{items.map((item, i) => {
const on = i === index;
return (
<li key={item.href} className="relative">
<a
href={item.href}
aria-current={on ? "page" : undefined}
onClick={(e) => {
setCurrent(item.href);
onSelect?.(item, e);
}}
className="relative flex h-full flex-col items-center justify-center rounded-[18px] outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-inset"
>
<motion.span
aria-hidden
className={`grid size-6 place-items-center transition-colors duration-300 [&>svg]:size-full ${on ? "text-[#0b0a14]" : ""}`}
initial={false}
// Lift the icon from the bar centre to the bubble centre.
animate={{ y: on ? -rise * 0.85 : 0 }}
transition={roll}
>
{item.icon}
</motion.span>
<motion.span
className="absolute bottom-2 text-[11px] font-medium text-white"
initial={false}
animate={{ opacity: on ? 1 : 0, y: on ? 0 : 6 }}
transition={{ duration: reduce ? 0 : 0.25, delay: on && !reduce ? 0.1 : 0 }}
>
{item.label}
</motion.span>
</a>
</li>
);
})}
</ul>
</nav>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/bubble-dock.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/bubble-dock-demo.tsx
"use client"; import { Bell, Heart, House, Search, User } from "lucide-react"; import { BubbleDock } from "@/components/ui/bubble-dock"; const ITEMS = [ { label: "Home", href: "#home", icon: <House /> }, { label: "Search", href: "#search", icon: <Search /> }, { label: "Saved", href: "#saved", icon: <Heart /> }, { label: "Alerts", href: "#alerts", icon: <Bell /> }, { label: "Profile", href: "#profile", icon: <User /> }, ]; export default function BubbleDockExample() { return ( <div className="flex h-full items-end justify-center bg-[radial-gradient(circle_at_50%_0%,#2a2150,#0b0a14_70%)] p-5"> <div className="w-full max-w-sm"> <BubbleDock items={ITEMS} onSelect={(_, e) => e.preventDefault()} /> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { label, href, icon }[] | — | The tabs. icon = an svg. |
| active | string | first href | href of the active tab at start. |
| onSelect | (item, e) => void | — | Click handler. Call e.preventDefault() to stay on the page. |
| size | number | 60 | Bar height in pixels. The bubble is the same size. |
| color | string | "#241e44" | Bar and bubble colour. |
| activeColor | string | "#a78bfa" | Colour of the core inside the bubble. |
| label | string | "Tabs" | Name read out by screen readers. |
| className | string | "text-white/60" | Icon colour. Width comes from the parent. |