Create a macOS Dock in React
Replicate the macOS Dock magnification effect where icons scale up as the cursor approaches them.
The macOS Dock magnification effect has been around since 2001 and it still feels great every single time. There is something deeply satisfying about icons growing toward your cursor like plants toward light. Here is how to build it with Framer Motion in about 200 lines.
The final result
What we are building
A horizontal row of icons that magnify as the cursor approaches. The icon closest to the pointer grows largest. Neighboring icons scale up progressively less. The dock panel height grows to accommodate the magnified icons. Tooltips appear on hover.
Setting up
npm install motionimport {
motion, MotionValue, useMotionValue, useSpring,
useTransform, AnimatePresence,
} from 'motion/react';Building the component
Tracking cursor position
The parent Dock component tracks the horizontal mouse position as a MotionValue. This value flows down to each DockItem without triggering React re-renders:
const mouseX = useMotionValue(Infinity);
// On the dock panel:
onMouseMove={({ pageX }) => {
isHovered.set(1);
mouseX.set(pageX);
}}
onMouseLeave={() => {
isHovered.set(0);
mouseX.set(Infinity);
}}Setting Infinity on leave effectively puts the cursor infinitely far from all icons, collapsing them back to base size.
Per-icon size calculation
Each DockItem computes its distance from the cursor using useTransform. The transform maps distance to a target size using three breakpoints:
const mouseDistance = useTransform(mouseX, val => {
const rect = ref.current?.getBoundingClientRect() ?? { x: 0, width: baseItemSize };
return val - rect.x - baseItemSize / 2;
});
const targetSize = useTransform(
mouseDistance,
[-distance, 0, distance],
[baseItemSize, magnification, baseItemSize]
);
const size = useSpring(targetSize, spring);At distance 0 (cursor directly over the icon) the size is magnification. At distance pixels away it returns to baseItemSize. The spring smooths the transition so the size change feels elastic.
Dynamic dock height
The dock container height is also a spring value. When the cursor enters, it grows to accommodate the tallest magnified icon:
const maxHeight = useMemo(
() => Math.max(dockHeight, magnification + magnification / 2 + 4),
[magnification, dockHeight]
);
const heightRow = useTransform(isHovered, [0, 1], [panelHeight, maxHeight]);
const height = useSpring(heightRow, spring);
// Applied to the outer wrapper:
<motion.div style={{ height, scrollbarWidth: 'none' }} className='flex items-center'>Tooltip with AnimatePresence
The tooltip uses a MotionValue subscription rather than a React state to detect hover, keeping renders minimal:
function DockLabel({ children, isHovered }: DockLabelProps) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (!isHovered) return;
const unsubscribe = isHovered.on('change', latest => {
setIsVisible(latest === 1);
});
return () => unsubscribe();
}, [isHovered]);
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: -10 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 0.2 }}
className='absolute -top-6 left-1/2 whitespace-pre rounded-md border border-neutral-700 bg-[#060010] px-2 py-0.5 text-xs text-white'
style={{ x: '-50%' }}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}How to use it
<Dock
items={[
{ icon: <HomeIcon />, label: 'Home', onClick: () => navigate('/') },
{ icon: <WorkIcon />, label: 'Work', onClick: () => navigate('/work') },
{ icon: <MailIcon />, label: 'Contact', onClick: () => navigate('/contact') },
]}
magnification={70}
distance={200}
baseItemSize={50}
spring={{ mass: 0.1, stiffness: 150, damping: 12 }}
/>| Prop | Default | Description |
|------|---------|-------------|
| magnification | 70 | Maximum size of the icon at cursor |
| distance | 200 | Pixel radius of the magnification falloff |
| baseItemSize | 50 | Default icon size |
| panelHeight | 64 | Height of the dock bar at rest |
| spring | { mass: 0.1, stiffness: 150, damping: 12 } | Spring config for size animation |
Key takeaways
- Using
MotionValuefor cursor position means the magnification effect runs entirely outside the React render cycle. There are zero re-renders while the cursor moves across the dock. - The
useTransformwith three breakpoints[-distance, 0, distance]gives you a natural bell-curve magnification. Icons farther thandistancepixels away stay at base size. - The
isHoveredMotionValue being passed to child components viacloneElementis a clean pattern for parent-to-child MotionValue communication without prop drilling.