A desktop-style dock on a dusk wallpaper. Icons grow smoothly as the pointer comes near and their neighbours rise with them, a tooltip names the one in focus, and a click makes the icon bounce. When nobody is touching it, an invisible pointer sweeps back and forth so the dock ripples by itself. It measures its own width and switches to smaller icons on a phone, where dragging a finger along it does the same thing.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent } from "react";
import { AnimatePresence, animate, motion, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useTransform } from "framer-motion";
import type { MotionValue } from "framer-motion";
import { CalendarDays, Camera, Compass, FolderOpen, Mail, MessageCircle, Music } from "lucide-react";
const apps = [
{ name: "Files", Icon: FolderOpen, from: "#38bdf8", to: "#1d4ed8", open: true },
{ name: "Browser", Icon: Compass, from: "#f8fafc", to: "#cbd5e1", open: true, ink: "#0f172a" },
{ name: "Mail", Icon: Mail, from: "#60a5fa", to: "#4338ca", open: false },
{ name: "Calendar", Icon: CalendarDays, from: "#fb7185", to: "#be123c", open: true },
{ name: "Music", Icon: Music, from: "#f472b6", to: "#9d174d", open: false },
{ name: "Camera", Icon: Camera, from: "#3f3f46", to: "#09090b", open: false },
{ name: "Messages", Icon: MessageCircle, from: "#4ade80", to: "#15803d", open: true },
];
/** base: resting icon size. peak: size under the pointer. reach: how far the magnification spreads, all in pixels. */
const DESKTOP = { base: 56, peak: 104, reach: 170, gap: 10 };
const PHONE = { base: 34, peak: 56, reach: 96, gap: 5 };
type Sizes = typeof DESKTOP;
function DockIcon({ app, index, pointer, sizes, still, tip, launching, onLaunch, onFocus, onBlur }: {
app: (typeof apps)[number];
index: number;
pointer: MotionValue<number>;
sizes: Sizes;
still: boolean;
tip: boolean;
launching: boolean;
onLaunch: () => void;
onFocus: () => void;
onBlur: () => void;
}) {
const centre = (index - (apps.length - 1) / 2) * (sizes.base + sizes.gap);
const target = useTransform(pointer, (x) => {
const u = Math.min(Math.abs(x - centre) / sizes.reach, 1);
const lift = Math.cos((u * Math.PI) / 2);
return sizes.base + (sizes.peak - sizes.base) * lift * lift;
});
const size = useSpring(target, { stiffness: 320, damping: 24, mass: 0.35 });
const dim = still ? sizes.base : size;
return (
<li className="relative flex flex-none items-end" style={{ height: sizes.base }}>
<motion.button
type="button"
aria-label={`${app.name}${app.open ? ", open" : ""}`}
onClick={onLaunch}
onFocus={onFocus}
onBlur={onBlur}
style={{ width: dim, height: dim }}
className="relative block flex-none rounded-[26%] focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
>
<motion.span
className="absolute inset-0 flex items-center justify-center rounded-[26%] shadow-lg shadow-black/40 ring-1 ring-inset ring-white/25"
style={{ backgroundImage: `linear-gradient(160deg, ${app.from}, ${app.to})`, color: app.ink ?? "#ffffff" }}
animate={launching && !still ? { y: [0, -26, 0, -11, 0] } : { y: 0 }}
transition={{ duration: 0.75, ease: "easeOut" }}
>
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-1/2 rounded-t-[26%] bg-gradient-to-b from-white/30 to-transparent" />
<app.Icon aria-hidden="true" className="relative h-1/2 w-1/2" strokeWidth={2} />
</motion.span>
<span aria-hidden="true" className="pointer-events-none absolute bottom-full left-1/2 mb-3 flex w-0 justify-center">
<AnimatePresence>
{tip && (
<motion.span
key="tip"
className="flex-none whitespace-nowrap rounded-lg bg-zinc-950/85 px-2.5 py-1 text-xs font-medium text-white shadow-lg ring-1 ring-white/15"
initial={still ? false : { opacity: 0, y: 6, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.95, transition: { duration: 0.1 } }}
transition={{ type: "spring", stiffness: 500, damping: 28 }}
>
{app.name}
</motion.span>
)}
</AnimatePresence>
</span>
</motion.button>
{app.open && <span aria-hidden="true" className="absolute -bottom-2 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-white/90" />}
</li>
);
}
export default function MagnifyingDockWithTooltipsAndIdleWave() {
const reduce = useReducedMotion();
const stage = useRef<HTMLElement>(null);
const inView = useInView(stage, { amount: 0.3 });
const pointer = useMotionValue(Infinity);
const [compact, setCompact] = useState(false);
const [idle, setIdle] = useState(true);
const [tip, setTip] = useState(-1);
const [launching, setLaunching] = useState(-1);
const sizes = compact ? PHONE : DESKTOP;
const pitch = sizes.base + sizes.gap;
useEffect(() => {
const el = stage.current;
if (!el) return;
const measure = () => setCompact(el.clientWidth < 640);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
// Idle demo: a pointer that is not there sweeps across the dock and back.
useEffect(() => {
if (!idle || reduce || !inView) return;
const edge = (apps.length / 2) * pitch + sizes.reach * 0.6;
pointer.set(-edge);
const sweep = animate(pointer, [-edge, edge], { duration: 2.6, ease: "easeInOut", repeat: Infinity, repeatType: "mirror" });
return () => sweep.stop();
}, [idle, reduce, inView, pitch, sizes.reach, pointer]);
useMotionValueEvent(pointer, "change", (x) => {
const i = Math.round(x / pitch + (apps.length - 1) / 2);
setTip(Number.isFinite(x) && i >= 0 && i < apps.length ? i : -1);
});
useEffect(() => {
if (launching < 0) return;
const id = window.setTimeout(() => setLaunching(-1), 800);
return () => window.clearTimeout(id);
}, [launching]);
const track = (e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
setIdle(false);
pointer.set(e.clientX - r.left - r.width / 2);
};
const release = () => {
pointer.set(Infinity);
setIdle(true);
};
return (
<section ref={stage} className="relative isolate flex h-[600px] flex-col overflow-hidden bg-[#1b0b3a] text-white">
{/* Wallpaper: dusk sky, a low sun and three ridgelines */}
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-gradient-to-b from-[#1b0b3a] via-[#8a1c5c] to-[#ff9d4d]" />
<div aria-hidden="true" className="absolute bottom-16 left-1/2 -z-10 h-56 w-56 -translate-x-1/2 rounded-full bg-gradient-to-b from-amber-100 to-orange-400 shadow-[0_0_120px_50px_rgba(255,170,90,0.5)]" />
<svg aria-hidden="true" viewBox="0 0 1440 320" preserveAspectRatio="none" className="absolute inset-x-0 bottom-0 -z-10 h-64 w-full">
<path fill="#6d1650" d="M0 140 C 180 70 320 190 520 120 S 860 40 1040 130 S 1320 90 1440 150 V320 H0Z" />
<path fill="#451046" d="M0 210 C 220 130 380 250 620 180 S 980 130 1160 200 S 1360 170 1440 210 V320 H0Z" />
<path fill="#210a33" d="M0 270 C 240 210 460 300 720 250 S 1180 220 1440 270 V320 H0Z" />
</svg>
<div className="mx-auto max-w-4xl px-5 pt-14 text-center sm:pt-16">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-200">Meridian desktop</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-5xl">Everything you open, one flick away</h2>
<p className="mx-auto mt-4 max-w-md text-rose-100">Sweep across the dock and the icons swell under your pointer. Tab works too.</p>
</div>
<div className="mt-auto flex justify-center pb-6 sm:pb-8">
<div onPointerMove={track} onPointerDown={track} onPointerLeave={release} onPointerCancel={release} className="touch-pan-y px-6 pt-16">
<nav aria-label="Dock" className="rounded-[28px] border border-white/25 bg-white/15 shadow-2xl shadow-black/40 backdrop-blur-xl" style={{ padding: compact ? "8px 10px 12px" : "10px 14px 14px" }}>
<ul className="flex items-end" style={{ gap: sizes.gap, height: sizes.base }}>
{apps.map((app, i) => (
<DockIcon
key={`${app.name}-${compact ? "s" : "l"}`}
app={app}
index={i}
pointer={pointer}
sizes={sizes}
still={!!reduce}
tip={tip === i}
launching={launching === i}
onLaunch={() => setLaunching(i)}
onFocus={() => {
setIdle(false);
pointer.set((i - (apps.length - 1) / 2) * pitch);
}}
onBlur={release}
/>
))}
</ul>
</nav>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/magnifying-dock-with-tooltips-and-idle-wave.json
One motion value holds the pointer's horizontal position measured from the centre of the dock. Each icon turns its distance from that point into a size with a cosine curve, so the nearest icon reaches `peak` and icons further than `reach` stay at `base`. A spring smooths every change.
The pointer position and the sizes are Framer Motion values, which update styles directly without going through React state. React only re-renders when the tooltip moves to a different icon.
The same pointer value is animated from the left edge to the right and back in a mirrored loop. Real input takes over the moment a pointer or keyboard focus arrives, and the loop resumes when it leaves. It stops while the dock is off screen.
Yes. It listens to pointer events, so pressing or dragging along the dock magnifies the icons under a finger. Vertical scrolling still works because the area only claims horizontal movement.
Five to nine is comfortable. On a phone the resting size is 34 pixels, so seven fit a 360 pixel screen with room for the magnified ones.
Related: Floating Toolbar Expanding from a Pill with Tool Options, Habit Tracker Phone Screen with Tab Bar, Navbar with Centered Links, CTA Button and Mobile Menu.
More docks and floating menus or all free UI components. Start for free to have a full site built around it.
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.