Components & interactionsDock magnification effect
A dock with a little gravity
Nearby tools grow gently as the pointer passes across a compact dock.

Move across the tools, then select one
Add to your project
Component source
Standalone React component. Styles are included.
npm install [email protected]Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* Adapted from https://github.com/ibelick/motion-primitives/blob/120f64f6ca60348e251f929e9c81f11ccbe45eda/components/core/dock.tsx
* Standalone composition; inline styles and artwork; bounded motion and reduced-motion behavior.
*
* MIT License
*
* Copyright (c) 2024 ibelick
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import { motion, type MotionValue, useMotionValue, useSpring, useTransform } from "motion/react"
import { useEffect, useRef, useState } from "react"
// Read after hydration; subscribe so an OS preference change updates this instance.
function useReducedMotion() {
const [reduced, setReduced] = useState<boolean | null>(null)
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)")
const update = () => setReduced(media.matches)
update()
media.addEventListener("change", update)
return () => media.removeEventListener("change", update)
}, [])
return reduced
}
const tools = [
{ label: "Canvas", color: "#c2cdbe", path: "M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z" },
{ label: "Notes", color: "#dbc2a2", path: "M6 3h12v18H6zM9 8h6M9 12h6M9 16h4" },
{ label: "Shapes", color: "#b3c9d0", path: "m12 3 10 18H2zM9 16h6" },
{ label: "Frames", color: "#c6bed6", path: "M4 8V4h4M16 4h4v4M20 16v4h-4M8 20H4v-4M9 9h6v6H9z" },
]
function DockItem({
item,
mouseX,
canAnimate,
selected,
onSelect,
}: {
item: (typeof tools)[number]
mouseX: MotionValue<number>
canAnimate: boolean
selected: boolean
onSelect: () => void
}) {
const ref = useRef<HTMLButtonElement>(null)
const distance = useTransform(mouseX, (value) => {
const element = ref.current
if (!element) {
return Infinity
}
const rect = element.getBoundingClientRect()
return ((value - rect.x - rect.width / 2) * element.offsetWidth) / rect.width
})
const targetWidth = useTransform(distance, [-115, 0, 115], [44, 64, 44])
const width = useSpring(targetWidth, { mass: 0.15, stiffness: 220, damping: 18 })
const iconWidth = useTransform(width, (value) => value * 0.48)
useEffect(() => {
if (!canAnimate) width.jump(44)
}, [canAnimate, width])
return (
<motion.button
ref={ref}
type="button"
aria-label={item.label}
aria-pressed={selected}
onClick={onSelect}
style={{
width: canAnimate ? width : 44,
height: canAnimate ? width : 44,
padding: 0,
border: 0,
borderRadius: 12,
background: item.color,
color: "#48505a",
cursor: "pointer",
display: "grid",
placeItems: "center",
position: "relative",
outlineOffset: 4,
}}
>
<motion.svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
style={{ width: canAnimate ? iconWidth : 21, height: canAnimate ? iconWidth : 21 }}
>
<path d={item.path} />
</motion.svg>
<span
aria-hidden="true"
style={{
position: "absolute",
bottom: -8,
width: 3,
height: 3,
borderRadius: "50%",
background: selected ? "#797c75" : "transparent",
}}
/>
</motion.button>
)
}
export default function MagnifyingDock({ active = true }: { active?: boolean }) {
const reduced = useReducedMotion()
const [keyboard, setKeyboard] = useState(false)
const canAnimate = active && reduced === false && !keyboard
const mouseX = useMotionValue(Infinity)
const [selected, setSelected] = useState("Canvas")
useEffect(() => {
if (!canAnimate) mouseX.set(Infinity)
}, [canAnimate, mouseX])
return (
<div
style={{
width: "100%",
height: "100%",
minHeight: 260,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
gap: 22,
background: "#e7e9e6",
color: "#5f665f",
fontFamily: "inherit",
}}
>
<div style={{ fontSize: 11, letterSpacing: ".02em" }} aria-live="polite">
{selected}
</div>
<div style={{ height: 82, display: "flex", alignItems: "end", maxWidth: "100%" }}>
<div
role="group"
aria-label="Creative tools"
onPointerMove={(event) => {
if (active && reduced === false && event.pointerType === "mouse") {
setKeyboard(false)
mouseX.set(event.clientX)
}
}}
onPointerLeave={() => mouseX.set(Infinity)}
onFocus={(event) => {
if (!event.target.matches(":focus-visible")) return
setKeyboard(true)
mouseX.set(Infinity)
}}
style={{
display: "flex",
gap: 9,
alignItems: "end",
padding: "12px 13px 19px",
border: "1px solid #fff9",
borderRadius: 20,
background: "#f4f5f0a6",
boxShadow: "0 8px 30px #2a403808",
}}
>
{tools.map((item) => (
<DockItem
key={item.label}
item={item}
mouseX={mouseX}
canAnimate={canAnimate}
selected={selected === item.label}
onSelect={() => setSelected(item.label)}
/>
))}
</div>
</div>
</div>
)
}
Where it works well
Add pointer feedback to a small set of creative tools with recognizable icons.
Reduced motion
Tools stay at their original size. Touch and keyboard use stationary native buttons.
Things to know
This bounded four-item dock animates width and height. Large docks need overflow handling and performance testing.