Floating Tooltip

A cursor-following tooltip that squishes and skews based on mouse velocity.

Made by Léo
Open in

Title only

Sweep your cursor quickly
demo-floating-tooltip.tsx
"use client";

import { FloatingTooltip } from "@/components/unlumen-ui/floating-tooltip";
import {
  Share2,
  Pencil,
  Copy,
  Archive,
  Trash2,
  MoveHorizontal,
  MoveVertical,
} from "lucide-react";

const ACTIONS = [
  {
    label: "Share",
    Icon: Share2,
    content: "Share link",
    description: "Copy a shareable link to clipboard.",
  },
  {
    label: "Edit",
    Icon: Pencil,
    content: "Edit",
    description: "Open in the inline editor.",
  },
  {
    label: "Duplicate",
    Icon: Copy,
    content: "Duplicate",
    description: "Creates an exact copy in this folder.",
  },
  {
    label: "Archive",
    Icon: Archive,
    content: "Archive",
    description: "Move to archive — reversible at any time.",
  },
  {
    label: "Delete",
    Icon: Trash2,
    content: "Delete permanently",
    description: "This action cannot be undone.",
  },
];

export function FloatingTooltipDemo() {
  return (
    <FloatingTooltip.Provider>
      <div className="flex flex-col gap-10 p-10 max-w-lg mx-auto w-full select-none">
        {/* Section 1 — title only */}
        <div className="flex flex-col gap-3">
          <p className="text-sm font-medium text-muted-foreground">
            Title only
          </p>
          <div className="flex flex-wrap gap-2">
            {ACTIONS.map(({ label, Icon, content }) => (
              <FloatingTooltip.Trigger key={label} content={content}>
                <button className="flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent">
                  <Icon className="size-4" />
                  <span>{label}</span>
                </button>
              </FloatingTooltip.Trigger>
            ))}
          </div>
        </div>

        {/* Section 3 — velocity squish */}
        <div className="flex flex-col  justify-center gap-3">
          <FloatingTooltip.Trigger
            content="Velocity-driven shape"
            description="Squish, skew & border-radius all react to cursor speed in real time."
          >
            <div className="flex flex-col h-72 w-full items-center text-center justify-center gap-3 rounded-xl border border-dashed border-border bg-accent/30 text-sm text-muted-foreground transition-colors hover:bg-accent/50">
              <MoveVertical className="size-4" />
              <span>Sweep your cursor quickly</span>
              <MoveVertical className="size-4" />
            </div>
          </FloatingTooltip.Trigger>
        </div>
      </div>
    </FloatingTooltip.Provider>
  );
}

Installation

Install the following dependencies:

Copy and paste the following code into your project:

components/unlumen-ui/floating-tooltip.tsx
"use client";

import { createContext, useContext, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import {
  AnimatePresence,
  motion,
  useMotionValue,
  useSpring,
  useVelocity,
  useTransform,
} from "motion/react";

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

interface TooltipContextType {
  setContent: (content: string, description?: string) => void;
  setIsActive: (active: boolean) => void;
}

const TooltipContext = createContext<TooltipContextType | null>(null);

export function FloatingTooltipProvider({
  children,
  className,
}: {
  children: React.ReactNode;
  className?: string;
}) {
  const x = useMotionValue(0);
  const y = useMotionValue(0);

  const springConfig = { damping: 25, stiffness: 300 };
  const smoothX = useSpring(x, springConfig);
  const smoothY = useSpring(y, springConfig);

  const velocityX = useVelocity(smoothX);
  const velocityY = useVelocity(smoothY);

  const scaleX = useTransform(velocityX, [-1000, 0, 1000], [0.9, 1, 1.15]);
  const scaleY = useTransform(velocityY, [-1000, 0, 1000], [1.15, 1, 0.9]);

  const skewX = useTransform(velocityX, [-1000, 0, 1000], [-3, 0, 3]);
  const skewY = useTransform(velocityY, [-1000, 0, 1000], [-3, 0, 3]);

  const borderRadius = useTransform([velocityX, velocityY], ([vx, vy]) => {
    const velocity = Math.sqrt((vx as number) ** 2 + (vy as number) ** 2);
    const radius = 8 + Math.min(velocity / 80, 16);
    return `${radius}px`;
  });

  const [isActive, setIsActive] = useState(false);
  const [content, setContent] = useState("");
  const [description, setDescription] = useState("");
  useEffect(() => {
    if (typeof window === "undefined") return;

    const getZoom = () => {
      const htmlElement = document.documentElement;
      const computedZoom = window.getComputedStyle(htmlElement).zoom;
      return computedZoom ? parseFloat(computedZoom) : 1;
    };

    const handleMouseMove = (e: MouseEvent) => {
      const zoom = getZoom();
      x.set(e.clientX / zoom);
      y.set(e.clientY / zoom);
    };

    window.addEventListener("mousemove", handleMouseMove, { passive: true });

    return () => {
      window.removeEventListener("mousemove", handleMouseMove);
    };
  }, [x, y]);

  const handleSetContent = (newContent: string, newDescription?: string) => {
    setContent(newContent);
    setDescription(newDescription || "");
  };

  return (
    <TooltipContext.Provider
      value={{ setContent: handleSetContent, setIsActive }}
    >
      {children}
      {typeof document !== "undefined" &&
        createPortal(
          <AnimatePresence>
            {isActive && content && (
              <motion.div
                className="pointer-events-none fixed z-50"
                style={{
                  top: smoothY,
                  left: smoothX,
                }}
                initial={{
                  opacity: 0,
                  scale: 0.8,
                }}
                animate={{
                  opacity: 1,
                  scale: 1,
                }}
                exit={{
                  opacity: 0,
                  scale: 0.8,
                }}
                transition={{
                  duration: 0.15,
                  ease: "easeOut",
                }}
              >
                <motion.div
                  layout
                  className={cn(
                    "ml-4 mt-4 bg-primary dark:bg-white px-4 py-3 text-sm font-medium text-background shadow-lg",
                    className,
                  )}
                  style={{
                    scaleX,
                    scaleY,
                    skewX,
                    skewY,
                    borderRadius,
                  }}
                  transition={{
                    layout: {
                      type: "spring",
                      damping: 25,
                      stiffness: 400,
                    },
                  }}
                >
                  <motion.div
                    key={content}
                    initial={{ opacity: 0 }}
                    animate={{ opacity: 1 }}
                    transition={{ duration: 0.15 }}
                    className="flex flex-col gap-1"
                  >
                    <span className="whitespace-nowrap font-semibold">
                      {content}
                    </span>
                    {description && (
                      <span className="max-w-[28ch] whitespace-normal text-sm leading-snug opacity-70 font-normal">
                        {description}
                      </span>
                    )}
                  </motion.div>
                </motion.div>
              </motion.div>
            )}
          </AnimatePresence>,
          document.body,
        )}
    </TooltipContext.Provider>
  );
}

export function FloatingTooltipTrigger({
  children,
  content,
  description,
}: {
  children: React.ReactNode;
  content: string;
  description?: string;
}) {
  const context = useContext(TooltipContext);

  if (!context) {
    throw new Error(
      "FloatingTooltipTrigger must be used within FloatingTooltipProvider",
    );
  }

  const { setContent, setIsActive } = context;

  const handleMouseEnter = () => {
    setContent(content, description);
    setIsActive(true);
  };

  const handleMouseLeave = () => {
    setIsActive(false);
  };

  return (
    <div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
      {children}
    </div>
  );
}

export const FloatingTooltip = {
  Provider: FloatingTooltipProvider,
  Trigger: FloatingTooltipTrigger,
};

Update the import paths to match your project setup.

Usage

import { FloatingTooltip } from "@/components/unlumen-ui/floating-tooltip";

<FloatingTooltip.Provider>
  <FloatingTooltip.Trigger content="Hello" description="Optional description">
    <button>Hover me</button>
  </FloatingTooltip.Trigger>
</FloatingTooltip.Provider>;

API Reference

FloatingTooltip.Provider

Wraps your content and renders the tooltip portal. Place it high in your tree so all triggers share a single tooltip instance.

PropTypeDefaultDescription
childrenReactNodeContent to render
classNamestringExtra classes applied to the tooltip bubble

FloatingTooltip.Trigger

Attach this around any element to make it show the tooltip on hover.

PropTypeDefaultDescription
contentstringMain label shown in the tooltip
descriptionstringOptional secondary text shown below the label
childrenReactNodeThe element that triggers the tooltip

Built by Léo from Unlumen :3

Last updated: 3/15/2026