Tooltip Card

A cursor-tracking tooltip card with spring animation, automatic viewport edge flipping, and touch support.

Tooltip Card

A self-positioning tooltip card that tracks your cursor, flips sides when it would overflow the viewport edges, and springs open with a smooth height animation. It works with both mouse and touch.

Installation

npm install framer-motion lucide-react react
pnpm install framer-motion lucide-react react
yarn add framer-motion lucide-react react
bun add framer-motion lucide-react react

Copy the Tooltip Card Component

"use client";import React, { useState, useRef, useEffect } from "react";import { motion, AnimatePresence } from "framer-motion";import { cn } from "@/lib/utils";import { Button } from "@/components/ui/button";import { HelpCircle } from "lucide-react";export const Tooltip = ({  content,  children,  containerClassName,}: {  content: string | React.ReactNode;  children: React.ReactNode;  containerClassName?: string;}) => {  const [isVisible, setIsVisible] = useState(false);  const [mouse, setMouse] = useState<{ x: number; y: number }>({ x: 0, y: 0 });  const [height, setHeight] = useState(0);  const [position, setPosition] = useState<{ x: number; y: number }>({    x: 0,    y: 0,  });  const contentRef = useRef<HTMLDivElement>(null);  const containerRef = useRef<HTMLDivElement>(null);  useEffect(() => {    if (isVisible && contentRef.current) {      setHeight(contentRef.current.scrollHeight);    }  }, [isVisible, content]);  const calculatePosition = (mouseX: number, mouseY: number) => {    if (!contentRef.current || !containerRef.current)      return { x: mouseX + 12, y: mouseY + 12 };    const tooltip = contentRef.current;    const container = containerRef.current;    const containerRect = container.getBoundingClientRect();    const viewportWidth = window.innerWidth;    const viewportHeight = window.innerHeight;    // Get tooltip dimensions    const tooltipWidth = 240; // min-w-[15rem] = 240px    const tooltipHeight = tooltip.scrollHeight;    // Calculate absolute position relative to viewport    const absoluteX = containerRect.left + mouseX;    const absoluteY = containerRect.top + mouseY;    let finalX = mouseX + 12;    let finalY = mouseY + 12;    // Check if tooltip goes beyond right edge    if (absoluteX + 12 + tooltipWidth > viewportWidth) {      finalX = mouseX - tooltipWidth - 12;    }    // Check if tooltip goes beyond left edge    if (absoluteX + finalX < 0) {      finalX = -containerRect.left + 12;    }    // Check if tooltip goes beyond bottom edge    if (absoluteY + 12 + tooltipHeight > viewportHeight) {      finalY = mouseY - tooltipHeight - 12;    }    // Check if tooltip goes beyond top edge    if (absoluteY + finalY < 0) {      finalY = -containerRect.top + 12;    }    return { x: finalX, y: finalY };  };  const updateMousePosition = (mouseX: number, mouseY: number) => {    setMouse({ x: mouseX, y: mouseY });    const newPosition = calculatePosition(mouseX, mouseY);    setPosition(newPosition);  };  const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {    setIsVisible(true);    const rect = e.currentTarget.getBoundingClientRect();    const mouseX = e.clientX - rect.left;    const mouseY = e.clientY - rect.top;    updateMousePosition(mouseX, mouseY);  };  const handleMouseLeave = () => {    setMouse({ x: 0, y: 0 });    setPosition({ x: 0, y: 0 });    setIsVisible(false);  };  const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {    if (!isVisible) return;    const rect = e.currentTarget.getBoundingClientRect();    const mouseX = e.clientX - rect.left;    const mouseY = e.clientY - rect.top;    updateMousePosition(mouseX, mouseY);  };  const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {    const touch = e.touches[0];    const rect = e.currentTarget.getBoundingClientRect();    const mouseX = touch.clientX - rect.left;    const mouseY = touch.clientY - rect.top;    updateMousePosition(mouseX, mouseY);    setIsVisible(true);  };  const handleTouchEnd = () => {    // Delay hiding to allow for tap interaction    setTimeout(() => {      setIsVisible(false);      setMouse({ x: 0, y: 0 });      setPosition({ x: 0, y: 0 });    }, 2000);  };  const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {    // Toggle visibility on click for mobile devices    if (window.matchMedia("(hover: none)").matches) {      e.preventDefault();      if (isVisible) {        setIsVisible(false);        setMouse({ x: 0, y: 0 });        setPosition({ x: 0, y: 0 });      } else {        const rect = e.currentTarget.getBoundingClientRect();        const mouseX = e.clientX - rect.left;        const mouseY = e.clientY - rect.top;        updateMousePosition(mouseX, mouseY);        setIsVisible(true);      }    }  };  // Update position when tooltip becomes visible or content changes  useEffect(() => {    if (isVisible && contentRef.current) {      const newPosition = calculatePosition(mouse.x, mouse.y);      setPosition(newPosition);    }  }, [isVisible, height, mouse.x, mouse.y]);  return (    <div      ref={containerRef}      className={cn("relative inline-block", containerClassName)}      onMouseEnter={handleMouseEnter}      onMouseLeave={handleMouseLeave}      onMouseMove={handleMouseMove}      onTouchStart={handleTouchStart}      onTouchEnd={handleTouchEnd}      onClick={handleClick}    >      {children}      <AnimatePresence>        {isVisible && (          <motion.div            key={String(isVisible)}            initial={{ height: 0, opacity: 1 }}            animate={{ height, opacity: 1 }}            exit={{ height: 0, opacity: 0 }}            transition={{              type: "spring",              stiffness: 200,              damping: 20,            }}            className="pointer-events-none absolute z-50 min-w-[15rem] overflow-hidden rounded-md border-border bg-card text-foreground shadow-theme-md"            style={{              top: position.y,              left: position.x,            }}          >            <div              ref={contentRef}              className="p-2 text-sm text-muted-foreground md:p-4"            >              {content}            </div>          </motion.div>        )}      </AnimatePresence>    </div>  );};export default function TooltipCard() {  return (    <div className="flex w-full items-center justify-center px-6 py-16">      <Tooltip        containerClassName="inline-flex justify-center"        content={          <div className="space-y-1">            <p className="font-medium text-foreground text-sm">              What is this?            </p>            <p>              A tooltip that tracks your cursor, flips sides near the edges of              the viewport, and works on touch devices.            </p>          </div>        }      >        <Button          className="gap-2"          variant="secondary"          aria-describedby="tooltip-content"        >          <HelpCircle className="h-4 w-4 text-primary" />          Hover me        </Button>      </Tooltip>    </div>  );}

Usage

Tooltip is exported as a named export that wraps any trigger element.

"use client";

import { Tooltip } from "@/components/mvpblocks/cards/tooltip-card/tooltip-card";

export default function MyComponent() {
  return (
    <Tooltip content={<p>This is a tooltip.</p>}>
      <button>Hover me</button>
    </Tooltip>
  );
}

The content prop accepts either a plain string or any React node, so you can render rich content like headings, lists, or buttons inside the tooltip.

<Tooltip
  content={
    <div className="space-y-1">
      <p className="font-medium text-sm text-foreground">Details</p>
      <p className="text-sm text-muted-foreground">
        Pricing, availability, and more.
      </p>
    </div>
  }
>
  <button>Learn more</button>
</Tooltip>

Props

PropTypeDescription
contentstring | React.ReactNodeContent rendered inside the tooltip
childrenReact.ReactNodeThe trigger element the tooltip is attached to
containerClassNamestringOptional classes applied to the wrapper element

Features

  • Edge Flipping: Automatically flips to the opposite side when the tooltip would overflow the viewport (left, right, top, and bottom edges)
  • Spring Animation: Opens and closes with a spring-driven height and opacity transition
  • Cursor Tracking: The tooltip follows the cursor within the trigger on mouse move
  • Touch Support: Tap triggers the tooltip, and a delayed hide keeps it readable for 2 seconds
  • Themed: Uses the current design tokens (card surface, border, foreground/muted text, primary accents)

Last updated on