Tooltip Card A cursor-tracking tooltip card with spring animation, automatic viewport edge flipping, and touch support.
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.
Preview Code
npx mvpblocks add tooltip-card Open in
npm pnpm yarn bun
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 "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> ); } Expand
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 >
Prop Type Description contentstring | React.ReactNodeContent rendered inside the tooltip childrenReact.ReactNodeThe trigger element the tooltip is attached to containerClassNamestringOptional classes applied to the wrapper element
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)