Menu

Magnetic Navigation Dock

A macOS-style dock with magnetic magnification and spring physics for a playful navigation experience.

Preview

Live interactive preview.

Installation

Add this component to your project using the CLI:

terminal
npx -y vui-registry-cli-v1@latest add magnetic-nav-dock

Source Code

dock-icon.tsx
"use client"

import { useRef, useState } from "react"
import {
  motion,
  useMotionValue,
  useSpring,
  useTransform,
  AnimatePresence,
  type MotionValue,
} from "framer-motion"
import { cn } from "@/lib/utils"
import type { DockItem } from "./dock-items"

type DockIconProps = {
  mouseX: MotionValue<number>
  item: DockItem
  isActive: boolean
  onClick: () => void
}

export function DockIcon({ mouseX, item, isActive, onClick }: DockIconProps) {
  const ref = useRef<HTMLDivElement>(null)
  const Icon = item.icon
  const [hovered, setHovered] = useState(false)

  const distance = useTransform(mouseX, (val) => {
    const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 }
    return val - bounds.x - bounds.width / 2
  })

  const widthSync = useTransform(distance, [-150, 0, 150], [38, 72, 38])
  const width = useSpring(widthSync, { mass: 0.1, stiffness: 150, damping: 12 })

  return (
    <div className="flex flex-col items-center gap-1">
      <motion.div
        ref={ref}
        style={{ width, height: width }}
        className="aspect-square cursor-pointer relative group flex items-center justify-center"
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
        onClick={onClick}
      >
        <motion.div
          className={cn(
            "absolute inset-0 rounded-2xl bg-white dark:bg-zinc-100 border border-slate-200/80 dark:border-zinc-300 shadow-lg flex items-center justify-center overflow-hidden transition-all duration-200",
            isActive && "ring-2 ring-white/60 ring-offset-2 ring-offset-black/50"
          )}
          whileTap={{ scale: 0.85 }}
          animate={isActive ? { y: [0, -10, 0] } : {}}
          transition={isActive ? { duration: 0.5, ease: "easeInOut", repeat: 0 } : {}}
        >
          <div className="absolute inset-0 bg-gradient-to-b from-white/40 to-transparent opacity-60" />
          <Icon className="relative z-10 w-[42%] h-[42%]" />
        </motion.div>

        <AnimatePresence>
          {hovered && (
            <motion.div
              initial={{ opacity: 0, y: 10, x: "-50%" }}
              animate={{ opacity: 1, y: -50, x: "-50%" }}
              exit={{ opacity: 0, y: 2, x: "-50%" }}
              transition={{ duration: 0.2 }}
              className="absolute left-1/2 -top-2 px-3 py-1 bg-neutral-900/90 text-neutral-200 text-xs font-medium rounded-md pointer-events-none whitespace-nowrap border border-white/10 shadow-xl backdrop-blur-md z-50"
            >
              {item.label}
              <div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-neutral-900/90 rotate-45 border-r border-b border-white/10" />
            </motion.div>
          )}
        </AnimatePresence>
      </motion.div>

      <div
        className={cn(
          "w-1 h-1 rounded-full bg-white/50 transition-all duration-300",
          isActive ? "opacity-100 scale-100" : "opacity-0 scale-0"
        )}
      />
    </div>
  )
}

dock-items.ts

dock-items.ts
import type { ComponentType } from "react"
import {
  ReactIcon,
  NextjsIcon,
  TailwindCssIcon,
  ClaudeIcon,
  GeminiIcon,
  CopilotIcon,
  ChatgptIcon,
  FramerIcon,
} from "@/components/icons/tech-svgs"
import { Settings } from "lucide-react"

export type DockItem = {
  id: string
  label: string
  icon: ComponentType<{ className?: string }>
  dividerAfter?: boolean
}

export const MAIN_DOCK_ITEMS: DockItem[] = [
  { id: "react", label: "React", icon: ReactIcon },
  { id: "nextjs", label: "Next.js", icon: NextjsIcon },
  { id: "tailwind", label: "Tailwind", icon: TailwindCssIcon },
  { id: "claude", label: "Claude", icon: ClaudeIcon },
  { id: "gemini", label: "Gemini", icon: GeminiIcon },
  { id: "copilot", label: "Copilot", icon: CopilotIcon },
  { id: "chatgpt", label: "ChatGPT", icon: ChatgptIcon, dividerAfter: true },
]

export const UTILITY_DOCK_ITEMS: DockItem[] = [
  { id: "framer", label: "Framer", icon: FramerIcon },
  { id: "settings", label: "Settings", icon: Settings },
]

Source Code

magnetic-nav-dock.tsx
"use client"

import { useState } from "react"
import { motion, useMotionValue } from "framer-motion"
import { cn } from "@/lib/utils"
import { DockIcon } from "./dock-icon"
import { MAIN_DOCK_ITEMS, UTILITY_DOCK_ITEMS } from "./dock-items"

export type MagneticNavDockProps = {
  className?: string
  defaultActive?: string
  onItemClick?: (id: string) => void
}

export default function MagneticNavDock({
  className,
  defaultActive = "react",
  onItemClick,
}: MagneticNavDockProps) {
  const mouseX = useMotionValue(Infinity)
  const [activeApp, setActiveApp] = useState(defaultActive)

  const handleClick = (id: string) => {
    setActiveApp(id)
    onItemClick?.(id)
  }

  return (
    <div
      className={cn(
        "relative flex min-h-[500px] w-full items-center justify-center overflow-hidden bg-neutral-950 font-sans",
        className
      )}
    >
      <div className="absolute inset-0 z-0">
        <div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1550684848-fac1c5b4e853?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center opacity-40 mix-blend-overlay" />
        <div className="absolute inset-0 bg-gradient-to-t from-neutral-950 via-neutral-950/50 to-neutral-950/20" />
      </div>

      <motion.div
        onMouseMove={(e) => mouseX.set(e.pageX)}
        onMouseLeave={() => mouseX.set(Infinity)}
        className="mx-auto flex h-16 items-end gap-3 rounded-2xl bg-white/5 px-4 pb-3 shadow-2xl backdrop-blur-2xl border border-white/10 ring-1 ring-white/5 relative z-10"
      >
        {MAIN_DOCK_ITEMS.map((item) => (
          <div key={item.id} className="contents">
            <DockIcon
              mouseX={mouseX}
              item={item}
              isActive={activeApp === item.id}
              onClick={() => handleClick(item.id)}
            />
            {item.dividerAfter && <div className="h-10 w-px bg-white/10 mx-1 self-center" />}
          </div>
        ))}

        {UTILITY_DOCK_ITEMS.map((item) => (
          <DockIcon
            key={item.id}
            mouseX={mouseX}
            item={item}
            isActive={activeApp === item.id}
            onClick={() => handleClick(item.id)}
          />
        ))}
      </motion.div>
    </div>
  )
}

Dependencies

  • framer-motion: latest
  • lucide-react: latest
  • clsx: latest
  • tailwind-merge: latest

Props

Component property reference.

NameTypeDefaultDescription
classNamestring-Optional class name for the dock container.
defaultActivestring-ID of the dock item shown as active on first render.
onItemClick(id: string) => void-Callback fired when a dock icon is clicked.
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.