Timing tooltips
Delay the first, skip the rest.
How long should a tooltip wait? Most products either show it on contact or make you wait a second or longer. For most interfaces, a better answer is to delay the first tooltip by 400ms, then open the next ones at once. Below is how that plays out, from the moment the pointer lands to the moment it leaves, and when not to wait at all.
Delay the first tooltip
On its way across the screen, a pointer passes over plenty of controls it has no interest in. Without a delay, each of them flashes a tooltip, and the interface feels nervous. The delay is how a tooltip tells passing from pausing.
Anywhere from 300 to 500ms works. Nielsen Norman Group recommends that range for content revealed on hover, and Windows tooltips wait 500ms by default. 400ms is a good place to start. The button itself reacts at once, so the pause feels intended, not broken.
Skip the delay for the next ones
Once the first tooltip is open, the person is reading the controls, not passing them. Making them wait at every neighbour is what makes a toolbar feel slow, so the next tooltips open at once.
Keep that going for a moment after a tooltip closes. If the pointer reaches another button within 300ms, it still skips the wait. Radix, Base UI and React Aria all work this way; only the length of that window differs.
Don't wait when pointing is the task
The wait protects people from controls they're only passing. When the pointer is there on purpose, there's nothing to protect. Charts are the clearest case: every point should answer right away. The same goes for the value on a slider while it's dragged and the preview while scrubbing a video.
A quick test: would someone point here only to see the tooltip? Then don't make them wait.
Close it fast, but let it be reached
When the pointer leaves, the tooltip should go. Nielsen Norman Group suggests up to half a second before hiding content shown on hover; for a one-line tooltip, about 100ms is enough. It still feels immediate, and it gives the pointer time to cross onto the tooltip, which WCAG requires: content shown on hover has to stay while the pointer is on it.
A click closes it too, and it stays closed until the pointer leaves the button. Escape closes it without moving the pointer.
Keep it short
A tooltip that opens and closes this quickly has to be read at a glance:
- One line, a few words. If it needs a heading or a link, it's a popover.
- It adds to the label, it doesn't repeat it. Text buttons rarely need one.
- The keyboard shortcut sits next to the name.
- It sits 8px from its trigger and never covers what it explains.
- Nothing important lives only in a tooltip. Touch screens can't hover.
Make it work without a mouse
The wait is for pointers. Keyboard focus is a deliberate step, so the tooltip opens at once:
| Input | Tooltip |
|---|---|
| Pointer rests | Opens after 400ms |
| Pointer moves on | Opens at once |
| Pointer leaves | Closes within 100ms |
| Pointer on a chart | Opens at once |
| Keyboard focus | Opens at once |
| Escape | Closes, focus stays where it is |
| Click or tap | Closes until the pointer leaves |
Touch has no hover at all, which is the best reason to keep tooltips optional. Every icon button still needs an accessible name, whether a tooltip shows or not.
How design systems time their tooltips
They all shorten or skip the wait for the next tooltip. They disagree on the first one.
| System | First | Next |
|---|---|---|
| Radix UI | 700ms | 0ms |
| Base UI | 600ms | 0ms |
| MUI | 100ms | 0ms |
| Windows | 500ms | 100ms |
| This guide | 400ms | 0ms |
In code
In Radix, that's two props on the provider: delayDuration={400} and skipDelayDuration={300}. Without a library, it's one timer and one timestamp:
const OPEN_DELAY = 400 // the first tooltip waits
const SKIP = 300 // the next ones don't, for this long
let timer
let openTip = null
let closedAt = -Infinity
function onEnter(tip) {
clearTimeout(timer)
const warm = openTip || performance.now() - closedAt < SKIP
if (warm) return show(tip)
timer = setTimeout(() => show(tip), OPEN_DELAY)
}
function onLeave() {
clearTimeout(timer)
if (!openTip) return
timer = setTimeout(() => {
hide()
closedAt = performance.now()
}, 100)
}
function onTooltipEnter() {
clearTimeout(timer) // the pointer made it across, keep it open
}
function onFocus(tip) {
clearTimeout(timer)
show(tip) // keyboard focus never waits
}