Command Palette

Search for a command to run...

Dock

A versatile UI element that provides a flexible and customizable way to organize and display menu items within your application.

Installation

Install the following dependencies:

pnpm add motion

Create a file at components/motion/dock.tsx and paste the given code.

"use client";
 
import {
    motion,
    MotionValue,
    useMotionValue,
    useSpring,
    useTransform,
    type SpringOptions,
    AnimatePresence,
} from "motion/react";
import {
    Children,
    cloneElement,
    createContext,
    useContext,
    useEffect,
    useMemo,
    useRef,
    useState,
} from "react";
import { cn } from "@/lib/utils";
 
const DOCK_HEIGHT = 128;
const DEFAULT_MAGNIFICATION = 80;
const DEFAULT_DISTANCE = 150;
const DEFAULT_PANEL_HEIGHT = 64;
 
export type DockProps = {
    children: React.ReactNode;
    className?: string;
    distance?: number;
    panelHeight?: number;
    magnification?: number;
    spring?: SpringOptions;
};
 
export type DockItemProps = {
    className?: string;
    children: React.ReactNode;
};
 
export type DockLabelProps = {
    className?: string;
    children: React.ReactNode;
};
 
export type DockIconProps = {
    className?: string;
    children: React.ReactNode;
};
 
export type DocContextType = {
    mouseX: MotionValue;
    spring: SpringOptions;
    magnification: number;
    distance: number;
};
 
export type DockProviderProps = {
    children: React.ReactNode;
    value: DocContextType;
};
 
const DockContext = createContext<DocContextType | undefined>(undefined);
 
function DockProvider({ children, value }: DockProviderProps) {
    return (
        <DockContext.Provider value={value}>{children}</DockContext.Provider>
    );
}
 
function useDock() {
    const context = useContext(DockContext);
    if (!context) {
        throw new Error("useDock must be used within an DockProvider");
    }
    return context;
}
 
function Dock({
    children,
    className,
    spring = { mass: 0.1, stiffness: 150, damping: 12 },
    magnification = DEFAULT_MAGNIFICATION,
    distance = DEFAULT_DISTANCE,
    panelHeight = DEFAULT_PANEL_HEIGHT,
}: DockProps) {
    const mouseX = useMotionValue(Infinity);
    const isHovered = useMotionValue(0);
 
    const maxHeight = useMemo(() => {
        return Math.max(DOCK_HEIGHT, magnification + magnification / 2 + 4);
    }, [magnification]);
 
    const heightRow = useTransform(isHovered, [0, 1], [panelHeight, maxHeight]);
    const height = useSpring(heightRow, spring);
 
    return (
        <motion.div
            style={{
                height: height,
                scrollbarWidth: "none",
            }}
            className="mx-2 flex max-w-full items-end overflow-x-auto"
        >
            <motion.div
                onMouseMove={({ pageX }) => {
                    isHovered.set(1);
                    mouseX.set(pageX);
                }}
                onMouseLeave={() => {
                    isHovered.set(0);
                    mouseX.set(Infinity);
                }}
                className={cn(
                    "mx-auto flex w-fit gap-4 rounded-2xl bg-gray-50 px-4 dark:bg-neutral-900",
                    className,
                )}
                style={{ height: panelHeight }}
                role="toolbar"
                aria-label="Application dock"
            >
                <DockProvider
                    value={{ mouseX, spring, distance, magnification }}
                >
                    {children}
                </DockProvider>
            </motion.div>
        </motion.div>
    );
}
 
function DockItem({ children, className }: DockItemProps) {
    const ref = useRef<HTMLDivElement>(null);
 
    const { distance, magnification, mouseX, spring } = useDock();
 
    const isHovered = useMotionValue(0);
 
    const mouseDistance = useTransform(mouseX, (val) => {
        const domRect = ref.current?.getBoundingClientRect() ?? {
            x: 0,
            width: 0,
        };
        return val - domRect.x - domRect.width / 2;
    });
 
    const widthTransform = useTransform(
        mouseDistance,
        [-distance, 0, distance],
        [40, magnification, 40],
    );
 
    const width = useSpring(widthTransform, spring);
 
    return (
        <motion.div
            ref={ref}
            style={{ width }}
            onHoverStart={() => isHovered.set(1)}
            onHoverEnd={() => isHovered.set(0)}
            onFocus={() => isHovered.set(1)}
            onBlur={() => isHovered.set(0)}
            className={cn(
                "relative inline-flex items-center justify-center",
                className,
            )}
            tabIndex={0}
            role="button"
            aria-haspopup="true"
        >
            {Children.map(children, (child) =>
                // eslint-disable-next-line @typescript-eslint/no-explicit-any
                cloneElement(child as React.ReactElement<any>, {
                    width,
                    isHovered,
                }),
            )}
        </motion.div>
    );
}
 
function DockLabel({ children, className, ...rest }: DockLabelProps) {
    const restProps = rest as Record<string, unknown>;
    const isHovered = restProps["isHovered"] as MotionValue<number>;
    const [isVisible, setIsVisible] = useState(false);
 
    useEffect(() => {
        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={cn(
                        "absolute -top-6 left-1/2 w-fit whitespace-pre rounded-md border border-gray-200 bg-gray-100 px-2 py-0.5 text-xs text-neutral-700 dark:border-neutral-900 dark:bg-neutral-800 dark:text-white",
                        className,
                    )}
                    role="tooltip"
                    style={{ x: "-50%" }}
                >
                    {children}
                </motion.div>
            )}
        </AnimatePresence>
    );
}
 
function DockIcon({ children, className, ...rest }: DockIconProps) {
    const restProps = rest as Record<string, unknown>;
    const width = restProps["width"] as MotionValue<number>;
 
    const widthTransform = useTransform(width, (val) => val / 2);
 
    return (
        <motion.div
            style={{ width: widthTransform }}
            className={cn("flex items-center justify-center", className)}
        >
            {children}
        </motion.div>
    );
}
 
export { Dock, DockIcon, DockItem, DockLabel };

Update the import paths to match your project setup.