A cursor-following tooltip with motion and shadcn-style variants.
Made by LéoLoading...
"use client";
import { FloatingTooltip } from "@/components/unlumen-ui/primitives/floating-tooltip";
import { Archive, Copy, Pencil, Share2, Trash2 } 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 (
<div className="flex flex-col gap-10 p-10 max-w-lg mx-auto w-full select-none">
<FloatingTooltip.Provider>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">Default</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>
</FloatingTooltip.Provider>
<FloatingTooltip.Provider variant="outline" size="lg">
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">
Outline / lg
</p>
<FloatingTooltip.Trigger
content="Detailed tooltip"
description="Larger spacing and a fixed rounded shape from the size variant."
contentClassName="font-serif text-2xl tracking-normal font-normal"
descriptionClassName="text-muted-foreground opacity-100"
>
<div className="flex h-32 w-full items-center justify-center rounded-xl border border-dashed border-border bg-accent/30 text-sm text-muted-foreground transition-colors hover:bg-accent/50">
Hover for a larger tooltip
</div>
</FloatingTooltip.Trigger>
</div>
</FloatingTooltip.Provider>
</div>
);
}Installation
Install the following dependencies:
Copy and paste the following code into your project:
"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 { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const floatingTooltipVariants = cva("ml-4 mt-4 font-medium", {
variants: {
variant: {
default: "bg-primary text-background dark:bg-white",
outline: "border border-border bg-background text-foreground shadow-none",
},
size: {
md: "rounded-md px-3.5 py-2.5 text-sm",
lg: "rounded-lg px-5 py-4 text-base",
},
},
defaultVariants: {
variant: "default",
size: "md",
},
});
interface TooltipContextType {
setContent: (
content: string,
description?: string,
contentClassName?: string,
descriptionClassName?: string,
) => void;
setIsActive: (active: boolean) => void;
}
const TooltipContext = createContext<TooltipContextType | null>(null);
export function FloatingTooltipProvider({
children,
className,
variant,
size,
}: {
children: React.ReactNode;
className?: string;
} & VariantProps<typeof floatingTooltipVariants>) {
const x = useMotionValue(0);
const y = useMotionValue(0);
const springConfig = { damping: 45, stiffness: 750 };
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 [isActive, setIsActive] = useState(false);
const [content, setContent] = useState("");
const [description, setDescription] = useState("");
const [contentClassName, setContentClassName] = useState("");
const [descriptionClassName, setDescriptionClassName] = 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,
newContentClassName?: string,
newDescriptionClassName?: string,
) => {
setContent(newContent);
setDescription(newDescription || "");
setContentClassName(newContentClassName || "");
setDescriptionClassName(newDescriptionClassName || "");
};
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(
floatingTooltipVariants({ variant, size }),
className,
)}
style={{
scaleX,
scaleY,
skewX,
skewY,
}}
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={cn(
"whitespace-nowrap font-semibold",
contentClassName,
)}
>
{content}
</span>
{description && (
<span
className={cn(
"max-w-[28ch] whitespace-normal text-sm leading-snug font-normal opacity-70",
descriptionClassName,
)}
>
{description}
</span>
)}
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</TooltipContext.Provider>
);
}
export function FloatingTooltipTrigger({
children,
content,
description,
contentClassName,
descriptionClassName,
}: {
children: React.ReactNode;
content: string;
description?: string;
contentClassName?: string;
descriptionClassName?: 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, contentClassName, descriptionClassName);
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 variant="default" size="md">
<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.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Content to render |
className | string | — | Extra classes applied to the tooltip bubble |
variant | "default" | "outline" | "default" | Tooltip visual style |
size | "md" | "lg" | "md" | Tooltip padding, text size, and radius |
FloatingTooltip.Trigger
Attach this around any element to make it show the tooltip on hover.
| Prop | Type | Default | Description |
|---|---|---|---|
content | string | — | Main label shown in the tooltip |
description | string | — | Optional secondary text shown below the label |
children | ReactNode | — | The element that triggers the tooltip |
Built by Léo
Last updated: 7/6/2026