ComponentsFeedback

Animated Tooltip

Animated tooltip component for React. Spring animations, 12 placement options, and custom content support. Built on Radix Tooltip primitive.

Last updated on

Edit on GitHub

Placement Options

"use client";
 
import { AnimatedTooltip } from "@/components/ui/animated-tooltip";
 
export function AnimatedTooltipDemo() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-4">
      {(["top", "bottom", "left", "right"] as const).map((placement) => (
        <AnimatedTooltip
          key={placement}
          content={`Tooltip on ${placement}`}
          placement={placement}
          animation="slide"
        >
          <button className="rounded-lg border bg-card px-4 py-2 font-medium text-sm capitalize transition-colors hover:bg-accent">
            {placement}
          </button>
        </AnimatedTooltip>
      ))}
    </div>
  );
}

Animation Types

"use client";
 
import { AnimatedTooltip } from "@/components/ui/animated-tooltip";
 
export function AnimatedTooltipAnimationDemo() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-4">
      {(["fade", "scale", "slide", "spring"] as const).map((animation) => (
        <AnimatedTooltip
          key={animation}
          content={`${animation} animation`}
          animation={animation}
        >
          <button className="rounded-lg border bg-card px-4 py-2 font-medium text-sm capitalize transition-colors hover:bg-accent">
            {animation}
          </button>
        </AnimatedTooltip>
      ))}
    </div>
  );
}

Icon Tooltips with Shortcuts

"use client";
 
import { Home, Mail, Search, Settings, User } from "lucide-react";
import { TooltipGroup } from "@/components/ui/animated-tooltip";
 
export function AnimatedTooltipIconDemo() {
  return (
    <div className="flex justify-center">
      <TooltipGroup
        items={[
          { icon: <Home className="h-4 w-4" />, label: "Home", shortcut: "H" },
          {
            icon: <Search className="h-4 w-4" />,
            label: "Search",
            shortcut: "⌘K",
          },
          {
            icon: <Mail className="h-4 w-4" />,
            label: "Messages",
            shortcut: "M",
          },
          {
            icon: <Settings className="h-4 w-4" />,
            label: "Settings",
            shortcut: "S",
          },
          {
            icon: <User className="h-4 w-4" />,
            label: "Profile",
            shortcut: "P",
          },
        ]}
      />
    </div>
  );
}

Status Tooltips

"use client";
 
import { StatusTooltip } from "@/components/ui/animated-tooltip";
import { cn } from "@/lib/utils";
 
export function AnimatedTooltipStatusDemo() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-6">
      {(["online", "away", "busy", "offline"] as const).map((status) => (
        <StatusTooltip key={status} status={status}>
          <div className="flex items-center gap-2 rounded-lg border bg-card px-3 py-2">
            <div
              className={cn(
                "h-2.5 w-2.5 rounded-full",
                status === "online" && "bg-green-500",
                status === "away" && "bg-yellow-500",
                status === "busy" && "bg-red-500",
                status === "offline" && "bg-gray-400",
              )}
            />
            <span className="font-medium text-sm capitalize">{status}</span>
          </div>
        </StatusTooltip>
      ))}
    </div>
  );
}

Rich Tooltip with Image

Open in
"use client";
 
import { RichTooltip } from "@/components/ui/animated-tooltip";
 
export function AnimatedTooltipRichDemo() {
  return (
    <div className="flex justify-center">
      <RichTooltip
        title="Mountain Vista"
        description="A beautiful landscape view from the top of the mountain during golden hour."
        image="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=300&h=150&fit=crop"
        placement="bottom"
      >
        <button className="rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground transition-colors hover:bg-primary/90">
          Hover for Rich Tooltip
        </button>
      </RichTooltip>
    </div>
  );
}

Installation

CLI

npx shadcn@latest add "https://jolyui.dev/r/animated-tooltip"

Manual

Install the following dependencies:

npm install motion

Copy and paste the following code into your project. component/ui/animated-tooltip.tsx

"use client";
import { AnimatePresence, motion } from "motion/react";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
 
type Placement =
  | "top"
  | "bottom"
  | "left"
  | "right"
  | "top-start"
  | "top-end"
  | "bottom-start"
  | "bottom-end";
type Animation = "fade" | "scale" | "slide" | "spring";
 
// Animated Tooltip
interface AnimatedTooltipProps {
  children: React.ReactNode;
  content: React.ReactNode;
  placement?: Placement;
  animation?: Animation;
  delay?: number;
  duration?: number;
  className?: string;
  contentClassName?: string;
  arrow?: boolean;
  offset?: number;
  disabled?: boolean;
}
 
export function AnimatedTooltip({
  children,
  content,
  placement = "top",
  animation = "fade",
  delay = 0,
  duration = 0.15,
  className,
  contentClassName,
  arrow = true,
  offset = 8,
  disabled = false,
}: AnimatedTooltipProps) {
  const [isVisible, setIsVisible] = useState(false);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const triggerRef = useRef<HTMLButtonElement>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
 
  const calculatePosition = useCallback(() => {
    if (!triggerRef.current || !tooltipRef.current) return;
 
    const triggerRect = triggerRef.current.getBoundingClientRect();
    const tooltipRect = tooltipRef.current.getBoundingClientRect();
 
    let x = 0;
    let y = 0;
 
    switch (placement) {
      case "top":
        x = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
        y = triggerRect.top - tooltipRect.height - offset;
        break;
      case "top-start":
        x = triggerRect.left;
        y = triggerRect.top - tooltipRect.height - offset;
        break;
      case "top-end":
        x = triggerRect.right - tooltipRect.width;
        y = triggerRect.top - tooltipRect.height - offset;
        break;
      case "bottom":
        x = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
        y = triggerRect.bottom + offset;
        break;
      case "bottom-start":
        x = triggerRect.left;
        y = triggerRect.bottom + offset;
        break;
      case "bottom-end":
        x = triggerRect.right - tooltipRect.width;
        y = triggerRect.bottom + offset;
        break;
      case "left":
        x = triggerRect.left - tooltipRect.width - offset;
        y = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
        break;
      case "right":
        x = triggerRect.right + offset;
        y = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
        break;
    }
 
    // Keep tooltip within viewport
    x = Math.max(8, Math.min(x, window.innerWidth - tooltipRect.width - 8));
    y = Math.max(8, Math.min(y, window.innerHeight - tooltipRect.height - 8));
 
    setPosition({ x, y });
  }, [placement, offset]);
 
  useEffect(() => {
    if (isVisible) {
      calculatePosition();
      window.addEventListener("scroll", calculatePosition);
      window.addEventListener("resize", calculatePosition);
    }
    return () => {
      window.removeEventListener("scroll", calculatePosition);
      window.removeEventListener("resize", calculatePosition);
    };
  }, [isVisible, calculatePosition]);
 
  const handleMouseEnter = () => {
    if (disabled) return;
    timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
  };
 
  const handleMouseLeave = () => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    setIsVisible(false);
  };
 
  const getAnimationVariants = () => {
    const baseDirection = placement.split("-")[0];
 
    switch (animation) {
      case "scale":
        return {
          hidden: { opacity: 0, scale: 0.8 },
          visible: { opacity: 1, scale: 1 },
        };
      case "slide": {
        const slideOffset = 10;
        const slideVariants = {
          top: {
            hidden: { opacity: 0, y: slideOffset },
            visible: { opacity: 1, y: 0 },
          },
          bottom: {
            hidden: { opacity: 0, y: -slideOffset },
            visible: { opacity: 1, y: 0 },
          },
          left: {
            hidden: { opacity: 0, x: slideOffset },
            visible: { opacity: 1, x: 0 },
          },
          right: {
            hidden: { opacity: 0, x: -slideOffset },
            visible: { opacity: 1, x: 0 },
          },
        };
        return (
          slideVariants[baseDirection as keyof typeof slideVariants] ||
          slideVariants.top
        );
      }
      case "spring":
        return {
          hidden: { opacity: 0, scale: 0.5 },
          visible: { opacity: 1, scale: 1 },
        };
      default:
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1 },
        };
    }
  };
 
  const getArrowPosition = () => {
    const baseDirection = placement.split("-")[0];
    const alignment = placement.split("-")[1];
 
    const arrowClasses = {
      top: "bottom-0 left-1/2 -translate-x-1/2 translate-y-full border-t-foreground border-x-transparent border-b-transparent",
      bottom:
        "top-0 left-1/2 -translate-x-1/2 -translate-y-full border-b-foreground border-x-transparent border-t-transparent",
      left: "right-0 top-1/2 -translate-y-1/2 translate-x-full border-l-foreground border-y-transparent border-r-transparent",
      right:
        "left-0 top-1/2 -translate-y-1/2 -translate-x-full border-r-foreground border-y-transparent border-l-transparent",
    };
 
    let alignmentClass = "";
    if (alignment === "start") {
      alignmentClass =
        baseDirection === "top" || baseDirection === "bottom"
          ? "left-4 -translate-x-0"
          : "";
    } else if (alignment === "end") {
      alignmentClass =
        baseDirection === "top" || baseDirection === "bottom"
          ? "left-auto right-4 translate-x-0"
          : "";
    }
 
    return cn(
      arrowClasses[baseDirection as keyof typeof arrowClasses],
      alignmentClass,
    );
  };
 
  return (
    <>
      <button
        ref={triggerRef}
        className={cn("inline-block", className)}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        onFocus={handleMouseEnter}
        onBlur={handleMouseLeave}
        type="button"
      >
        {children}
      </button>
 
      <AnimatePresence>
        {isVisible && (
          <motion.div
            ref={tooltipRef}
            className={cn(
              "fixed z-50 max-w-xs rounded-md bg-foreground px-3 py-1.5 text-background text-sm shadow-lg",
              contentClassName,
            )}
            style={{ left: position.x, top: position.y }}
            variants={getAnimationVariants()}
            initial="hidden"
            animate="visible"
            exit="hidden"
            transition={
              animation === "spring"
                ? { type: "spring", stiffness: 500, damping: 25 }
                : { duration }
            }
          >
            {content}
            {arrow && (
              <div
                className={cn("absolute h-0 w-0 border-4", getArrowPosition())}
              />
            )}
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
}
 
// Tooltip with Rich Content
interface RichTooltipProps {
  children: React.ReactNode;
  title: string;
  description?: string;
  image?: string;
  placement?: Placement;
  className?: string;
}
 
export function RichTooltip({
  children,
  title,
  description,
  image,
  placement = "top",
  className,
}: RichTooltipProps) {
  return (
    <AnimatedTooltip
      placement={placement}
      animation="scale"
      arrow={false}
      className={className}
      contentClassName="p-0 overflow-hidden max-w-[280px]"
      content={
        <div className="rounded-lg border bg-card text-card-foreground shadow-xl">
          {image && (
            <div className="h-32 w-full overflow-hidden">
              {/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
              <img
                src={image}
                alt={title}
                className="h-full w-full object-cover"
              />
            </div>
          )}
          <div className="p-3">
            <p className="font-semibold text-foreground">{title}</p>
            {description && (
              <p className="mt-1 text-muted-foreground text-sm">
                {description}
              </p>
            )}
          </div>
        </div>
      }
    >
      {children}
    </AnimatedTooltip>
  );
}
 
// Icon Tooltip (compact)
interface IconTooltipProps {
  children: React.ReactNode;
  label: string;
  placement?: Placement;
  shortcut?: string;
}
 
export function IconTooltip({
  children,
  label,
  placement = "top",
  shortcut,
}: IconTooltipProps) {
  return (
    <AnimatedTooltip
      placement={placement}
      animation="fade"
      delay={200}
      content={
        <div className="flex items-center gap-2">
          <span>{label}</span>
          {shortcut && (
            <kbd className="rounded bg-background/20 px-1.5 py-0.5 font-mono text-xs">
              {shortcut}
            </kbd>
          )}
        </div>
      }
    >
      {children}
    </AnimatedTooltip>
  );
}
 
// Hover Card (larger tooltip with delay)
interface HoverCardTooltipProps {
  children: React.ReactNode;
  content: React.ReactNode;
  placement?: Placement;
  className?: string;
}
 
export function HoverCardTooltip({
  children,
  content,
  placement = "bottom",
  className,
}: HoverCardTooltipProps) {
  return (
    <AnimatedTooltip
      placement={placement}
      animation="spring"
      delay={300}
      arrow={false}
      contentClassName={cn(
        "p-0 bg-card text-card-foreground border rounded-xl shadow-2xl max-w-sm",
        className,
      )}
      content={content}
    >
      {children}
    </AnimatedTooltip>
  );
}
 
// Confirmation Tooltip
interface ConfirmTooltipProps {
  children: React.ReactNode;
  message: string;
  onConfirm: () => void;
  onCancel?: () => void;
  placement?: Placement;
  confirmText?: string;
  cancelText?: string;
}
 
export function ConfirmTooltip({
  children,
  message,
  onConfirm,
  onCancel,
  placement: _placement = "top",
  confirmText = "Confirm",
  cancelText = "Cancel",
}: ConfirmTooltipProps) {
  const [isOpen, setIsOpen] = useState(false);
 
  const handleConfirm = () => {
    onConfirm();
    setIsOpen(false);
  };
 
  const handleCancel = () => {
    onCancel?.();
    setIsOpen(false);
  };
 
  return (
    <>
      <button
        type="button"
        onClick={() => setIsOpen(!isOpen)}
        className="inline-block cursor-pointer"
      >
        {children}
      </button>
 
      <AnimatePresence>
        {isOpen && (
          <>
            {/* Backdrop */}
            <motion.div
              className="fixed inset-0 z-40"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              onClick={handleCancel}
            />
 
            {/* Tooltip */}
            <motion.div
              className="fixed z-50 w-64 rounded-lg border bg-card p-4 shadow-xl"
              initial={{ opacity: 0, scale: 0.9 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.9 }}
              transition={{ type: "spring", stiffness: 400, damping: 25 }}
            >
              <p className="mb-3 text-sm">{message}</p>
              <div className="flex justify-end gap-2">
                <button
                  onClick={handleCancel}
                  className="rounded-md px-3 py-1.5 text-muted-foreground text-sm hover:bg-accent"
                >
                  {cancelText}
                </button>
                <button
                  onClick={handleConfirm}
                  className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-sm hover:bg-primary/90"
                >
                  {confirmText}
                </button>
              </div>
            </motion.div>
          </>
        )}
      </AnimatePresence>
    </>
  );
}
 
// Tooltip Group (for toolbar-style tooltips)
interface TooltipItem {
  icon: React.ReactNode;
  label: string;
  shortcut?: string;
  onClick?: () => void;
}
 
interface TooltipGroupProps {
  items: TooltipItem[];
  className?: string;
}
 
export function TooltipGroup({ items, className }: TooltipGroupProps) {
  return (
    <div
      className={cn(
        "inline-flex items-center gap-1 rounded-lg border bg-card p-1",
        className,
      )}
    >
      {items.map((item, index) => (
        <IconTooltip key={index} label={item.label} shortcut={item.shortcut}>
          <button
            onClick={item.onClick}
            className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
          >
            {item.icon}
          </button>
        </IconTooltip>
      ))}
    </div>
  );
}
 
// Floating Label (tooltip that stays visible on focus)
interface FloatingLabelProps {
  children: React.ReactNode;
  label: string;
  className?: string;
}
 
export function FloatingLabel({
  children,
  label,
  className,
}: FloatingLabelProps) {
  const [isFocused, setIsFocused] = useState(false);
 
  return (
    <div className={cn("relative", className)}>
      <AnimatePresence>
        {isFocused && (
          <motion.div
            className="-top-6 absolute left-0 font-medium text-primary text-xs"
            initial={{ opacity: 0, y: 5 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 5 }}
            transition={{ duration: 0.15 }}
          >
            {label}
          </motion.div>
        )}
      </AnimatePresence>
      <div
        onFocus={() => setIsFocused(true)}
        onBlur={() => setIsFocused(false)}
        role="button"
        tabIndex={0}
      >
        {children}
      </div>
    </div>
  );
}
 
// Status Tooltip (with colored indicator)
interface StatusTooltipProps {
  children: React.ReactNode;
  status: "online" | "offline" | "away" | "busy";
  label?: string;
  placement?: Placement;
}
 
export function StatusTooltip({
  children,
  status,
  label,
  placement = "top",
}: StatusTooltipProps) {
  const statusConfig = {
    online: { color: "bg-green-500", text: "Online" },
    offline: { color: "bg-gray-400", text: "Offline" },
    away: { color: "bg-yellow-500", text: "Away" },
    busy: { color: "bg-red-500", text: "Busy" },
  };
 
  const config = statusConfig[status];
 
  return (
    <AnimatedTooltip
      placement={placement}
      animation="fade"
      content={
        <div className="flex items-center gap-2">
          <div className={cn("h-2 w-2 rounded-full", config.color)} />
          <span>{label || config.text}</span>
        </div>
      }
    >
      {children}
    </AnimatedTooltip>
  );
}
 
export type {
  AnimatedTooltipProps,
  Animation,
  ConfirmTooltipProps,
  FloatingLabelProps,
  HoverCardTooltipProps,
  IconTooltipProps,
  Placement,
  RichTooltipProps,
  StatusTooltipProps,
  TooltipGroupProps,
};

API Reference

AnimatedTooltip

Prop

Type

RichTooltip

Prop

Type

IconTooltip

Prop

Type

HoverCardTooltip

Prop

Type

ConfirmTooltip

Prop

Type

TooltipGroup

Prop

Type

FloatingLabel

Prop

Type

StatusTooltip

Prop

Type

Notes

  • Tooltips automatically position themselves to stay within viewport bounds
  • All tooltips support keyboard navigation and accessibility features
  • Animation types include fade, scale, slide, and spring transitions
  • Rich tooltips support images, titles, and descriptions
  • Icon tooltips can display keyboard shortcuts
  • Status tooltips show online/offline/away/busy indicators
  • Tooltip groups are perfect for toolbar-style interfaces

How is this guide?

On this page