Tooltip
Tooltips are used to display information on demand.
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip';
export default function TooltipPreview() {
return (
<Tooltip>
<TooltipTrigger>Nothing to see here</TooltipTrigger>
<TooltipContent>Or is there?</TooltipContent>
</Tooltip>
);
} Dependencies
Source Code
'use client';
import type {
Placement,
UseFloatingOptions,
UseInteractionsReturn,
} from '@floating-ui/react';
import {
arrow,
autoUpdate,
FloatingArrow,
FloatingDelayGroup,
flip,
hide,
offset as offsetMiddleware,
safePolygon,
useDelayGroup,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
useMergeRefs,
useRole,
useTransitionStatus,
} from '@floating-ui/react';
import {
createContext,
use,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { Slot } from '@/components/slot';
import { useTopLayer } from '@/foundations/hooks/use-top-layer';
import { cn } from '@/lib/utils/classnames';
// Let's keep an eye on popover="hint", it might be able to handle the tooltip logic natively
// https://developer.mozilla.org/en-US/docs/Web/API/Popover_API/Using#using_hint_popover_state
const DEFAULT_DELAY_IN = 600;
const DEFAULT_DELAY_OUT = 0;
const ARROW_HEIGHT = 4;
const ARROW_WIDTH = 8;
const DEFAULT_GROUP_TIMEOUT_MS = 150;
interface UseTooltipFloatingOptions {
initialOpen?: boolean;
open?: boolean;
onOpenChange?: UseFloatingOptions['onOpenChange'];
placement?: Placement;
offset?: number;
delayIn?: number;
delayOut?: number;
disabled?: boolean;
persistOnClick?: boolean;
}
const useTooltipFloating = ({
initialOpen = false,
open: propsOpen,
onOpenChange: propsOnOpenChange,
placement = 'top',
offset = 4,
delayIn = DEFAULT_DELAY_IN,
delayOut = DEFAULT_DELAY_OUT,
disabled = false,
persistOnClick = false,
}: UseTooltipFloatingOptions) => {
const arrowRef = useRef<SVGSVGElement | null>(null);
const [internalOpen, setInternalOpen] = useState(initialOpen);
const open = propsOpen ?? internalOpen;
const setOpen = useCallback<NonNullable<UseFloatingOptions['onOpenChange']>>(
(open, event, reason) => {
setInternalOpen(open);
propsOnOpenChange?.(open, event, reason);
},
[propsOnOpenChange]
);
const floating = useFloating({
placement,
open,
onOpenChange: setOpen,
whileElementsMounted: (...args) =>
autoUpdate(...args, {
elementResize: true,
layoutShift: true,
}),
middleware: [
flip({ fallbackAxisSideDirection: 'start', padding: offset * 2 }),
offsetMiddleware(offset + ARROW_HEIGHT),
arrow({ element: arrowRef, padding: 8 }),
hide(),
],
});
return useMemo(
() => ({
open,
setOpen,
arrowRef,
delayIn,
delayOut,
disabled,
persistOnClick,
...floating,
}),
[open, setOpen, delayIn, delayOut, disabled, persistOnClick, floating]
);
};
// Context
interface TooltipContextType
extends ReturnType<typeof useTooltipFloating>,
UseInteractionsReturn {}
const TooltipContext = createContext<TooltipContextType | null>(null);
const useTooltipContext = () => {
const context = use(TooltipContext);
if (context == null) {
throw new Error('Tooltip components must be wrapped in <Tooltip />');
}
return context;
};
// Components
interface TooltipProps extends UseTooltipFloatingOptions {
children: React.ReactNode;
}
/**
* Tooltip component
*
* @example
* ```
* <Tooltip>
* <Tooltip.Trigger asChild>
* <Button>Hover me</Button>
* </Tooltip.Trigger>
* <Tooltip.Content>Tooltip content</Tooltip.Content>
* </Tooltip>
* ```
*/
const Tooltip = ({ children, ...props }: TooltipProps) => {
const floating = useTooltipFloating(props);
const ctx = floating.context;
const { delay: groupDelay } = useDelayGroup(ctx);
const hover = useHover(ctx, {
enabled: !floating.disabled,
move: false,
delay: {
open: typeof groupDelay === 'object' ? groupDelay.open : floating.delayIn,
close:
typeof groupDelay === 'object' ? groupDelay.close : floating.delayOut,
},
handleClose: safePolygon({}),
});
const focus = useFocus(ctx, {
enabled: !floating.disabled,
});
const dismiss = useDismiss(ctx, {
referencePress: !floating.persistOnClick,
});
const role = useRole(ctx, { role: 'tooltip' });
const interactions = useInteractions([hover, focus, dismiss, role]);
const tooltipContextValue = useMemo(
() => ({
...floating,
...interactions,
}),
[floating, interactions]
);
return (
<TooltipContext value={tooltipContextValue}>{children}</TooltipContext>
);
};
interface TooltipTriggerProps extends React.ComponentPropsWithRef<'button'> {
asChild?: boolean;
}
/**
* Will show the tooltip when hovered or focused.
*
* Use `asChild` to render as your child element.
*
* @example
* ```
* <Tooltip.Trigger asChild>
* <Button>Hover me</Button>
* </Tooltip.Trigger>
* ```
*/
const TooltipTrigger = ({
ref: refProp,
children,
asChild = false,
...props
}: TooltipTriggerProps) => {
const context = useTooltipContext();
const Comp = asChild ? Slot : 'button';
const ref = useMergeRefs([context.refs.setReference, refProp]);
return (
<Comp
ref={ref}
type={asChild ? undefined : 'button'}
{...context.getReferenceProps(props)}
>
{children}
</Comp>
);
};
/**
* Will render the tooltip content.
*
* @example
* ```
* <Tooltip.Content>
* Tooltip text here
* </Tooltip.Content>
* ```
*/
const TooltipContent = ({
ref: refProp,
className,
children,
...props
}: React.ComponentPropsWithRef<'div'>) => {
const { context, refs, arrowRef, getFloatingProps } = useTooltipContext();
const { isMounted, status } = useTransitionStatus(context, { duration: 0 });
const topLayerRef = useTopLayer<HTMLDivElement>(isMounted);
const ref = useMergeRefs([refs.setFloating, refProp, topLayerRef]);
if (!isMounted) return null;
return (
<div
ref={ref}
className={cn(
'z-50 max-w-80 overflow-visible whitespace-normal break-words rounded-lg bg-foreground px-3 py-1.5 text-background text-xs drop-shadow-md transition duration-300 ease-out-quint',
'data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2 data-[state=closed]:data-[side=bottom]:-translate-y-2 data-[state=closed]:data-[side=top]:translate-y-2',
'data-[state=closed]:scale-95 data-[state=closed]:opacity-0',
'data-[state=open]:translate-x-0 data-[state=open]:translate-y-0 data-[state=open]:scale-100',
context.middlewareData.hide?.referenceHidden && 'hidden',
className
)}
data-state={status === 'open' ? 'open' : 'closed'}
data-side={context.placement.split('-')[0]}
style={{
position: context.strategy,
top: context.y ?? 0,
left: context.x ?? 0,
...props.style,
}}
{...getFloatingProps(props)}
>
<FloatingArrow
ref={arrowRef}
context={context}
className="fill-foreground"
tipRadius={1}
height={ARROW_HEIGHT}
width={ARROW_WIDTH}
/>
{children}
</div>
);
};
interface TooltipGroupProps {
children: React.ReactNode;
delayIn?: number;
delayOut?: number;
timeoutMs?: number;
}
/**
* TooltipGroup allows you to group tooltips so they won't have delay when you move between them.
*
* This is very useful for navigation or toolbars where you want the first tooltip to have significant delay but moving to the next item should be instant so the user can scan all options.
*
* @example
* ```
* <Tooltip.Group>
* <div className="flex gap-2">
* {tools.map((tool) => (
* <Tooltip key={tool.id}>
* <Tooltip.Trigger asChild>
* <Button>{tool.icon}</Button>
* </Tooltip.Trigger>
* <Tooltip.Content>{tool.label}</Tooltip.Content>
* </Tooltip>
* ))}
* </div>
* </Tooltip.Group>
* ```
*/
const TooltipGroup = ({
delayIn = DEFAULT_DELAY_IN,
delayOut = DEFAULT_DELAY_OUT,
timeoutMs = DEFAULT_GROUP_TIMEOUT_MS,
children,
}: TooltipGroupProps) => {
return (
<FloatingDelayGroup
delay={{ open: delayIn, close: delayOut }}
timeoutMs={timeoutMs}
>
{children}
</FloatingDelayGroup>
);
};
export {
Tooltip,
TooltipContent,
TooltipGroup,
TooltipTrigger,
useTooltipContext,
}; Features
- Smart Positioning: Automatically adjusts position to stay in view
- Configurable Delays: Customizable show/hide delays to prevent flicker
- Group Support: Coordinated behavior when multiple tooltips are nearby
- Rich Content: Supports any React node as content
- Controlled Mode: Optional controlled state management
API Reference
Tooltip
Root component that manages tooltip state and provides context to child components.
| Prop | Default | Type | Description |
|---|---|---|---|
open | - | boolean | Used to control the tooltip's open state. |
onOpenChange | - | (open: boolean, event?: Event, reason?: OpenChangeReason) => void | Callback function called when the tooltip's open state changes. |
initialOpen | | boolean | Whether the tooltip is open by default. Useful when used uncontrolled. |
placement | "top" | Placement | The placement of the tooltip relative to the trigger. |
offset | 4 | number | The distance between the tooltip and the trigger. |
delayIn | 600 | number | The delay in milliseconds before the tooltip is shown. |
delayOut | 0 | number | The delay in milliseconds before the tooltip is hidden. |
disabled | | boolean | Whether the tooltip is shown. |
persistOnClick | | boolean | Whether the tooltip persists when the trigger is clicked. |
TooltipTrigger
The element that triggers the tooltip. Renders as a button by default for accessibility and interactive functionality.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | | boolean | When true, the trigger will merge props with its child element instead of rendering as a button. |
TooltipContent
The tooltip content that appears when triggered. Extends the div element.
| Prop | Default | Type |
|---|
Examples
Group
Tooltips placed within a TooltipGroup won’t have delay between them, creating a smooth experience when moving between multiple tooltips.
import { ClipboardIcon, ScissorsIcon } from '@phosphor-icons/react/dist/ssr';
import { Button } from '@/components/button';
import {
Tooltip,
TooltipContent,
TooltipGroup,
TooltipTrigger,
} from '@/components/tooltip';
export default function TooltipGroupPreview() {
return (
<div className="flex items-center gap-2">
<TooltipGroup>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" square>
<ClipboardIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" square>
<ScissorsIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Cut</TooltipContent>
</Tooltip>
</TooltipGroup>
</div>
);
} Placement
Controlling the position of the tooltip relative to its trigger.
import { Button } from '@/components/button';
import {
Tooltip,
TooltipContent,
TooltipGroup,
TooltipTrigger,
} from '@/components/tooltip';
export default function TooltipPlacementPreview() {
return (
<div className="flex w-full flex-col gap-2 overflow-auto p-2">
<TooltipGroup>
<Tooltip placement="top">
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
Top
</Button>
</TooltipTrigger>
<TooltipContent>Top</TooltipContent>
</Tooltip>
<Tooltip placement="right">
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
Right
</Button>
</TooltipTrigger>
<TooltipContent>Right</TooltipContent>
</Tooltip>
<Tooltip placement="bottom">
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
Bottom
</Button>
</TooltipTrigger>
<TooltipContent>Bottom</TooltipContent>
</Tooltip>
<Tooltip placement="left">
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
Left
</Button>
</TooltipTrigger>
<TooltipContent>Left</TooltipContent>
</Tooltip>
<Tooltip placement="top-start">
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
Top Start
</Button>
</TooltipTrigger>
<TooltipContent>Top Start</TooltipContent>
</Tooltip>
<Tooltip placement="top-end">
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
Top End
</Button>
</TooltipTrigger>
<TooltipContent>Top End</TooltipContent>
</Tooltip>
<Tooltip placement="right-start">
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
Right Start
</Button>
</TooltipTrigger>
<TooltipContent>Right Start</TooltipContent>
</Tooltip>
<Tooltip placement="right-end">
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
Right End
</Button>
</TooltipTrigger>
<TooltipContent>Right End</TooltipContent>
</Tooltip>
<Tooltip placement="bottom-start">
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
Bottom Start
</Button>
</TooltipTrigger>
<TooltipContent>Bottom Start</TooltipContent>
</Tooltip>
<Tooltip placement="bottom-end">
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
Bottom End
</Button>
</TooltipTrigger>
<TooltipContent>Bottom End</TooltipContent>
</Tooltip>
<Tooltip placement="left-start">
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
Left Start
</Button>
</TooltipTrigger>
<TooltipContent>Left Start</TooltipContent>
</Tooltip>
<Tooltip placement="left-end">
<TooltipTrigger asChild>
<Button variant="outline" size="sm">
Left End
</Button>
</TooltipTrigger>
<TooltipContent>Left End</TooltipContent>
</Tooltip>
</TooltipGroup>
</div>
);
} Persist on Click
Keeping the tooltip visible when the trigger is clicked. This example also demonstrates that onClick handlers work perfectly with tooltips.
'use client';
import { ClipboardIcon } from '@phosphor-icons/react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip';
export default function TooltipPersistClickPreview() {
const [copied, setCopied] = useState(false);
useEffect(() => {
let timeout: ReturnType<typeof setTimeout>;
if (copied) {
timeout = setTimeout(() => {
setCopied(false);
}, 2000);
}
return () => clearTimeout(timeout);
}, [copied]);
return (
<Tooltip
persistOnClick
delayIn={0}
onOpenChange={(open) => {
if (!open) setCopied(false);
}}
>
<TooltipTrigger asChild>
<Button
variant="outline"
square
size="sm"
onClick={() => setCopied(true)}
aria-label="Copy"
>
<ClipboardIcon />
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? 'Copied!' : 'Copy'}</TooltipContent>
</Tooltip>
);
} Long Content
Handling tooltips with longer text content.
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip';
export default function TooltipLongContentPreview() {
return (
<Tooltip>
<TooltipTrigger>
<span>Hover me to see long content</span>
</TooltipTrigger>
<TooltipContent>
This is a very long tooltip content that demonstrates how tooltips
handle lengthy text. The tooltip will automatically wrap the text to
ensure it remains readable while maintaining a reasonable width.
</TooltipContent>
</Tooltip>
);
} Rich Content
Using complex content inside tooltips.
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/avatar';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip';
export default function TooltipRichContentPreview() {
return (
<Tooltip>
<TooltipTrigger>
<span>Hover to see rich content</span>
</TooltipTrigger>
<TooltipContent>
<div className="flex items-center gap-1.5">
<Avatar
variant="square"
size="sm"
className="-ml-1.5 backdrop-blur-none"
>
<AvatarImage src="https://github.com/pdrbrnd.png" />
<AvatarFallback>Pedro Brandão</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span>Pedro Brandão</span>
<span className="text-foreground-secondary">Significa</span>
</div>
</div>
</TooltipContent>
</Tooltip>
);
} Automatic reposition
The tooltip automatically repositions to stay in view when scrolling.
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip';
export default function TooltipScrollPreview() {
return (
<div className="h-full w-full overflow-y-auto">
<div className="flex h-[200vh] items-center justify-center">
<Tooltip open>
<TooltipTrigger>
Scroll to see the tooltip reposition itself
</TooltipTrigger>
<TooltipContent>Tooltip content</TooltipContent>
</Tooltip>
</div>
</div>
);
} Best Practices
-
Content Guidelines:
- Keep tooltip content concise and focused
- Use for supplementary information only
- Avoid using for essential information
- Consider using other components for complex interactions
-
Timing:
- Use appropriate delays to prevent accidental triggers
- Adjust delays based on user behavior patterns
-
Positioning:
- Choose sensible default placements
- Ensure tooltips don’t obscure important content
- Test behavior with different screen sizes
-
Performance:
- Avoid heavy content in tooltips
Previous
Textarea
Next
Compose Refs