Interactive
FloatingDock
A row of icons that swell as the pointer passes, in a wave that follows it. The dock keeps its place in the page while it grows. Keyboard focus swells an icon too and shows its name.
Download
- React
- Tailwind CSS v4
- motion
components/ui/floating-dock.tsx
"use client";
import { useRef, useState, type FocusEvent, type MouseEvent, type PointerEvent, type ReactNode } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue } from "motion/react";
export type FloatingDockItem = {
label: string;
href: string;
/** Icon (an svg works best). */
icon: ReactNode;
};
type FloatingDockProps = {
items: FloatingDockItem[];
/** href of the active link at start. Leave out for no marker. */
active?: string;
/** Called on click. Call e.preventDefault() to stop the browser following the link. */
onSelect?: (item: FloatingDockItem, e: MouseEvent<HTMLAnchorElement>) => void;
/** Icon size at rest in pixels. Icons at rest set the dock's width, so make sure they fit. */
size?: number;
/** How many times bigger the icon under the pointer gets. */
magnify?: number;
/** Pixels from the pointer where icons start to grow. */
range?: number;
/** Colour of the active marker. */
accent?: string;
/** Name read out by screen readers. */
label?: string;
/** Look of the bar. To float it, wrap it in a fixed box. */
className?: string;
};
const GAP = 8;
const PAD = 10;
const GROW = { stiffness: 420, damping: 30, mass: 0.5 };
/* FloatingDock: a row of icons that swell as the pointer passes, like a wave following it. Hover or keyboard focus shows the name. */
export function FloatingDock({
items,
active,
onSelect,
size = 44,
magnify = 1.7,
range = 130,
accent = "#a78bfa",
label = "Dock",
className = "border border-white/10 bg-[#15131f]/85 text-white shadow-xl backdrop-blur-md",
}: FloatingDockProps) {
const reduce = useReducedMotion();
const [current, setCurrent] = useState(active);
const box = useRef<HTMLElement>(null);
// Where the pointer is along the dock at rest. Infinity = away, so nothing grows.
const pointer = useMotionValue(Infinity);
const centre = (i: number) => PAD + i * (size + GAP) + size / 2;
const restWidth = centre(items.length - 1) + size / 2 + PAD;
const restHeight = size + PAD * 2;
// Measured on the outer box, which never changes size, so growing icons cannot feed back into the reading.
const follow = (e: PointerEvent) => {
if (e.pointerType === "touch" || !box.current) return;
const x = e.clientX - box.current.getBoundingClientRect().left;
pointer.set(Math.max(centre(0), Math.min(centre(items.length - 1), x)));
};
const onFocus = (e: FocusEvent<HTMLAnchorElement>, i: number) => {
if (e.currentTarget.matches(":focus-visible")) pointer.set(centre(i));
};
return (
<nav ref={box} aria-label={label} className="relative mx-auto shrink-0" style={{ width: restWidth, height: restHeight }}>
<ul
onPointerMove={follow}
onPointerLeave={() => pointer.set(Infinity)}
className={`absolute bottom-0 left-1/2 flex w-max -translate-x-1/2 items-end rounded-2xl ${className}`}
style={{ height: restHeight, gap: GAP, padding: PAD }}
>
{items.map((item, i) => (
<DockItem
key={item.href}
item={item}
on={item.href === current}
rest={centre(i)}
pointer={pointer}
size={size}
magnify={reduce ? 1 : magnify}
range={range}
accent={accent}
onFocus={(e) => onFocus(e, i)}
onBlur={() => pointer.set(Infinity)}
onClick={(e) => {
setCurrent(item.href);
onSelect?.(item, e);
}}
/>
))}
</ul>
</nav>
);
}
type DockItemProps = {
item: FloatingDockItem;
on: boolean;
rest: number;
pointer: MotionValue<number>;
size: number;
magnify: number;
range: number;
accent: string;
onFocus: (e: FocusEvent<HTMLAnchorElement>) => void;
onBlur: () => void;
onClick: (e: MouseEvent<HTMLAnchorElement>) => void;
};
function DockItem({ item, on, rest, pointer, size, magnify, range, accent, onFocus, onBlur, onClick }: DockItemProps) {
// Cosine falloff: full size under the pointer, easing to rest at the edge of the range.
const target = useTransform(pointer, (x) => {
const away = Math.abs(x - rest);
return away >= range ? size : size * (1 + ((magnify - 1) * (1 + Math.cos((Math.PI * away) / range))) / 2);
});
const width = useSpring(target, GROW);
return (
<motion.li className="relative shrink-0" style={{ width, height: width }}>
<a
href={item.href}
aria-label={item.label}
aria-current={on ? "page" : undefined}
onFocus={onFocus}
onBlur={onBlur}
onClick={onClick}
className="group grid size-full place-items-center rounded-[30%] bg-white/10 outline-none transition-colors hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white/70 [&_svg]:size-1/2"
>
{item.icon}
<span
aria-hidden
className="pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 rounded-lg bg-black/85 px-2 py-1 text-xs font-medium whitespace-nowrap text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
>
{item.label}
</span>
</a>
{on && <span aria-hidden className="absolute -bottom-[7px] left-1/2 size-1 -translate-x-1/2 rounded-full" style={{ background: accent }} />}
</motion.li>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/floating-dock.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/floating-dock-demo.tsx
"use client"; import { Bell, Heart, House, Search, User } from "lucide-react"; import { FloatingDock } from "@/components/ui/floating-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 FloatingDockExample() { return ( <div className="flex h-full items-end justify-center overflow-hidden bg-[radial-gradient(circle_at_50%_0%,#2a2150,#0b0a14_70%)] p-5 pb-8"> <FloatingDock items={ITEMS} active="#home" onSelect={(_, e) => e.preventDefault()} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | FloatingDockItem[] | โ | Links: label, href and icon. An svg works best. |
| active | string | โ | href of the active link at start. Leave out for no marker. |
| onSelect | (item, e) => void | โ | Click handler. Call e.preventDefault() to stay on the page. |
| size | number | 44 | Icon size at rest in pixels. Icons at rest set the dock's width. |
| magnify | number | 1.7 | How many times bigger the icon under the pointer gets. |
| range | number | 130 | Pixels from the pointer where icons start to grow. |
| accent | string | "#a78bfa" | Colour of the active marker. |
| label | string | "Dock" | Read by screen readers. |
| className | string | dark glass | Look of the bar. To float it, wrap it in a fixed box. |