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'
Source
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`.
*/
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="https://bedrock.sams.land">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 — is not standardised and is not in Chrome stable, so
src/interest.ts handles pointer in, pointer out, focus and blur.
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-describedby points 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, it falls
back to auto — opening a tooltip then closes an open menu, which is wrong but
not broken.
Children mount only while open.
Keyboard
| key | behaviour |
|---|---|
| focus | Opens after the delay. Keyboard users get tooltips too. |
| blur | Closes. |
Escape |
Closes. |
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.
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 gaps.
- Touch support beyond the platform's. There is no hover on a touch screen, and a tooltip that opens on tap is a popover with extra steps.