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'
Loading demo…
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-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 — 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. popover is an enumerated attribute whose invalid-value default is manual, so writing hint at an engine that has never heard of it does not degrade to auto: it produces a popover that neither light dismiss nor Escape reaches. Popover.Root's kind prop 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:

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:

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