Tooltip
pnpm dlx shadcn@latest add …/r/tooltip.jsonAn accessible React tooltip for Next.js and Tailwind CSS: a short hint on hover and keyboard focus, fast across a toolbar, built on Radix Tooltip.
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Undo2Icon } from "lucide-react"
export function UndoButton() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Undo">
<Undo2Icon />
</Button>
</TooltipTrigger>
<TooltipContent>Undo</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}"use client";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DownloadIcon,
type LucideIcon,
MessageSquarePlusIcon,
Redo2Icon,
Share2Icon,
Undo2Icon,
} from "lucide-react";
import { Fragment, useId, useState } from "react";
type Side = "top" | "right" | "bottom" | "left";
const groups: { label: string; icon: LucideIcon }[][] = [
[
{ label: "Undo", icon: Undo2Icon },
{ label: "Redo", icon: Redo2Icon },
],
[
{ label: "Add a comment", icon: MessageSquarePlusIcon },
{ label: "Share", icon: Share2Icon },
{ label: "Download", icon: DownloadIcon },
],
];
/*
* A toolbar of icon buttons, each with a tooltip, and the side in the top-right corner. Hover one, then move
* along the row: the next tooltips appear at once. An empty row at the bottom, as tall as the setting's
* row, keeps the toolbar centred.
*/
export default function TooltipToolbar() {
const id = useId();
const [side, setSide] = useState<Side>("top");
return (
<div className="flex w-full flex-col gap-6 self-stretch">
<div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
<label htmlFor={`${id}-side`} className="text-sm text-muted-foreground">
Side
</label>
<Select value={side} onValueChange={(value) => setSide(value as Side)}>
<SelectTrigger id={`${id}-side`} size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top">Top</SelectItem>
<SelectItem value="right">Right</SelectItem>
<SelectItem value="bottom">Bottom</SelectItem>
<SelectItem value="left">Left</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-1 items-center justify-center">
{/* One provider for the row, so its tooltips share the delay and hand over to each other */}
<TooltipProvider>
<div className="flex items-center gap-1">
{groups.map((group, index) => (
<Fragment key={group[0]?.label}>
{index > 0 ? <div aria-hidden className="mx-1 h-5 w-px bg-border" /> : null}
{group.map(({ label, icon: Icon }) => (
<Tooltip key={label}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" aria-label={label}>
<Icon aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{label}</TooltipContent>
</Tooltip>
))}
</Fragment>
))}
</div>
</TooltipProvider>
</div>
<div aria-hidden className="-mb-6 h-(--size-control-sm)" />
</div>
);
}Installation
Install the dependencies.
$ pnpm add radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Copy the source into your project.
"use client"; import { cn } from "@/lib/utils"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { type ComponentProps, createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; /* * Tooltip, built on Radix Tooltip: a short hint that shows on hover and on keyboard focus, and names nothing * itself (an icon button still needs its own aria-label; the tooltip describes it). Escape closes it. * - The first tooltip waits a moment, then opens out of its trigger through the blur (the popover-in * keyframes, scaling from Radix's transform origin). * - Moving along a row of triggers (a toolbar), the next tooltips appear at once and take the last one's * place: no delay and no animation, and the one before goes in the same frame instead of fading out * under the new one. Keyboard focus works the same way. Leaving the row, the last tooltip closes back * into its trigger; after `skipDelayDuration` the next one waits and animates again. * - The provider stays "warm" while a tooltip shows and for the skip window after, and its tooltips skip the * delay meanwhile. Radix's own skip window starts when the previous tooltip hands over, so resting on a * tooltip for longer than the window would bring the delay back for the next one. * - Nothing animates on first paint. * - A Tooltip outside a TooltipProvider gets its own, so one on its own just works. Wrap a toolbar (or the * app) in one TooltipProvider so its tooltips share the delay and the hand-over. */ /** The tooltips under one provider hand over to each other through this */ type Group = { /** A tooltip opens: whether it takes the place of one that's showing or closed a moment ago */ open: (tooltip: object) => boolean; /** A tooltip closes; `hide` makes it vanish at once if the next one takes its place */ close: (tooltip: object, hide: () => void) => void; }; const GroupContext = createContext<Group | null>(null); /** A tooltip is showing, or closed less than the skip window ago: the next one opens without the delay */ const WarmContext = createContext(false); /** One tooltip's way of coming and going, read by its content */ const TooltipContext = createContext<{ following: boolean; hidden: boolean } | null>(null); function TooltipProvider({ delayDuration = 300, skipDelayDuration = 300, ...props }: ComponentProps<typeof TooltipPrimitive.Provider>) { const [warm, setWarm] = useState(false); const record = useRef({ showing: null as object | null, closedAt: Number.NEGATIVE_INFINITY, hideLast: null as (() => void) | null, cooling: 0, }); useEffect(() => () => window.clearTimeout(record.current.cooling), []); const group = useMemo<Group>( () => ({ open: (tooltip) => { const last = record.current; const follows = last.showing !== null || performance.now() - last.closedAt < skipDelayDuration; // Radix closes the one before just ahead of this one opening; it goes in this frame if (follows) last.hideLast?.(); last.showing = tooltip; window.clearTimeout(last.cooling); setWarm(true); return follows; }, close: (tooltip, hide) => { const last = record.current; if (last.showing !== tooltip) return; last.showing = null; last.closedAt = performance.now(); last.hideLast = hide; last.cooling = window.setTimeout(() => setWarm(false), skipDelayDuration); }, }), [skipDelayDuration], ); return ( <GroupContext.Provider value={group}> <WarmContext.Provider value={warm}> <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} skipDelayDuration={skipDelayDuration} {...props} /> </WarmContext.Provider> </GroupContext.Provider> ); } type TooltipProps = ComponentProps<typeof TooltipPrimitive.Root>; function TooltipRoot({ group, open: openProp, defaultOpen = false, onOpenChange, delayDuration, ...props }: TooltipProps & { group: Group }) { const warm = useContext(WarmContext); // This tooltip's identity in the group const self = useRef({}); const [uncontrolled, setUncontrolled] = useState(defaultOpen); // Open from the start counts as following: it's simply there, with nothing animating on first paint const [following, setFollowing] = useState(defaultOpen); const [hidden, setHidden] = useState(false); const open = openProp ?? uncontrolled; // A tooltip that unmounts while showing counts as closed, so the group doesn't stay warm useEffect(() => { const tooltip = self.current; return () => group.close(tooltip, () => {}); }, [group]); return ( <TooltipContext.Provider value={{ following, hidden }}> <TooltipPrimitive.Root data-slot="tooltip" open={open} onOpenChange={(next) => { // Radix's timers can report a state it's already in if (next === open) return; if (next) { setFollowing(group.open(self.current)); setHidden(false); } else { group.close(self.current, () => setHidden(true)); } if (openProp === undefined) setUncontrolled(next); onOpenChange?.(next); }} delayDuration={warm ? 0 : delayDuration} {...props} /> </TooltipContext.Provider> ); } function Tooltip(props: TooltipProps) { const group = useContext(GroupContext); if (group) return <TooltipRoot group={group} {...props} />; return ( <TooltipProvider> <Tooltip {...props} /> </TooltipProvider> ); } function TooltipTrigger(props: ComponentProps<typeof TooltipPrimitive.Trigger>) { return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />; } function TooltipContent({ className, sideOffset = 4, children, ...props }: ComponentProps<typeof TooltipPrimitive.Content>) { const tooltip = useContext(TooltipContext); return ( <TooltipPrimitive.Portal> <TooltipPrimitive.Content data-slot="tooltip-content" data-following={tooltip?.following ? "" : undefined} sideOffset={sideOffset} className={cn( "z-(--z-tooltip) w-fit max-w-xs rounded-md bg-primary px-2 py-1 text-xs text-balance text-primary-foreground", "origin-(--radix-tooltip-content-transform-origin) data-[state=closed]:animate-popover-out", // Radix's open states are delayed-open and instant-open; one that follows another doesn't animate !tooltip?.following && "data-[state=delayed-open]:animate-popover-in data-[state=instant-open]:animate-popover-in", // Handed over to the next tooltip: gone at once, while Radix waits out the closing keyframes tooltip?.hidden && "invisible", className, )} {...props} > {children} </TooltipPrimitive.Content> </TooltipPrimitive.Portal> ); } export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };Update the import paths to match your project.
API reference
TooltipProvider
Wrap a toolbar, or your whole app, so its tooltips share the delay: once one has opened, the next open at once. A Tooltip without a provider gets its own. Also accepts every Radix Tooltip Provider prop.
| Prop | Type | Default | Description |
|---|---|---|---|
delayDuration? | number | 300 | Milliseconds the pointer rests on a trigger before its tooltip opens. |
skipDelayDuration? | number | 300 | Milliseconds after a tooltip closes during which the next one opens at once, without the delay or the opening animation. |
disableHoverableContent? | boolean | false | Closes the tooltip as soon as the pointer leaves the trigger, even towards the tooltip. |
Tooltip
One trigger and its tooltip. Also accepts every Radix Tooltip Root prop.
| Prop | Type | Default | Description |
|---|---|---|---|
open? | boolean | – | Whether it's open, when you control it. |
defaultOpen? | boolean | false | Whether it starts open. |
onOpenChange? | (open: boolean) => void | – | Called when it opens or closes. |
delayDuration? | number | – | Overrides the provider's delay for this tooltip. |
TooltipTrigger
What the tooltip describes. It opens on hover and on keyboard focus, and closes on click and Escape. Use asChild with a Button; an icon-only Button still needs its own aria-label, since the tooltip only describes it.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild? | boolean | false | Render the single child (for example a Button) as the trigger. |
TooltipContent
The hint: short text, inverted from the page. It opens out of its trigger through a soft blur. Also accepts every Radix Tooltip Content prop.
| Prop | Type | Default | Description |
|---|---|---|---|
side? | "top" | "right" | "bottom" | "left" | "top" | The side of the trigger it shows on. It flips when there's no room. |
align? | "start" | "center" | "end" | "center" | How it lines up with the trigger along that side. |
sideOffset? | number | 4 | Gap between the trigger and the tooltip, in px. |
Related components
An accessible React popover for Next.js and Tailwind CSS: forms, settings and other rich content anchored to a button, built on Radix Popover.
An accessible React hover card for Next.js and Tailwind CSS: a preview card for a link, like a profile on an @mention, built on Radix Hover Card.
An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.