Components

Animated Tooltip

Interactive tooltips with smooth animations, multiple placements, and various content types.

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