AnimatedMotion
Dock
A launcher whose icons swell on springs as the mouse passes, with labels that float up on hover or focus.
Opened Home
Installation
npx shadcn@latest add https://ui-collections.vercel.app/r/tide/dock.jsonAlso installs the theme, cn() and motion-scope. Packages: clsx, tailwind-merge, motion.
Usage
dock-demo.tsx
"use client";
import { useState } from "react";
import { Dock } from "@/registry/kit/ui/dock";
const icon = (d: string) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
const apps = [
{ label: "Home", icon: icon("M4 11l8-7 8 7v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z") },
{ label: "Search", icon: icon("M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4-4") },
{ label: "Projects", icon: icon("M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") },
{ label: "Inbox", icon: icon("M4 6h16v12H4zM4 7l8 6 8-6") },
{ label: "Analytics", icon: icon("M5 19V11M12 19V5M19 19v-6") },
{ label: "Settings", icon: icon("M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19 12h2M3 12h2M12 3v2M12 19v2M17 7l1.5-1.5M5.5 18.5L7 17M17 17l1.5 1.5M5.5 5.5L7 7") },
];
export default function DockDemo() {
// In an app, pass `href` instead and mark the current route `active`.
const [current, setCurrent] = useState("Home");
return (
<div className="flex min-h-48 flex-col items-center justify-end gap-6">
<p className="text-sm text-muted" aria-live="polite">
Opened {current}
</p>
<Dock items={apps.map((app) => ({ ...app, active: app.label === current, onClick: () => setCurrent(app.label) }))} />
</div>
);
}API
items{ label; icon; href?; onClick?; active? }[]Icons. Links when `href` is set, buttons otherwise.
size= 44
numberResting icon size in px.
magnification= 58
numberIcon size directly under the pointer. The bar is sized to hold it.
distance= 130
numberReach of the swell either side of the pointer, in px.
Source
components/ui/dock.tsx
"use client";
import { m, useMotionValue, useSpring, useTransform, type MotionValue } from "motion/react";
import { useRef } from "react";
import { MotionScope } from "@/registry/kit/lib/motion";
import { cn } from "@/registry/kit/lib/utils";
export interface DockItem {
label: string;
icon: React.ReactNode;
href?: string;
onClick?: () => void;
/** Shows a small dot under the icon. */
active?: boolean;
}
export interface DockProps {
items: DockItem[];
/** Resting icon size in px. */
size?: number;
/** Size of the icon directly under the pointer, in px. */
magnification?: number;
/** How far, in px, the swell reaches either side of the pointer. */
distance?: number;
className?: string;
}
const spring = { mass: 0.15, stiffness: 170, damping: 17 };
function DockIcon({
item,
pointerX,
size,
magnification,
distance,
}: {
item: DockItem;
pointerX: MotionValue<number>;
size: number;
magnification: number;
distance: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const offset = useTransform(pointerX, (x) => {
const rect = ref.current?.getBoundingClientRect();
if (!rect || !Number.isFinite(x)) return distance;
return x - rect.left - rect.width / 2;
});
const target = useTransform(offset, [-distance, 0, distance], [size, magnification, size]);
const extent = useSpring(target, spring);
const inner = (
<>
<span aria-hidden className="grid size-[46%] place-items-center [&>svg]:size-full">
{item.icon}
</span>
<span className="sr-only">{item.label}</span>
</>
);
const control =
"surface grid size-full place-items-center rounded-field text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/70";
return (
<li className="group/dock relative flex flex-col items-center">
<span
aria-hidden
className="surface-raised pointer-events-none absolute -top-10 left-1/2 -translate-x-1/2 translate-y-1 rounded-control px-2.5 py-1 text-xs whitespace-nowrap opacity-0 transition-[opacity,translate] duration-300 ease-theme group-focus-within/dock:translate-y-0 group-focus-within/dock:opacity-100 group-hover/dock:translate-y-0 group-hover/dock:opacity-100"
>
{item.label}
</span>
<m.div ref={ref} style={{ width: extent, height: extent }}>
{item.href ? (
<a href={item.href} className={control} aria-current={item.active ? "page" : undefined}>
{inner}
</a>
) : (
<button
type="button"
onClick={item.onClick}
aria-current={item.active ? "page" : undefined}
className={cn(control, "cursor-pointer")}
>
{inner}
</button>
)}
</m.div>
<span
aria-hidden
className={cn("absolute -bottom-2 size-1 rounded-full bg-fg/80 transition-opacity", item.active ? "opacity-100" : "opacity-0")}
/>
</li>
);
}
/**
* A surface dock whose icons swell as the mouse passes over them, like a
* desktop launcher. Sizes ride on springs driven by one pointer motion value,
* so moving the mouse never re-renders React. Touch and keyboard get the
* resting size. The bar is tall enough for the largest icon, which swells
* from its centre and so stays inside it; the dock also reserves room above
* itself for the floating label, so it never covers what sits on top.
*/
export function Dock({ items, size = 44, magnification = 58, distance = 130, className }: DockProps) {
const pointerX = useMotionValue(Number.POSITIVE_INFINITY);
return (
<MotionScope>
<nav
aria-label="Dock"
onPointerMove={(e) => e.pointerType === "mouse" && pointerX.set(e.clientX)}
onPointerLeave={() => pointerX.set(Number.POSITIVE_INFINITY)}
className={cn("surface-raised inline-flex items-center rounded-card px-3", className)}
// Room for the largest icon inside the bar, and for the label above it.
style={{ height: Math.max(size, magnification) + 20, marginTop: 40 }}
>
<ul className="flex items-center gap-2.5">
{items.map((item) => (
<DockIcon
key={item.label}
item={item}
pointerX={pointerX}
size={size}
magnification={magnification}
distance={distance}
/>
))}
</ul>
</nav>
</MotionScope>
);
}