Build an interactive hovercard

Hovercards provide an interactive preview of content when a user expresses interest in a link (via hover or focus). This is shown to users before they navigate, and either provides the user with the content they need or helps verify that the link will contain the content they are looking for.

If you need plain-text, non-interactive tooltips rather than structured link previews, see Show a tooltip when hovering.

1. Triggering the hovercard

Use the Interest Invokers API to trigger the hovercard. This ensures the card appears on both hover and focus.

  • Add the popover="hint" attribute to your card container. Use hint to avoid closing unrelated popover menus that may be open.
  • Add the interestfor attribute to the trigger link, pointing to the card's id.
<a href="/article" interestfor="article-preview">Article title</a>

<!-- The `id` must match the `interestfor` value on the trigger. -->
<div id="article-preview" popover="hint" class="hovercard">
  <h2>The buzz on hummingbirds</h2>
  <p class="byline">By Delphi Aguilar</p>
  <div>
    <img src="bird.jpg" alt="A vibrant hummingbird">
    <p>Brief summary of the article...</p>
  </div>
</div>

You do not need to include an additional link to the content inside of the hovercard, as it is redundant to the triggering link.

Accessibility

interestfor is specified to wire hint popovers (popover="hint") for assistive technology itself, based on what the target contains. A plain hint (text and images only) becomes the trigger's accessible description, or its name if it has none. A rich hint (headings, links, or other interactive content) gets a minimum role of tooltip, the trigger gets aria-expanded and an aria-details relation while the hint is open, and the open hint comes right after the trigger in the tab order so keyboard users can reach its content.

Browsers and screen readers do not yet expose this consistently, so add explicit ARIA as well:

  • Hint that repeats the trigger's accessible name (e.g. an icon-only button with aria-label="Bold" and a "Bold" tooltip): add nothing. A description identical to the name would be read twice.
  • Hint on a trigger with no visible label of its own (e.g. an icon-only button without aria-label): aria-labelledby on the trigger pointing at the hint, so the hint becomes the name.
  • Hint whose trigger also opens it on activation (a toggletip, or any button whose only job is the hint: give it popovertarget with popovertargetaction="show"): role="tooltip" on the hint and aria-expanded on the trigger, kept in sync with the popover's toggle event. A description read right after "button" suggests further actions and muddles the role; the expanded state tells the user there is content to reveal and whether it is open, and pressing the button delivers it. Place the hint right after the trigger in the DOM so reading keys reach its content once it is open.
  • Hint whose trigger does something else on activation (a link, a button with its own action): role="tooltip" on the hint and aria-describedby on the trigger, never aria-expanded. A trigger announced as "collapsed" suggests Enter would expand it, but Enter navigates or acts. The hint must be something the user can afford to miss: a hovercard previews the destination, a tooltip on an action button describes the action.
  • DO NOT attach a hint that opens on activation to a control that already uses aria-expanded for something else (for example, a menu button). Give that hint its own trigger instead.
  • DO NOT move focus into the target when interest is shown (no autofocus, no focus() in an interest listener). Interest is triggered by hover or focus alone, so moving focus would fail WCAG 3.2.1 (On Focus).

Not every screen reader reads descriptions when moving through the page (NVDA skips them in browse mode by design), so a hint exposed as a description must only carry content the user can afford to miss. Link previews and tooltips on action controls qualify; anything the user needs belongs in a toggletip or inline.

Wire this with a short script instead of hand-authoring the attributes, so the markup stays declarative and the same rules apply to every hint. The script should:

  1. Run in every browser, before loading the interestfor polyfill, once the DOM is parsed, and expose a function that can be called again for content inserted later. Scanning upfront is required: a browse-mode screen reader user needs the attributes in place before any interaction, so wiring them lazily on focus or interest is too late, and so is waiting for the polyfill, which wires its ARIA only on load and adds no role="tooltip" to plain hints.
  2. For every [interestfor] trigger whose target has popover="hint", normalize whitespace and compare the hint text with the trigger's accessible name (aria-labelledby targets, else aria-label, else text content including image alt, else title). Equal: do nothing. Otherwise set role="tooltip" on the hint if it has no role.
  3. When the trigger has no name, point aria-labelledby at the hint unless it already has one. Otherwise, if the trigger also opens the hint on activation (a popovertarget or commandfor naming the hint), set aria-expanded to the popover's current state (:popover-open) unless it already has aria-expanded, and remember which triggers the script owns; if not, point aria-describedby at the hint unless it already has one.
  4. Keep aria-expanded in sync from the popover's toggle event in every browser with a single capturing listener on document, updating every trigger with a matching interestfor that the script owns (the interestfor polyfill does not sync aria-expanded on plain-text hints or when a toggletip is opened via popovertarget).

Things that are easy to get wrong:

  • toggle does not bubble. A bubbling listener on document never fires; use the capture phase (or a listener per popover).
  • Sync aria-expanded from toggle, not from interest/loseinterest. The popover is the source of truth: it can be closed by Escape, light dismiss, or another invoker, and several triggers can share one hint.
  • A hidden popover is a valid aria-describedby or aria-labelledby target. Do not force the hint open or duplicate its text to make it "visible".
  • Do not add aria-haspopup or a live region to any hint.
  • Do not override author ARIA: an existing aria-describedby, aria-labelledby, or aria-expanded on the trigger wins.

2. Positioning with Anchor Positioning

Position the hovercard relative to its trigger using CSS Anchor Positioning. When using interestfor, the trigger becomes an implicit anchor for the popover.

[popover].hovercard {
  /* Position the card below the trigger, aligned with its inline-start edge
     and spanning toward the inline-end, flipping if it overflows the viewport. */
  position-area: block-end span-inline-end;
  position-try-fallbacks: flip-block, flip-inline;
}

3. Animating entry and exit

By default, interest is shown and lost for keyboard and mouse users with a delay of half a second. This prevents the targeted popover from opening while tabbing through a page or moving your mouse around, and from closing while moving your mouse from the trigger to the popover. To adjust the timing, use the interest-delay CSS property.

[interestfor]{
  /* Shorthand for interest-delay-start and interest-delay-end */
  interest-delay: 0.2s 0.1s;
}

If users are likely to move through multiple triggers in quick succession, set the initial delay to 0s if an interest-triggered popover is already open.

:root:has(:interest-source) [interestfor] {
  interest-delay-start: 0s;
}

Use @starting-style to define the entry animation and transition-behavior: allow-discrete (along with display and overlay) to animate the exit from the top layer, gating the transitions behind @media (prefers-reduced-motion: no-preference). Declare transition-behavior: allow-discrete as a separate property after transition rather than inside the transition shorthand so older browsers do not ignore the entire transition declaration.

[popover].hovercard {
  opacity: 0;
  transform-origin: top;
}

[popover].hovercard:popover-open {
  opacity: 1;
  transform: translateY(0) scale(1);
}

@starting-style {
  [popover].hovercard:popover-open {
    opacity: 0;
    transform: translateY(-10px) scale(0.95);
  }
}

/* MANDATORY: Respect user preference for reduced motion by selectively applying transitions. */
@media (prefers-reduced-motion: no-preference) {
  [popover].hovercard {
    transition:
      opacity,
      transform,
      display,
      overlay;
    transition-duration: 0.4s;
    /* Separate after transition so the shorthand doesn't reset it or fail in older browsers */
    transition-behavior: allow-discrete;
  }
}

4. OPTIONAL: Seamlessly morphing to the next page

You can optionally use Cross-Document View Transitions to create a seamless "morph" animation between the hovercard and the destination page. See Consistent Cross-Document Transitions for guidance on implementation.

By assigning matching view-transition-name values to elements in the hovercard and their counterparts on the next page, the browser will animate them across the navigation. You can name multiple elements (e.g. titles, images, bylines) to create a complex, multi-element morphing effect. Assign the view-transition-name to elements on the open popover using :popover-open, to avoid creating duplicate view-transition-names or requiring link-specific view-transition-names.

/* Opt-in to cross-document transitions on both pages */
@view-transition {
  navigation: auto;
}

/* Don't show transitions when loading this page */
::view-transition-group(*) {
  animation-duration: 0s;
}

/* Assign names ONLY when the popover is open to ensure only the correct elements are selected. */
[popover]:popover-open {
  view-transition-name: --hovercard;
  view-transition-class: morph;

  h2 {
    view-transition-name: --title;
    view-transition-class: morph;
  }

  img {
    view-transition-name: --image;
    view-transition-class: morph;
  }
}

MANDATORY: To ensure the transition is stable and doesn't flicker, the destination page should use blocking="render" and <link rel="expect"> to wait for critical content to be parsed before rendering the first frame.

<!-- On the destination page: -->
<head>
  <!-- Block rendering until the hero image and title are parsed,
       ensuring the view transition snapshot is taken at the right time. -->
  <link rel="expect" href="#main-content" blocking="render">
</head>
<body>
  <main id="main-content">
    <h1>The buzz on hummingbirds</h1>
    <img src="bird.jpg">
  </main>
</body>
/* On the destination page: */
main {
  view-transition-name: --hovercard;
  view-transition-class: morph;
}
h1 {
  view-transition-name: --title;
  view-transition-class: morph;
}
img {
  view-transition-name: --image;
  view-transition-class: morph;
}

/* By default no transition */
::view-transition-group(*) {
  animation-duration: 0s;
}
/* Short transition for morphing elements */
::view-transition-group(.morph) {
  animation-duration: 0.4s;
}
/* Disable transition if no `old` view */
::view-transition-new(.morph):only-child {
  animation-duration: 0s;
}

Fallback strategies

Because the trigger is a standard <a href="..."> link, browsers without support can still navigate on click if you choose to treat the hovercard as a progressive enhancement. When cross-browser hovercard support is required, conditionally load polyfills as needed.

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 (!Object.hasOwn(HTMLElement.prototype, "popover")) {
  import("@oddbird/popover-polyfill");
}

If not using a package manager, import from a CDN, e.g. "https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js".

Caveats when using the polyfill:

  • Do NOT use the popover IDL attribute for any code that may run before the polyfill is fully loaded. It will not be mirrored as an attribute, the popover will not be picked up by the polyfill, and the property will be shadowing the accessor the polyfill adds forever. Use setAttribute()/removeAttribute() instead.
  • 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;
}

Fallbacks & browser support for popover="hint"

The @oddbird/popover-polyfill package does not polyfill popover="hint" in browsers that already support native popover (auto and manual). In those browsers, the unrecognized "hint" value defaults to popover="manual" (the HTML invalid value default), so a hovercard opened via keyboard focus may remain open if a second hovercard is opened via pointer hover unless explicitly dismissed on loss of interest by the invoker.

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 (!("interestForElement" in HTMLButtonElement.prototype)) {
    // 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 Anchor positioning

Without CSS Anchor Positioning, tethered elements such as [popover] overlays fall back to their default user-agent placement (centered in the viewport for popovers). Depending on whether spatial tethering is required for your use case, either conditionally load the polyfill or gate your positioning rules with @supports.

Option 1: Polyfill (@oddbird/css-anchor-positioning)

When the positioned element must stay spatially tethered to its trigger in unsupported browsers, conditionally load the @oddbird/css-anchor-positioning package.

MANDATORY: Feature-detect support via CSS.supports("anchor-name: --a") (or "anchorName" in CSSStyleDeclaration.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 anchor positioning support before loading the polyfill.
if (!CSS.supports("anchor-name: --a")) {
  import("@oddbird/css-anchor-positioning");
}

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

<!-- MANDATORY: Conditionally install the anchor positioning polyfill -->
<script type="module">
  if (!CSS.supports("anchor-name: --a")) {
    import("https://unpkg.com/@oddbird/css-anchor-positioning");
  }
</script>

Polyfill caveats:

  • No implicit anchors: The polyfill does not support implicit anchor associations (such as those created by popovertarget, commandfor, interestfor, or showPopover({ source })). You MUST set a unique explicit anchor-name on each trigger and a matching position-anchor on the positioned element.
  • No position-area with position-try-fallbacks: position-area with position-try-fallbacks is not supported by the polyfill. When using the polyfill on elements needing fallbacks, position elements using anchor() functions on inset properties (top, left, right, bottom) with inset: auto instead of position-area.
  • Do not wrap polyfilled rules in @supports: Browsers without native anchor positioning evaluate @supports (position-area: ...) and @supports (anchor-name: --a) to false and discard the block before the polyfill can process it.
.trigger {
  /* MANDATORY: Each trigger and positioned element pair must share a unique anchor name. */
  anchor-name: --my-trigger;
}

[popover].anchored-overlay {
  position-anchor: --my-trigger;
  /* MANDATORY: Use anchor() on inset properties rather than position-area when using the polyfill on elements needing position-try-fallbacks. */
  inset: auto;
  top: anchor(bottom);
  left: anchor(left);
  margin: 0;
}

Option 2: Progressive enhancement with @supports (no polyfill)

If you do not ship the polyfill, DO NOT set inset: auto or margin: 0 unconditionally on a [popover] element that relies on position-area or anchor(). In browsers without anchor positioning, inset: auto overrides the user-agent centering (inset: 0; margin: auto) while position-area is ignored, leaving the popover stranded at (0, 0) or overlapping its trigger.

Instead, gate inset: auto and margin: 0 inside @supports for the specific positioning feature used (for example, @supports (position-area: block-end) or @supports (top: anchor(bottom))) so unsupported browsers retain the default centered popover placement, or provide a matching @supports not (...) fallback layout (such as a fixed bottom sheet):

@supports (position-area: block-end) {
  [popover].anchored-overlay {
    inset: auto;
    margin: 0;
    position-area: block-end span-inline-end;
    position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
  }
}

@supports not (position-area: block-end) {
  /* Optional: override default popover centering with a fixed bottom bar */
  [popover].anchored-overlay {
    position: fixed;
    inset: auto 0 0 0;
    width: auto;
    margin: 0;
  }
}

Fallbacks & browser support for Cross-document view transitions

Cross-document view transitions are a progressive enhancement. In browsers that do not support @view-transition { navigation: auto; }, the rule (along with blocking="render" and <link rel="expect"> on the destination page) degrades gracefully and clicking the link performs a standard page navigation without the morph animation.