Tooltip and HoverCard
Both are hover-and-focus intent over an anchored popover, and both are one implementation. They differ in their delays, in whether the content is hoverable, and in whether the thing is a label or a region.
import { Tooltip, HoverCard } from '@apostel/bedrock'
Sourcetooltip.tsx
import { HoverCard, Tooltip } from '../../src/index'
/**
* Both are hover and focus intent over an anchored popover. The tooltip
* *describes* its trigger, so the button keeps its own name; the hover card is
* a region you can move the pointer into, which is why its content holds a
* link and the tooltip's does not.
*
* The intent timers are JavaScript today. When `interestfor` ships, the same
* props become declarative and nothing above this changes — which is why the
* prop is `delayDuration` and not `interest-show-delay`.
*
* On a phone, hold either one. The tooltip is held up while you press and goes
* when you let go; the card stays, because it is somewhere to go. A tap is
* still a tap: the button activates and the link follows.
*/
export default function TooltipDemo() {
return (
<>
<Tooltip.Root delayDuration={200}>
<Tooltip.Trigger>Save</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={6}>
Saves without closing
</Tooltip.Content>
</Tooltip.Root>
<HoverCard.Root openDelay={150} closeDelay={200}>
<HoverCard.Trigger asChild>
<a href="./docs.html">bedrock</a>
</HoverCard.Trigger>
<HoverCard.Content side="bottom" align="start" sideOffset={8}>
<strong>@apostel/bedrock</strong>
<p style={{ margin: '.4rem 0 0' }}>
Headless React primitives built on native platform features.
</p>
</HoverCard.Content>
</HoverCard.Root>
</>
)
}The honest part
The panel, its stacking, its dismissal and its positioning are the platform's.
The intent timers are not. interestfor, the attribute that would make this
declarative, shipped in Chrome 142 and nowhere else, and is still not on
a standards track, so src/interest.ts handles pointer in, pointer out, focus,
blur and the long press for everyone.
It is written as a fallback rather than as a feature: when the attribute ships,
useInterest stops attaching anything and the same props become declarative.
Nothing above it changes, which is exactly why the prop is called
delayDuration and not interest-show-delay. See
browser support.
Tooltip.Root
| prop | type | notes |
|---|---|---|
delayDuration |
number |
Milliseconds before it opens. |
closeDelay |
number |
Milliseconds before it closes. |
onOpenChange |
(open: boolean) => void |
Reports; cannot refuse. |
HoverCard.Root
Same, with openDelay instead of delayDuration, and hoverable content:
moving the pointer from the trigger onto the card keeps it open. A tooltip's
content is not hoverable, because a tooltip is a label and there is nothing in
it to reach.
Trigger
Renders <button>, or whatever you pass with asChild. A HoverCard trigger is
usually an <a>, which is the point of link previews.
aria-describedbypoints at the content: a tooltip describes its trigger and must not replace its name. A button labelled only by its tooltip is a button with no name when the tooltip is closed.
Content
Renders <div popover data-bedrock-tooltip> and takes side, align,
sideOffset and avoidCollisions, exactly as Popover does.
Tooltip content uses popover="hint" where the browser supports it, so it
layers above an open menu instead of closing it. Where it does not — every
engine but Chrome today — it falls back to auto, so opening a tooltip closes
an open menu, which is wrong but not broken.
The fallback is asked for, not assumed.
popoveris an enumerated attribute whose invalid-value default is manual, so writinghintat an engine that has never heard of it does not degrade toauto: it produces a popover that neither light dismiss nor Escape reaches.Popover.Root'skindprop resolves the same way.
Children mount only while open.
Keyboard
| key | behaviour |
|---|---|
| focus | Opens after the delay. Keyboard users get tooltips too. |
| blur | Closes. |
Escape |
Closes. |
Focus opens it where the browser calls that focus visible: :focus-visible,
asked of the trigger itself. Tapping a control focuses it on Android, and a
tooltip that appears because you tapped the button is a tooltip nobody asked
for.
There is no key that opens a hover card, and that is a real gap for keyboard-only users where the card holds links that exist nowhere else. Do not put unique navigation in one.
Touch
There is no hover on a touch screen, so the gesture is a press: hold the
trigger and the tooltip or card opens. That is the hold iOS and Android already
use for their own previews, and the one interestfor is specified to answer
where the platform runs intent itself.
| gesture | what happens |
|---|---|
| tap | Nothing opens. The trigger's onClick runs, a link follows. |
| press | Opens while you hold it. The click the lift produces is swallowed, so pressing a link previews it instead of following it. |
| lift | A tooltip goes; a hover card stays. A tooltip is a label held up while you press; a card is somewhere to go. |
| press, then drag | Nothing opens; past about 10px the finger is scrolling. |
| tap anywhere else | Closes the card. Light dismiss is the popover's, not ours. |
On iPhone, "force touch" is a press: 3D Touch was replaced by Haptic Touch and Safari exposes no pressure to read, so the hold is the whole gesture.
How long the hold is
Not one number, because the wait is not for the gesture — it is for the tap the gesture might have been.
| the trigger | the wait |
|---|---|
| does nothing when tapped | 150ms, as fast as a hold can be told from the start of a scroll |
| does something | 250ms, just past the length of a tap |
An info icon is the first row: nothing happens when you tap it, so there is no tap to protect and it does not pay for one. The second row is a trigger that has somewhere to go, which means any of:
- an
onClick, on theTriggeror on itsasChildchild - an
<a href> - a submit button inside a
<form>
A component that binds its handler where neither the props nor the DOM show it
reads as inert and gets the shorter hold; the cost is a preview where a tap was
meant, on a hold already past a tap's length. Neither number is delayDuration
— a press is a different gesture from a pointer coming to rest, and tuning one
says nothing about the other.
Where the browser answers the hold itself
Chrome 142 and later run interestfor, and there the press, the panel and its
dismissal are the browser's: it opens the panel while you hold and takes it away
when you lift. One half stays ours. The lift produces a click that the browser
lets through, so without us a long press on a hover card link would preview it
and follow it. The press is tracked either way, and a click that ends a
press which opened the panel is swallowed. Everything else in the table above is
the JavaScript path, which is what every WebKit engine takes, iOS included.
Two platform gestures compete for that hold, and the trigger takes both off, but only where the JavaScript path is running, because a browser doing intent itself owns the conflict too:
-webkit-touch-callout: none, inline on the trigger, so iOS offers no share sheet for the link you are pressing.user-select: none, for the length of the press and no longer. Setting it permanently would drop the trigger's own text out of any selection made around it, and a hover card trigger is usually a link in the middle of a paragraph.
There is no prop to turn the press off. If a trigger must not answer a hold, it is a control with a description, not a tooltip.
What is not here
- A shared provider with a global "skip delay" window. Radix opens subsequent tooltips instantly once one has opened. Not implemented; each root keeps its own timers. See should you switch?.
- A tap that opens anything. See touch: the gesture is a press, and a tap stays a tap.