Back

Motion dock

A reusable and accessible toolbar component that displays animated tooltips on hover or keyboard focus, with smooth transitions as users move between items. Designed by my favorite - Rauno Freiberg.

Current Theme:

API usage

components/motion-dock.demo.tsx
import {
  CpuIcon,
  PaperclipIcon,
  GlobeIcon,
  MicIcon,
  AudioLinesIcon,
} from "lucide-react";

import { MotionDock, type MotionDockProps } from "./motion-dock";

export function MotionDockDemo() {
  return <MotionDock className="[&_svg]:size-4.5" {...demoProps} />;
}
const demoProps: MotionDockProps = {
  dockItems: [
    {
      icon: <CpuIcon />,
      tooltip: "Choose a model",
    },
    {
      icon: <GlobeIcon />,
      tooltip: "Set sources for search",
    },
    {
      icon: <PaperclipIcon />,
      tooltip: "Attach files",
    },

    {
      icon: <MicIcon />,
      tooltip: "Dictation",
    },
    {
      icon: <AudioLinesIcon />,
      tooltip: "Voice mode",
      className: "text-destructive",
    },
  ],
};

Installation

CLI (recommended)

1
Run the command below to add the component to your project.
It will also generate the required base stylesheet if one doesn't already exist and guide you through setting up the import alias @/components/... if it isn't already configured.
pnpm dlx shadcn@latest add https://100xui.com/components/motion-dock.json

Manual

1
Install the following dependencies.
pnpm add clsx motion tailwind-merge
2
Copy and paste the following code into your project.
100xui
components/motion-dock.tsx
"use client";

import React from "react";
import {
  useAnimate,
  motion,
  AnimatePresence,
  usePresence,
  useMotionValue,
  type Transition,
  type AnimationPlaybackControlsWithThen,
  type HTMLMotionProps,
} from "motion/react";

import { usePrevious } from "@/hooks/use-previous";
import { useDebouncedState } from "@/hooks/use-debounced-state";

import { cn } from "@/lib/utils";

interface DockItem extends Omit<HTMLMotionProps<"button">, "ref"> {
  icon: React.ReactElement;
  tooltip: string;
}

export interface MotionDockProps extends React.ComponentProps<"div"> {
  dockItems: DockItem[];
  tooltipBorderRadius?: React.CSSProperties["borderRadius"];
}
const TRANSITION: Transition = {
  duration: 0.2,
  ease: [0.76, 0, 0.24, 1], // easeInOutQuart
};
export function MotionDock({
  dockItems,
  className,
  tooltipBorderRadius = "var(--radius-sm)",
  ...rest
}: MotionDockProps) {
  const [activeItem, setActiveItem] = useDebouncedState<number>(-1, 100);
  const dockRef = React.useRef<HTMLDivElement>(null);

  const handleReset = () => setActiveItem(-1);

  return (
    <div className={cn(className, "!relative !w-fit")} {...rest}>
      <div
        ref={dockRef}
        className="bg-background text-foreground border-border flex gap-0.5 rounded-full border p-1 shadow-sm"
        onMouseLeave={handleReset}
        onBlurCapture={handleReset}
      >
        {dockItems.map(
          ({ icon, tooltip, onMouseEnter, onFocus, className, ...rest }, i) => (
            <motion.button
              key={tooltip}
              aria-label={tooltip}
              onFocus={(e) => {
                setActiveItem(i);
                onFocus?.(e);
              }}
              onMouseEnter={(e) => {
                setActiveItem(i);
                onMouseEnter?.(e);
              }}
              data-dockitem={i}
              className={cn(
                "hover:text-accent-foreground hover:bg-accent focus-visible:text-accent-foreground focus-visible:bg-accent focus-visible:ring-ring focus-visible:ring-offset-background cursor-pointer rounded-full p-1.5 transition-colors duration-150 ease-out focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-none",
                className,
              )}
              {...rest}
            >
              {icon}
            </motion.button>
          ),
        )}
      </div>
      <AnimatePresence>
        {activeItem >= 0 && (
          <ToolTipsContainer
            activeItem={activeItem}
            dockRef={dockRef as React.RefObject<HTMLDivElement>}
            tooltips={dockItems.map(({ tooltip }) => tooltip)}
            tooltipBorderRadius={tooltipBorderRadius}
          />
        )}
      </AnimatePresence>
    </div>
  );
}

interface TooltipsContainerProps {
  dockRef: React.RefObject<HTMLDivElement>;
  activeItem: number;
  tooltips: string[];
  tooltipBorderRadius: React.CSSProperties["borderRadius"];
}
function ToolTipsContainer({
  dockRef,
  activeItem,
  tooltips,
  tooltipBorderRadius,
}: TooltipsContainerProps) {
  const prevMouseIn = usePrevious<number>(activeItem);
  const [isPresent, safeToRemove] = usePresence();
  const [tooltipContainerScope, animate] = useAnimate();
  const x = useMotionValue(0);

  const getTranslateX = React.useCallback(
    (activeItem: number) => {
      const hoverDockItem = dockRef.current.querySelector(
        `[data-dockitem="${activeItem}"]`,
      );
      const { left: hoverDockItemLeft, width: hoverDockItemWidth } =
        hoverDockItem!.getBoundingClientRect();
      const finalPosition = hoverDockItemLeft + hoverDockItemWidth / 2;

      const correspondingTooltip = tooltipContainerScope.current.querySelector(
        `[data-tooltip="${activeItem}"]`,
      );
      const { left: tooltipLeft, width: tooltipWidth } =
        correspondingTooltip!.getBoundingClientRect();
      const currentPosition = tooltipLeft + tooltipWidth / 2;

      const relativeTranslateX = finalPosition - currentPosition;
      const translateX = relativeTranslateX + x.get();
      return translateX;
    },
    [x, dockRef, tooltipContainerScope],
  );

  const getClipPath = React.useCallback(
    (activeItem: number) => {
      let left = 0;
      let right = 0;
      for (let j = 0; j < tooltips.length; j++) {
        const { width } = tooltipContainerScope.current
          .querySelector(`[data-tooltip="${j}"]`)
          .getBoundingClientRect();
        if (j < activeItem) {
          left += width;
        } else if (j > activeItem) {
          right += width;
        }
      }
      const clipPath = `inset(0px ${right}px 0px ${left}px round ${tooltipBorderRadius}`;

      return clipPath;
    },
    [tooltips.length, tooltipContainerScope, tooltipBorderRadius],
  );

  React.useEffect(() => {
    let control: AnimationPlaybackControlsWithThen | undefined = undefined;
    if (isPresent) {
      const keyframes = {
        clipPath: getClipPath(activeItem),
        x: getTranslateX(activeItem),
      };
      if (prevMouseIn === undefined) {
        const enterAnimation = async () => {
          if (typeof control !== "undefined") {
            control.stop();
          }
          await animate(tooltipContainerScope.current, keyframes, {
            duration: 0,
          });
          await animate(
            tooltipContainerScope.current,
            { opacity: 1 },
            TRANSITION,
          );
        };
        enterAnimation();
      } else {
        const intermediateAnimation = () => {
          animate(tooltipContainerScope.current, keyframes, TRANSITION);
        };
        intermediateAnimation();
      }
    } else {
      const exitAnimation = async () => {
        control = await animate(
          tooltipContainerScope.current,
          { opacity: 0 },
          TRANSITION,
        );
        safeToRemove();
      };
      exitAnimation();
    }
  }, [
    animate,
    safeToRemove,
    isPresent,
    prevMouseIn,
    activeItem,
    getClipPath,
    getTranslateX,
    tooltipContainerScope,
  ]);

  return (
    <motion.div
      ref={tooltipContainerScope}
      initial={{
        opacity: 0,
      }}
      style={{
        x,
      }}
      className="bg-primary text-primary-foreground absolute bottom-[calc(100%_+_var(--spacing)_*_1)] flex flex-nowrap py-1 text-xs"
    >
      {tooltips.map((tooltip, i) => (
        <div
          key={tooltip}
          className="px-2 text-nowrap whitespace-nowrap"
          data-tooltip={i}
        >
          {tooltip}
        </div>
      ))}
    </motion.div>
  );
}
3
Finally, Update the import paths to match your project setup.

API reference

<MotionDock/>

PropsTypeDescriptionDefault value
dockItems
{
  icon: React.ReactElement;
  tooltip: string;
  ...rest: Omit<HTMLMotionProps<"button">, "ref">;
}[];
icon: The visual element for the button, such as an SVG icon or a text string.
tooltip: The text that shows up when a user hovers over the button or focuses on it with keyboard navigation.
rest: Any standard React button props, like onClick handlers or disabled states, which will be applied directly to the button, except for ref.
(required)
tooltipBorderRadius?
React.CSSProperties["borderRadius"]
Specifies the border radius of the tooltip.
"var(--radius-sm)"
...rest
React.ComponentProps<"div">
Any standard React div props, like id, style or className, which will be applied directly to the component's root element.
undefined