Show a tooltip when hovering

Users expect to see additional related information without completely changing their context. Showing a tooltip when a user is interested in more information can be useful to provide definitions for a term, clarifying the action an icon-only button will take, or provide additional form field guidance.

Creating the tooltip

You can create a popover with the required behavior by adding the popover="hint" attribute to a <div> or other semantically appropriate element. When the user opens the tooltip, this hides other popover="hint" tooltips, but doesn't hide auto or manual tooltips. It also handles dismissing nested tooltips.

It also provides light dismiss behavior, so when a user clicks or otherwise focuses outside of the popover, the popover is dismissed.

The tooltip element must have an id attribute with a unique value:

<!-- MANDATORY: The tooltip container `<div>` must have a `popover` attribute.
     the value of `"hint"` ensures it can be "light dismissed". -->
<div popover="hint" id="tooltip">Tooltip content</div>

A user expresses interest in the additional information by hovering or focusing on an <a> or <button> element. The element must have an interestfor attribute that matches the id attribute of the tooltip.

<!-- The `interestfor` attribute can be applied to a `<button>` element: -->
<button interestfor="tooltip">Tooltip trigger</button>

<!-- The `interestfor` attribute can also be applied to an `<a>` element: -->
<a interestfor="tooltip" href="">Tooltip trigger</a>

The trigger must have a visual indicator to indicate that there is additional information available by interacting with the trigger.

Accessibility built in to interestfor

When you use interestfor, the browser handles the assistive-technology wiring for you:

  • Implicit semantics: A target with popover="hint" gains an implicit minimum role of tooltip. DO NOT set role="tooltip" yourself.
  • Implicit association: The browser implicitly associates the source element with the target via aria-describedby when the target is plaintext, or via aria-details when the target contains interactive content. DO NOT add aria-describedby or aria-details to the trigger.
  • Interactive content: Because the association switches to aria-details when needed, the target IS allowed to contain interactive content (e.g. a link inside an "interest card").

Accessibility Constraints (WCAG 1.4.13)

Even with interestfor handling the semantics above, your implementation MUST still satisfy WCAG 1.4.13 (Content on Hover or Focus):

  • Dismissible: Users must be able to dismiss the tooltip without moving pointer hover or keyboard focus (e.g., by pressing the Escape key). The native popover attribute manages this binding automatically.
  • Hoverable: The pointer must be able to move over the tooltip content itself without the tooltip disappearing. This allows users with magnification tools to read the tooltip text safely.
  • Persistent: The tooltip must remain visible until the hover or focus trigger is removed, the user explicitly dismisses it, or its content is no longer valid.

Positioning the tooltip

The tooltip is positioned using CSS Anchor Positioning. When the tooltip is opened using interestfor, the trigger element automatically becomes an implicit anchor for the popover.

In modern browsers supporting native anchor positioning, you do not need to define an anchor-name on the trigger or a position-anchor on the tooltip. You can position the tooltip directly relative to its implicit anchor using position-area and handle collision flipping with position-try-fallbacks:

[popover] {
  /* Implicit anchor from interestfor: no anchor-name or position-anchor needed */
  position-area: top;
  position-try-fallbacks: flip-block;
  margin: unset;
}

Fallback strategies

Fallbacks & browser support for Interest invokers

Interest invokers must be conditionally polyfilled using the interestfor polyfill package from NPM. Prefer bundling the polyfill over using the CDN.

MANDATORY: Feature detect by checking for the interestForElement property on HTMLButtonElement.prototype, and load the polyfill only when native support is missing.

<script type="module">
  if (!HTMLButtonElement.prototype.hasOwnProperty("interestForElement")) {
    // CDN link only used for example, prefer bundling.
    await import("https://unpkg.com/interestfor@latest");
  }
</script>

CSS Polyfill Caveats

When using the polyfill, the CSS API changes slightly to account for the lack of native pseudo-class and property support:

  • Pseudo-classes: The polyfill cannot define real :interest-source and :interest-target pseudo-classes, so it applies .interest-source and .interest-target classes instead. MANDATORY: Combine them with :is() to support both native and polyfilled states.
/* Styles to apply when the effect is being previewed */
:is(:interest-source, .interest-source) { /* ... */ }
:is(:interest-target, .interest-target) { /* ... */ }
  • CSS Properties: For interest-delay, interest-delay-start, and interest-delay-end, the polyfill requires using CSS variables as it cannot read the custom properties directly in all browsers.
/* Adjust the start and end delay for interest invokers */
[interestfor] {
  --interest-delay-start: 0.2s;
  interest-delay-start: var(--interest-delay-start);
  --interest-delay-end: 0.1s;
  interest-delay-end: var(--interest-delay-end);
}

Fallbacks & browser support for Popover

The Popover API is mostly progressive enhancement, but its defining behaviors — top-layer promotion, light-dismiss, and popovertarget invocation — have no CSS-only equivalent. Older browsers need a polyfill, or a manual fallback if you would rather not ship one.

Polyfill: To support the popover attribute in older browsers, conditionally load @oddbird/popover-polyfill. MANDATORY: Feature detect by checking for the popover property on HTMLElement.prototype, and load the polyfill only when native support is missing — do NOT load it unconditionally.

With a bundler or import map:

// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
  import("@oddbird/popover-polyfill");
}

Without a bundler, import from a CDN inside a <script type="module">:

<script type="module">
  if (!("popover" in HTMLElement.prototype)) {
    import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
  }
</script>

Styling caveat: The polyfill cannot define the real :popover-open pseudo-class, so it applies a .\:popover-open class instead. MANDATORY: Combine the two with :is() or :where(), otherwise browsers that lack :popover-open discard the entire rule:

[popover]:is(:popover-open, .\:popover-open) {
  display: block;
}

Alternatively, for a legacy fallback without a polyfill, use position: fixed and manually calculate coordinates via getBoundingClientRect() or rely on default positioning with inset: auto if that's acceptable for the use case.

The popover-polyfill does not polyfill the hint behavior in browsers that support popover but not popover="hint". For those browsers, a tooltip opened via focus may stay open when a second tooltip opened via hover.

To support browsers without anchor positioning, you can use the @oddbird/css-anchor-positioning polyfill. Alternatively, you can use @supports to specify a fallback position.

When using the @oddbird/css-anchor-positioning polyfill for legacy browser support, be aware that the polyfill does not support implicit anchors or position-area on popovers. Explicitly set an anchor-name on the trigger, set position-anchor on the popover, and position using anchor() inset functions instead of position-area:

<!-- MANDATORY: Conditionally install the anchor positioning polyfill -->
<script type="module">
  if (!("anchorName" in document.documentElement.style)) {
    await import("https://unpkg.com/@oddbird/css-anchor-positioning");
  }
</script>
button[interestfor="tooltip-attrs"] {
  /* MANDATORY: Each trigger and popover pair must have a unique anchor name, referenced by `anchor-name` on the trigger and `position-anchor` on the popover. */
  anchor-name: --tooltip-attrs;
}
#tooltip-attrs {
  position-anchor: --tooltip-attrs;
  /* If using the anchor positioning polyfill with a popover, DO use `anchor()` functions, and not `position-area. */
  top: anchor(bottom);
  left: anchor(right);
  margin: unset;
}