Tooltip for React
Short labels on hover and focus, with an optional shortcut. Escape hides them, they never hold important content.
One of 9 components in Overlays
Short labels, never the only label
A tooltip names what an icon does when there is no room for a word. This one opens on hover and on keyboard focus, hides with Escape, links itself to its trigger through aria-describedby and keeps out of the way of the pointer.
Sides and shortcuts shows the tooltip above, below, left and right, with an optional shortcut such as Ctrl B in a key cap. In a toolbar shows the typical use, a row of icon buttons for an editor, each one with its name and shortcut, and a short delay so moving along the row feels calm.
Keep tooltips to a few words. They cannot hold links or buttons, use a popover or a hover card for that. Touch screens have no hover, so the icon button still needs its own aria label, the tooltip only adds the visual hint.
Details
What Tooltip brings along, read from the registry and the source.
- Category
- Overlays
- Area
- Elements
- Variants
- 2
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/tooltip.tsx
- Brings along
- utilskitpopover
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The TooltipProps interface from components/ui/tooltip.tsx, with the comments from the code.
contentrequiredReact.ReactNodeThe text. Keep it short, a tooltip is a label, not a manual.
keysoptionalstring[]Keyboard shortcut shown next to the text, e.g. ["⌘", "S"]
sideoptionalSidealignoptionalAligndelayoptionalnumberDelay before showing on hover, in ms. Focus shows it right away.
openoptionalbooleanForce it open, e.g. for a demo or an onboarding hint
toneoptional'default' | 'quiet'quiet: popover colors. Default is the inverted, high-contrast chip.
childrenrequiredReact.ReactElementOne focusable element. It gets aria-describedby.
Accessibility
Read from the source of the component.
- Tooltip handles the keys Escape itself.
- Uses the role tooltip.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 135 lines
'use client';
import { cloneElement, isValidElement, useEffect, useId, useRef, useState } from 'react';
import { cn } from '@/lib/kit';
import { Floating, originFor, useMounted, usePresence, type Align, type FloatingPosition, type Side } from '@/components/ui/popover';
/* The tip slides a few pixels away from its trigger while it fades in, and back toward it when it leaves */
const FROM: Record<Side, string> = {
top: 'starting:translate-y-1 data-[closing]:translate-y-0.5',
bottom: 'starting:-translate-y-1 data-[closing]:-translate-y-0.5',
left: 'starting:translate-x-1 data-[closing]:translate-x-0.5',
right: 'starting:-translate-x-1 data-[closing]:-translate-x-0.5',
};
export interface TooltipProps {
/** The text. Keep it short, a tooltip is a label, not a manual. */
content: React.ReactNode;
/** Keyboard shortcut shown next to the text, e.g. ["⌘", "S"] */
keys?: string[];
side?: Side;
align?: Align;
/** Delay before showing on hover, in ms. Focus shows it right away. */
delay?: number;
/** Force it open, e.g. for a demo or an onboarding hint */
open?: boolean;
/** quiet: popover colors. Default is the inverted, high-contrast chip. */
tone?: 'default' | 'quiet';
className?: string;
/** One focusable element. It gets aria-describedby. */
children: React.ReactElement;
}
function Arrow({ pos, tone }: { pos: FloatingPosition | null; tone: 'default' | 'quiet' }) {
if (!pos) return null;
const s = pos.side;
const style: React.CSSProperties =
s === 'bottom' ? { top: -4, left: pos.arrow - 4 } : s === 'top' ? { bottom: -4, left: pos.arrow - 4 } : s === 'right' ? { left: -4, top: pos.arrow - 4 } : { right: -4, top: pos.arrow - 4 };
const borders = tone === 'quiet' ? (s === 'bottom' ? 'border-l border-t' : s === 'top' ? 'border-r border-b' : s === 'right' ? 'border-l border-b' : 'border-r border-t') : '';
return <span aria-hidden className={cn('absolute size-2 rotate-45 rounded-[1px]', tone === 'quiet' ? 'border-border bg-popover' : 'bg-foreground', borders)} style={style} />;
}
/**
* A short label on hover and focus. Escape hides it.
* <Tooltip content="Archive" keys={["E"]}><IconButton icon="archive" label="Archive" /></Tooltip>
*/
export function Tooltip({ content, keys, side = 'top', align = 'center', delay = 450, open: forced, tone = 'default', className, children }: TooltipProps) {
const [open, setOpen] = useState(false);
const mounted = useMounted();
const anchorRef = useRef<HTMLSpanElement>(null);
const timer = useRef<number | undefined>(undefined);
const id = useId();
const visible = forced ?? open;
const { present, closing } = usePresence(visible, 120);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
useEffect(() => () => window.clearTimeout(timer.current), []);
const show = (now: boolean) => {
window.clearTimeout(timer.current);
if (now) setOpen(true);
else timer.current = window.setTimeout(() => setOpen(true), delay);
};
const hide = () => {
window.clearTimeout(timer.current);
setOpen(false);
};
const child = isValidElement<Record<string, unknown>>(children)
? cloneElement(children, { 'aria-describedby': cn(children.props['aria-describedby'] as string | undefined, id) || undefined })
: children;
return (
<>
<span
ref={anchorRef}
className="inline-flex"
onPointerEnter={(e) => e.pointerType === 'mouse' && show(false)}
onPointerLeave={hide}
onFocus={(e) => (e.target as HTMLElement).matches(':focus-visible') && show(true)}
onBlur={hide}
onPointerDown={hide}
>
{child}
</span>
{/* Server and closed state: the text stays in the DOM for aria-describedby */}
{!(mounted && present) && (
<span id={id} hidden>
{content}
</span>
)}
{mounted && present && (
<Floating anchor={anchorRef} side={side} align={align} offset={8} className="pointer-events-none">
{(pos) => (
<div
id={id}
role="tooltip"
data-closing={closing || undefined}
className={cn(
'relative flex max-w-[16rem] items-center gap-2 rounded-md px-2.5 py-1.5 text-[12.5px] font-medium leading-[18px] transition-[opacity,scale,translate] duration-200 ease-[cubic-bezier(0.34,1.4,0.64,1)] starting:scale-90 starting:opacity-0 data-[closing]:scale-95 data-[closing]:opacity-0 data-[closing]:duration-[120ms] data-[closing]:ease-[var(--ease-kit)] motion-reduce:transition-none',
'origin-[var(--origin)]',
FROM[pos?.side ?? side],
tone === 'quiet' ? 'border border-border bg-popover text-popover-foreground shadow-[var(--shadow-md)]' : 'bg-foreground text-background shadow-[var(--shadow-md)]',
className,
)}
style={{ '--origin': originFor(pos, side) } as React.CSSProperties}
>
<Arrow pos={pos} tone={tone} />
<span className="relative">{content}</span>
{keys && (
<span className="relative flex gap-0.5">
{keys.map((k) => (
<kbd
key={k}
className={cn(
'grid h-[18px] min-w-[18px] place-items-center rounded-[4px] px-1 font-sans text-[11px] font-medium',
tone === 'quiet' ? 'border border-border bg-surface text-muted-foreground' : 'bg-[color-mix(in_oklab,var(--background)_16%,transparent)] text-[color-mix(in_oklab,var(--background)_78%,var(--foreground))]',
)}
>
{k}
</kbd>
))}
</span>
)}
</div>
)}
</Floating>
)}
</>
);
}Frequent questions
How do I install Tooltip?
With aiw add @ai-website/tooltip. The command puts components/ui/tooltip.tsx into your project, the code is yours from then on. It brings along popover. It needs no further npm packages.
Which variants does Tooltip have?
2 variants, Sides and shortcuts and In a toolbar.
Can an AI assistant build with Tooltip?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Tooltip with aiw_components and install it with aiw_add @ai-website/tooltip.