A floating icon bar that magnifies and lifts around the pointer, with tooltips and a gliding active indicator.
Customize
Usage
import { House, Search, Mail } from "lucide-react";
import { Dock } from "@/components/motionkit/dock";
<Dock
active="Home"
items={[
{ label: "Home", icon: <House />, href: "/" },
{ label: "Search", icon: <Search />, onClick: openSearch },
{ label: "Mail", icon: <Mail />, href: "/contact" },
]}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Magnify and lift
Pointer moves along the dock
Indicator glide
Active item changes
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/dock.tsx.
"use client";
import { useId, useRef, useState, type ReactNode } from "react";
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
export type DockItem = {
label: string;
icon: ReactNode;
/** Renders the item as a link. */
href?: string;
onClick?: () => void;
};
type DockProps = {
items: DockItem[];
/** Icon size in pixels when idle. */
size?: number;
/** Size multiplier for the icon under the pointer. */
magnification?: number;
/** How far (px) the magnification reaches. */
distance?: number;
/** How far (px) the icon under the pointer floats up. 0 keeps icons on the baseline. */
lift?: number;
/** Label of the current item. It gets an indicator dot that glides when this changes. */
active?: string;
className?: string;
};
export function Dock({
items,
size = 44,
magnification = 1.6,
distance = 120,
lift = 8,
active,
className = "",
}: DockProps) {
const mouseX = useMotionValue(Infinity);
const indicatorId = useId();
return (
<nav
onPointerMove={(event) => mouseX.set(event.clientX)}
onPointerLeave={() => mouseX.set(Infinity)}
className={`flex items-end gap-2 rounded-2xl border border-white/10 bg-[#141B1B]/70 px-2.5 pb-3 pt-2.5 shadow-xl shadow-black/40 backdrop-blur ${className}`}
>
{items.map((item) => (
<DockIcon
key={item.label}
item={item}
mouseX={mouseX}
size={size}
magnification={magnification}
distance={distance}
lift={lift}
active={item.label === active}
indicatorId={indicatorId}
/>
))}
</nav>
);
}
function DockIcon({
item,
mouseX,
size,
magnification,
distance,
lift,
active,
indicatorId,
}: {
item: DockItem;
mouseX: MotionValue<number>;
size: number;
magnification: number;
distance: number;
lift: number;
active: boolean;
indicatorId: string;
}) {
const ref = useRef<HTMLElement>(null);
const [showLabel, setShowLabel] = useState(false);
const reduceMotion = useReducedMotion();
const offset = useTransform(mouseX, (x) => {
const rect = ref.current?.getBoundingClientRect();
return rect ? x - rect.left - rect.width / 2 : Infinity;
});
const target = useTransform(offset, [-distance, 0, distance], [size, size * magnification, size], {
clamp: true,
});
const width = useSpring(target, { stiffness: 260, damping: 18, mass: 0.2 });
// icons float up in proportion to how much they have grown
const y = useTransform(width, (w) => (magnification > 1 ? (-(w - size) / (size * (magnification - 1))) * lift : 0));
const Element = item.href ? motion.a : motion.button;
return (
<div className="relative flex flex-col items-center">
<Element
ref={ref as never}
href={item.href}
aria-label={item.label}
aria-current={active ? "page" : undefined}
onClick={item.onClick}
onPointerEnter={() => setShowLabel(true)}
onPointerLeave={() => setShowLabel(false)}
onFocus={() => setShowLabel(true)}
onBlur={() => setShowLabel(false)}
style={reduceMotion ? { width: size } : { width, y }}
className="relative flex aspect-square items-center justify-center rounded-xl border border-white/10 bg-[#1D2727] text-[#DCE3E2] transition-colors hover:bg-[#3A4A49] focus-visible:outline-2 focus-visible:outline-[#5FAEB4]"
>
<AnimatePresence>
{showLabel && (
<motion.span
initial={{ opacity: 0, y: 4, x: "-50%" }}
animate={{ opacity: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, y: 2, x: "-50%" }}
className="pointer-events-none absolute -top-8 left-1/2 whitespace-nowrap rounded-md border border-white/10 bg-[#141B1B] px-2 py-0.5 text-xs text-[#F6F1E8]"
>
{item.label}
</motion.span>
)}
</AnimatePresence>
<span className="flex size-1/2 items-center justify-center [&>svg]:size-full">{item.icon}</span>
</Element>
{active && (
<motion.span
aria-hidden
layoutId={indicatorId}
className="absolute -bottom-2 size-1 rounded-full bg-[#5FAEB4] shadow-[0_0_6px_#5FAEB4]"
transition={reduceMotion ? { duration: 0 } : { type: "spring", bounce: 0.3, duration: 0.4 }}
/>
)}
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { label, icon, href?, onClick? }[] | — | The dock items. With href they render as links. |
| size | number | 44 | Icon size in pixels when idle. |
| magnification | number | 1.6 | Size multiplier for the icon under the pointer. |
| distance | number | 120 | How far (px) the magnification reaches. |
| lift | number | 8 | How far (px) the icon under the pointer floats up. 0 keeps icons on the baseline. |
| active | string | — | Label of the current item. It gets an indicator dot that glides when this changes. |
Overview
A sliding pill follows your selection.
Animated Tabs
Accessible tabs with a sliding indicator and soft content transitions.
Command Menu
A ⌘K palette with live filtering, grouped results and a highlight that glides between them.
Command Centre
A ⌘K command palette with recent actions, fuzzy search across pages and commands, and scope tabs.