Tooltip
Explain UI elements on hover. Keep interface minimal. Educate users.
Net MRR
$18,240
+6.4% vs February
1'use client';23import { Tooltip } from '@/components/ui/tooltip';4import { InfoIcon } from '@/components/ui/info';56export function Default() {7 return (8 <div className="border-border bg-card w-full max-w-xs rounded-xl border p-4">9 <div className="flex items-center gap-1.5">10 <p className="text-muted-foreground text-xs">Net MRR</p>11 <Tooltip>12 <Tooltip.Trigger asChild>13 <button14 type="button"15 aria-label="How net MRR is calculated"16 className="text-muted-foreground hover:text-foreground transition-colors"17 >18 <InfoIcon size={14} />19 </button>20 </Tooltip.Trigger>21 <Tooltip.Content className="w-56 whitespace-normal">22 New and expansion revenue minus churn and downgrades. One time charges, taxes and23 refunds are left out.24 </Tooltip.Content>25 </Tooltip>26 </div>27 <p className="mt-1 text-xl font-semibold tracking-tight">$18,240</p>28 <p className="text-success-text mt-1 text-xs">+6.4% vs February</p>29 </div>30 );31}
Installation
Copy and paste the following code into your project.
1'use client';23import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion } from 'motion/react';4import * as React from 'react';5import { cn } from '../lib/cn';6import { FloatingPortal, useFloating } from '../lib/floating';7import { floatingOrigin, floatingVariants, reveal } from '../lib/motion';8import { assignRef, getFocusable, useLayer } from '../lib/overlay';9import { useControllableState } from '../lib/use-controllable-state';1011const TOOLTIP_ARROW_CLASSES = {12 top: '-bottom-1 left-1/2 -translate-x-1/2',13 bottom: '-top-1 left-1/2 -translate-x-1/2',14 left: '-right-1 top-1/2 -translate-y-1/2',15 right: '-left-1 top-1/2 -translate-y-1/2',16} as const;1718const TOOLTIP_STYLE = { willChange: 'opacity, transform, filter' } as const;1920const CLOSE_DELAY = 100;21const LONG_PRESS_DELAY = 500;22const ARROW_INSET = 8;2324type TooltipSide = 'top' | 'bottom' | 'left' | 'right';2526interface TooltipContextType {27 open: boolean;28 setOpen: (open: boolean) => void;29 delayDuration: number;30 id: string;31 triggerRef: React.RefObject<HTMLElement | null>;32 contentRef: React.RefObject<HTMLDivElement | null>;33 schedule: (open: boolean, delay: number) => void;34 cancel: () => void;35}3637const TooltipContext = React.createContext<TooltipContextType | undefined>(undefined);3839const useTooltip = () => {40 const context = React.use(TooltipContext);41 if (!context) {42 throw new Error('useTooltip must be used within a TooltipProvider');43 }44 return context;45};4647interface TooltipProps {48 children: React.ReactNode;49 delayDuration?: number;50 defaultOpen?: boolean;51 open?: boolean;52 onOpenChange?: (open: boolean) => void;53}5455const TooltipRoot = ({56 children,57 delayDuration = 200,58 defaultOpen = false,59 open: controlledOpen,60 onOpenChange,61}: TooltipProps) => {62 const [open, setOpen] = useControllableState({63 value: controlledOpen,64 defaultValue: defaultOpen,65 onChange: onOpenChange,66 });6768 const id = React.useId();69 const triggerRef = React.useRef<HTMLElement>(null);70 const contentRef = React.useRef<HTMLDivElement>(null);71 const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);72 const layer = useLayer(open, false, contentRef);7374 const cancel = React.useCallback(() => {75 if (timerRef.current) clearTimeout(timerRef.current);76 timerRef.current = null;77 }, []);7879 const schedule = React.useCallback(80 (next: boolean, delay: number) => {81 cancel();82 timerRef.current = setTimeout(() => {83 timerRef.current = null;84 setOpen(next);85 }, delay);86 },87 [cancel, setOpen],88 );8990 React.useEffect(() => cancel, [cancel]);9192 React.useEffect(() => {93 if (!open) return;94 const handlePointerDown = (event: PointerEvent) => {95 const target = event.target as Node | null;96 if (!target) return;97 if (triggerRef.current?.contains(target) || contentRef.current?.contains(target)) return;98 cancel();99 setOpen(false);100 };101 const handleEscape = (event: KeyboardEvent) => {102 if (event.key !== 'Escape' || event.defaultPrevented || !layer.isTopmost()) return;103 event.preventDefault();104 cancel();105 setOpen(false);106 };107 document.addEventListener('pointerdown', handlePointerDown);108 document.addEventListener('keydown', handleEscape);109 return () => {110 document.removeEventListener('pointerdown', handlePointerDown);111 document.removeEventListener('keydown', handleEscape);112 };113 }, [open, setOpen, cancel, layer]);114115 const contextValue = React.useMemo(116 () => ({ open, setOpen, delayDuration, id, triggerRef, contentRef, schedule, cancel }),117 [open, setOpen, delayDuration, id, schedule, cancel],118 );119120 return (121 <TooltipContext value={contextValue}>122 <span className="relative inline-flex h-fit w-fit items-center justify-center">123 {children}124 </span>125 </TooltipContext>126 );127};128129interface TooltipTriggerProps extends React.HTMLAttributes<HTMLElement> {130 asChild?: boolean;131}132133type TriggerHandlers = {134 onPointerEnter?: (event: React.PointerEvent<HTMLElement>) => void;135 onPointerLeave?: (event: React.PointerEvent<HTMLElement>) => void;136 onPointerDown?: (event: React.PointerEvent<HTMLElement>) => void;137 onPointerUp?: (event: React.PointerEvent<HTMLElement>) => void;138 onPointerCancel?: (event: React.PointerEvent<HTMLElement>) => void;139 onClick?: (event: React.MouseEvent<HTMLElement>) => void;140 onFocus?: (event: React.FocusEvent<HTMLElement>) => void;141 onBlur?: (event: React.FocusEvent<HTMLElement>) => void;142};143144function joinIds(...ids: Array<string | undefined>) {145 return ids.filter(Boolean).join(' ') || undefined;146}147148function TooltipTrigger({ children, asChild = false, className, ...props }: TooltipTriggerProps) {149 const { open, setOpen, delayDuration, id, triggerRef, schedule, cancel } = useTooltip();150 const pointerTypeRef = React.useRef<string | null>(null);151 const [nestedTarget, setNestedTarget] = React.useState<HTMLElement | null>(null);152153 const wrapperRef = React.useCallback(154 (node: HTMLSpanElement | null) => {155 triggerRef.current = node;156 setNestedTarget(node ? (getFocusable(node)[0] ?? null) : null);157 },158 [triggerRef],159 );160161 React.useEffect(() => {162 if (!open || !nestedTarget) return;163 const previous = nestedTarget.getAttribute('aria-describedby');164 nestedTarget.setAttribute('aria-describedby', previous ? `${previous} ${id}` : id);165 return () => {166 if (previous === null) nestedTarget.removeAttribute('aria-describedby');167 else nestedTarget.setAttribute('aria-describedby', previous);168 };169 }, [open, nestedTarget, id]);170171 const slotted = asChild && React.isValidElement(children);172 const childProps = (slotted ? (children as React.ReactElement).props : {}) as TriggerHandlers & {173 className?: string;174 ref?: React.Ref<HTMLElement>;175 'aria-describedby'?: string;176 };177178 const handlers: TriggerHandlers = {179 onPointerEnter: (event) => {180 if (event.pointerType !== 'touch') schedule(true, delayDuration);181 props.onPointerEnter?.(event);182 childProps.onPointerEnter?.(event);183 },184 onPointerLeave: (event) => {185 if (event.pointerType !== 'touch') schedule(false, CLOSE_DELAY);186 else if (!open) cancel();187 props.onPointerLeave?.(event);188 childProps.onPointerLeave?.(event);189 },190 onPointerDown: (event) => {191 pointerTypeRef.current = event.pointerType;192 if (event.pointerType === 'touch') schedule(true, LONG_PRESS_DELAY);193 props.onPointerDown?.(event);194 childProps.onPointerDown?.(event);195 },196 onPointerUp: (event) => {197 if (event.pointerType === 'touch' && !open) cancel();198 props.onPointerUp?.(event);199 childProps.onPointerUp?.(event);200 },201 onPointerCancel: (event) => {202 if (event.pointerType === 'touch' && !open) cancel();203 props.onPointerCancel?.(event);204 childProps.onPointerCancel?.(event);205 },206 onClick: (event) => {207 pointerTypeRef.current = null;208 props.onClick?.(event);209 childProps.onClick?.(event);210 },211 onFocus: (event) => {212 if (pointerTypeRef.current !== 'touch') {213 cancel();214 setOpen(true);215 }216 props.onFocus?.(event);217 childProps.onFocus?.(event);218 },219 onBlur: (event) => {220 cancel();221 setOpen(false);222 props.onBlur?.(event);223 childProps.onBlur?.(event);224 },225 };226227 if (slotted) {228 const baseDescribedBy = childProps['aria-describedby'] ?? props['aria-describedby'];229230 return React.cloneElement(children as React.ReactElement<Record<string, unknown>>, {231 ...props,232 ...handlers,233 'aria-describedby': open ? joinIds(baseDescribedBy, id) : baseDescribedBy,234 className: cn(className, childProps.className),235 ref: (node: HTMLElement | null) => {236 triggerRef.current = node;237 assignRef(childProps.ref, node);238 },239 });240 }241242 const describedBy =243 open && !nestedTarget ? joinIds(props['aria-describedby'], id) : props['aria-describedby'];244245 return (246 <span247 tabIndex={nestedTarget ? undefined : 0}248 className={cn('cursor-pointer', className)}249 {...props}250 {...handlers}251 aria-describedby={describedBy}252 ref={wrapperRef}253 >254 {children}255 </span>256 );257}258259interface TooltipContentProps extends HTMLMotionProps<'div'> {260 side?: TooltipSide;261 sideOffset?: number;262 children?: React.ReactNode;263}264265const TooltipPanel = ({266 side: preferredSide = 'top',267 sideOffset = 4,268 className,269 children,270 style,271 onPointerEnter,272 onPointerLeave,273 ...props274}: TooltipContentProps) => {275 const { id, triggerRef, contentRef, schedule, cancel } = useTooltip();276 const shouldReduceMotion = useReducedMotion();277 const arrowRef = React.useRef<HTMLSpanElement>(null);278 const floating = useFloating({279 open: true,280 anchorRef: triggerRef,281 floatingRef: contentRef,282 side: preferredSide,283 sideOffset,284 });285 const side = floating.side;286 const top = floating.style.top;287 const left = floating.style.left;288289 React.useLayoutEffect(() => {290 const arrow = arrowRef.current;291 const content = contentRef.current;292 const anchor = triggerRef.current;293 if (!arrow || !content || !anchor) return;294 const rect = anchor.getBoundingClientRect();295 const vertical = side === 'top' || side === 'bottom';296 const size = vertical ? content.offsetWidth : content.offsetHeight;297 const center = vertical298 ? rect.left + rect.width / 2 - Number(left)299 : rect.top + rect.height / 2 - Number(top);300 const offset = Math.min(301 Math.max(center, ARROW_INSET),302 Math.max(ARROW_INSET, size - ARROW_INSET),303 );304 arrow.style.left = vertical ? `${offset}px` : '';305 arrow.style.top = vertical ? '' : `${offset}px`;306 }, [side, top, left, contentRef, triggerRef]);307308 return (309 <motion.div310 ref={contentRef}311 id={id}312 role="tooltip"313 data-side={side}314 variants={shouldReduceMotion ? reveal : floatingVariants[side]}315 initial="hidden"316 animate="visible"317 exit="exit"318 style={{ ...TOOLTIP_STYLE, ...style, ...floating.style }}319 onPointerEnter={(event) => {320 onPointerEnter?.(event);321 cancel();322 }}323 onPointerLeave={(event) => {324 onPointerLeave?.(event);325 if (event.pointerType !== 'touch') schedule(false, CLOSE_DELAY);326 }}327 className={cn(328 'bg-foreground text-background z-500 w-max max-w-[min(20rem,calc(100vw-2rem))] rounded-sm px-2.5 py-1 text-xs whitespace-normal',329 floatingOrigin[side],330 className,331 )}332 {...props}333 >334 <span335 ref={arrowRef}336 aria-hidden="true"337 className={cn(338 'bg-foreground absolute size-2 rotate-45 rounded-[1px]',339 TOOLTIP_ARROW_CLASSES[side],340 )}341 />342 {children as React.ReactNode}343 </motion.div>344 );345};346347const TooltipContent = (props: TooltipContentProps) => {348 const { open } = useTooltip();349350 return (351 <FloatingPortal>352 <AnimatePresence>{open && <TooltipPanel {...props} />}</AnimatePresence>353 </FloatingPortal>354 );355};356357const Tooltip = Object.assign(TooltipRoot, {358 Trigger: TooltipTrigger,359 Content: TooltipContent,360});361362const TooltipProvider = Tooltip;363364export { Tooltip, TooltipProvider };365export type { TooltipProps, TooltipTriggerProps, TooltipContentProps, TooltipSide };
Update the import paths to match your project structure.
Anatomy
1import { Tooltip } from '@/components/ui/tooltip';
1<Tooltip>2 <Tooltip.Trigger asChild>3 <Button variant="outline">Hover me</Button>4 </Tooltip.Trigger>5 <Tooltip.Content>6 <p>Helpful tooltip text</p>7 </Tooltip.Content>8</Tooltip>
The tooltip opens after
delayDuration on hover and right away on keyboard focus. You can move the pointer onto it without it closing, and Escape closes it without closing a dialog or sheet around it. It renders in a portal, so a parent with overflow: hidden never clips it, and it flips and shifts to stay on screen.On touch screens a long press opens it and the next tap outside closes it. Since most people will never long press, a tooltip should add detail, not be the only label: give icon buttons an
aria-label too.Use
asChild to attach the tooltip to your own button. Without it the trigger wraps its children in a span, which is focusable for plain text and steps aside when the child is already focusable, so you never get two tab stops.Composition
Use the following composition to build a
Tooltip:Tooltip├── Tooltip.Trigger└── Tooltip.Content
Features
- Multiple positions - Top, bottom, left, or right placement
- Delay control - Configurable hover delay
- Smooth animations - Fade and blur entrance effects
- Arrow indicator - Visual pointer to the trigger element
Variants
Positions
1'use client';23import { Tooltip } from '@/components/ui/tooltip';4import { HistoryIcon } from '@/components/ui/history';5import { MessageCircleIcon } from '@/components/ui/message-circle';6import { ShareIcon } from '@/components/ui/share';7import { TrashIcon } from '@/components/ui/trash';89const actions = [10 {11 side: 'top',12 icon: MessageCircleIcon,13 label: 'Comment on selection',14 hint: 'Comment on selection (⇧⌘M)',15 },16 {17 side: 'bottom',18 icon: HistoryIcon,19 label: 'Version history',20 hint: 'Version history, last saved 4m ago',21 },22 {23 side: 'left',24 icon: ShareIcon,25 label: 'Share document',26 hint: 'Shared with 3 people in Northwind',27 },28 {29 side: 'right',30 icon: TrashIcon,31 label: 'Move to trash',32 hint: 'Move to trash, recoverable for 30 days',33 },34] as const;3536export function Positions() {37 return (38 <div className="border-border bg-card flex flex-col items-center gap-10 rounded-xl border px-6 py-4 md:flex-row">39 {actions.map((action) => (40 <Tooltip key={action.side}>41 <Tooltip.Trigger asChild>42 <button43 type="button"44 aria-label={action.label}45 className="text-muted-foreground hover:bg-muted hover:text-foreground flex size-9 items-center justify-center rounded-md transition-colors"46 >47 <action.icon size={18} strokeWidth={1.6} />48 </button>49 </Tooltip.Trigger>50 <Tooltip.Content side={action.side}>{action.hint}</Tooltip.Content>51 </Tooltip>52 ))}53 </div>54 );55}
API Reference
Tooltip
| Prop | Type | Default | Description |
|---|---|---|---|
delayDuration | number | 200 | Delay before showing (ms) |
defaultOpen | boolean | false | Open state on first render when uncontrolled |
open | boolean | - | Controlled open state |
onOpenChange | (open: boolean) => void | - | Callback when state changes |
Tooltip.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render as child element |
className | string | - | Additional CSS classes |
Tooltip.Content
| Prop | Type | Default | Description |
|---|---|---|---|
side | 'top' | 'bottom' | 'left' | 'right' | 'top' | Tooltip position |
sideOffset | number | 4 | Distance from trigger (px) |
className | string | - | Additional CSS classes |
Variants
Different Positions
1// Top (default)2<Tooltip>3 <Tooltip.Trigger asChild>4 <Button>Top</Button>5 </Tooltip.Trigger>6 <Tooltip.Content side="top">7 <p>Tooltip on top</p>8 </Tooltip.Content>9</Tooltip>1011// Bottom12<Tooltip>13 <Tooltip.Trigger asChild>14 <Button>Bottom</Button>15 </Tooltip.Trigger>16 <Tooltip.Content side="bottom">17 <p>Tooltip on bottom</p>18 </Tooltip.Content>19</Tooltip>
Custom Delay
1<Tooltip delayDuration={500}>2 <Tooltip.Trigger asChild>3 <Button>Slow tooltip</Button>4 </Tooltip.Trigger>5 <Tooltip.Content>6 <p>This appears after 500ms</p>7 </Tooltip.Content>8</Tooltip>
Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.