Floating Dock

A macOS-style dock with proximity-based icon magnification and smooth animations.

Overview

An interactive dock component inspired by macOS, featuring proximity-based icon scaling, floating labels, and responsive mobile/desktop layouts. Icons smoothly magnify as the cursor approaches.


Features

  • Proximity-based icon magnification
  • Smooth spring physics for scaling
  • Floating tooltip labels on hover
  • Auto-centered responsive mobile drawer
  • Dynamic stroke weight animation

Preview

Live Preview

Installation

Install via the ZenBlocks CLI to automatically handle dependencies:

npx shadcn@latest add https://zenblocks-three.vercel.app/r/floating-dock.json

Method 2: Manual

  1. Install Dependencies

    npm install framer-motion lucide-react
  2. Copy the Source Code

    Copy the code below into components/zenblocks/floating-dock.tsx.

    Click to expand source
    "use client";
    
    import React, { useRef, useState } from "react";
    import {
        AnimatePresence,
        motion,
        useMotionValue,
        useSpring,
        useTransform,
    } from "framer-motion";
    import { LayoutGrid } from "lucide-react";
    import { cn } from "@/lib/utils";
    import Link from "next/link";
    
    export interface FloatingDockItem {
        title: string;
        icon: React.ReactElement; // Using ReactElement to allow cloning for strokeWidth
        href: string;
    }
    
    export interface FloatingDockProps {
        items: FloatingDockItem[];
        desktopClassName?: string;
        mobileClassName?: string;
    }
    
    export const FloatingDock = ({
        items,
        desktopClassName,
        mobileClassName,
    }: FloatingDockProps) => {
        return (
            <>
                <FloatingDockDesktop items={items} className={desktopClassName} />
                <FloatingDockMobile items={items} className={mobileClassName} />
            </>
        );
    };
    
    const FloatingDockMobile = ({
        items,
        className,
    }: {
        items: FloatingDockItem[];
        className?: string;
    }) => {
        const [open, setOpen] = useState(false);
        return (
            <div className={cn("relative block md:hidden w-fit mx-auto", className)}>
                <AnimatePresence>
                    {open && (
                        <motion.div
                            layoutId="nav"
                            className="absolute bottom-full mb-4 inset-x-0 flex flex-col gap-3 items-center"
                        >
                            {items?.map((item, idx) => (
                                <motion.div
                                    key={item.title}
                                    initial={{ opacity: 0, y: 10, scale: 0.8 }}
                                    animate={{
                                        opacity: 1,
                                        y: 0,
                                        scale: 1
                                    }}
                                    exit={{
                                        opacity: 0,
                                        y: 10,
                                        scale: 0.8,
                                        transition: {
                                            delay: idx * 0.05,
                                        },
                                    }}
                                    transition={{ type: "spring", stiffness: 300, damping: 20, delay: (items.length - 1 - idx) * 0.05 }}
                                >
                                    <Link
                                        href={item.href}
                                        className="h-12 w-12 rounded-2xl bg-white dark:bg-zinc-900 flex items-center justify-center border border-zinc-200 dark:border-zinc-800 shadow-xl"
                                    >
                                        <div className="h-5 w-5">{item.icon}</div>
                                    </Link>
                                </motion.div>
                            ))}
                        </motion.div>
                    )}
                </AnimatePresence>
                <button
                    onClick={() => setOpen(!open)}
                    className="h-12 w-12 rounded-2xl bg-zinc-900 dark:bg-white flex items-center justify-center shadow-2xl transition-transform active:scale-90"
                >
                    <LayoutGrid className="h-6 w-6 text-white dark:text-zinc-900" />
                </button>
            </div>
        );
    };
    
    const FloatingDockDesktop = ({
        items,
        className,
    }: {
        items: FloatingDockItem[];
        className?: string;
    }) => {
        let mouseX = useMotionValue(Infinity);
        return (
            <motion.div
                onMouseMove={(e) => mouseX.set(e.pageX)}
                onMouseLeave={() => mouseX.set(Infinity)}
                className={cn(
                    "mx-auto hidden md:flex h-20 gap-4 items-end rounded-[2.5rem] bg-white/40 dark:bg-zinc-950/40 backdrop-blur-3xl px-6 pb-4 border border-zinc-200/50 dark:border-zinc-800/50 shadow-[0_30px_60px_-15px_rgba(0,0,0,0.1)] dark:shadow-[0_30px_60px_-15px_rgba(0,0,0,0.5)] w-fit",
                    className
                )}
            >
                {items?.map((item) => (
                    <IconContainer mouseX={mouseX} key={item.title} {...item} />
                ))}
            </motion.div>
        );
    };
    
    function IconContainer({
        mouseX,
        title,
        icon,
        href,
    }: {
        mouseX: any;
        title: string;
        icon: React.ReactElement;
        href: string;
    }) {
        let ref = useRef<HTMLDivElement>(null);
    
        let distance = useTransform(mouseX, (val: number) => {
            let bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 };
            return val - bounds.x - bounds.width / 2;
        });
    
        // Proximity Scaling - Iconic Zen "Liquid" feel
        let widthSync = useTransform(distance, [-150, 0, 150], [44, 90, 44]);
        let heightSync = useTransform(distance, [-150, 0, 150], [44, 90, 44]);
    
        // Floating Effect - Lift icons as they scale
        let ySync = useTransform(distance, [-150, 0, 150], [0, -12, 0]);
    
        // Dynamic Stroke - Icons get bolder when focused
        let strokeSync = useTransform(distance, [-150, 0, 150], [1.5, 2.5, 1.5]);
    
        let width = useSpring(widthSync, { mass: 0.1, stiffness: 200, damping: 15 });
        let height = useSpring(heightSync, { mass: 0.1, stiffness: 200, damping: 15 });
        let y = useSpring(ySync, { mass: 0.1, stiffness: 200, damping: 15 });
        let strokeWidth = useSpring(strokeSync, { mass: 0.1, stiffness: 200, damping: 15 });
    
        const [hovered, setHovered] = useState(false);
    
        return (
            <Link href={href}>
                <motion.div
                    ref={ref}
                    style={{ width, height, y }}
                    onMouseEnter={() => setHovered(true)}
                    onMouseLeave={() => setHovered(false)}
                    className="aspect-square rounded-[1.5rem] bg-zinc-50 dark:bg-zinc-900 flex items-center justify-center relative shadow-sm border border-zinc-200 dark:border-zinc-800 hover:border-zinc-400 dark:hover:border-zinc-600 transition-colors group/dock-item"
                >
                    {/* Internal Glow Effect */}
                    <motion.div
                        className="absolute inset-0 rounded-[1.5rem] bg-gradient-to-t from-zinc-200/50 to-transparent dark:from-white/5 opacity-0 group-hover/dock-item:opacity-100 transition-opacity"
                    />
    
                    <AnimatePresence>
                        {hovered && (
                            <motion.div
                                initial={{ opacity: 0, y: 10, x: "-50%", scale: 0.9 }}
                                animate={{ opacity: 1, y: -45, x: "-50%", scale: 1 }}
                                exit={{ opacity: 0, y: 10, x: "-50%", scale: 0.9 }}
                                transition={{ type: "spring", stiffness: 400, damping: 25 }}
                                className="px-3 py-1 whitespace-pre rounded-xl bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 absolute left-1/2 -top-8 w-fit text-[10px] font-black uppercase tracking-widest shadow-2xl z-50 pointer-events-none"
                            >
                                {title}
                                <div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 rotate-45 bg-zinc-950 dark:bg-white" />
                            </motion.div>
                        )}
                    </AnimatePresence>
    
                    <motion.div
                        className="flex items-center justify-center w-full h-full p-2"
                    >
                        {React.cloneElement(icon, {
                            // @ts-ignore - Dynamically injecting strokeWidth for Lucide icons
                            strokeWidth: strokeWidth.get(),
                            className: cn("w-1/2 h-1/2 transition-colors", hovered ? "text-zinc-900 dark:text-white" : "text-zinc-500")
                        })}
                    </motion.div>
    
                    {/* Indicator Dot */}
                    {hovered && (
                        <motion.div
                            layoutId="indicator"
                            className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-zinc-900 dark:bg-white"
                        />
                    )}
                </motion.div>
            </Link>
        );
    }

Usage

import { FloatingDock } from "@/components/zenblocks/floating-dock";
import { Home, Settings } from "lucide-react";

const items = [
  { title: "Home", icon: <Home />, href: "/" },
  { title: "Settings", icon: <Settings />, href: "/settings" }
];

<FloatingDock items={items} />

Props

PropTypeDefaultDescription
itemsFloatingDockItem[]-Array of dock items
desktopClassNamestring-Classes for desktop variant
mobileClassNamestring-Classes for mobile variant

FloatingDockItem

PropertyTypeDescription
titlestringLabel shown on hover
iconReactElementIcon component
hrefstringNavigation link

Accessibility

  • Keyboard navigation with Tab key
  • Focus indicators on all interactive elements
  • ARIA labels for icon-only buttons
  • Mobile variant uses semantic button for toggle

Customization

Icon Size Range

The magnification range is controlled by useTransform:

  • Base size: 44px
  • Hover size: 90px
  • Distance threshold: 150px

Custom Styling

<FloatingDock 
  desktopClassName="bg-white/80"
  items={items}
/>

Motion Behavior

  • Magnification: Icons scale based on cursor distance using useTransform with [-150, 0, 150] range
  • Lift effect: Icons translate upward (-12px) when magnified
  • Stroke weight: Icon stroke increases from 1.5 to 2.5 on proximity
  • Springs: All animations use mass: 0.1, stiffness: 200, damping: 15

Performance Notes

  • Desktop and mobile variants render conditionally (hidden via CSS)
  • Uses useMotionValue for cursor tracking without React re-renders
  • Icon transformations are GPU-accelerated via CSS transforms

Examples

const navItems = [
  { title: "Dashboard", icon: <LayoutDashboard />, href: "/dashboard" },
  { title: "Projects", icon: <FolderKanban />, href: "/projects" },
  { title: "Team", icon: <Users />, href: "/team" }
];

<FloatingDock items={navItems} />

Notes

  • Mobile variant (< 768px) shows as an expandable button
  • Desktop variant is hidden on mobile and vice versa
  • Requires Next.js Link component for navigation
  • Works with any Lucide React icons