Subframe
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

Source: Motion Primitives

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.

More examples