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. Usehintto avoid closing unrelated popover menus that may be open. - Add the
interestforattribute to the trigger link, pointing to the card'sid.
<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-labelledbyon 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
popovertargetwithpopovertargetaction="show"):role="tooltip"on the hint andaria-expandedon the trigger, kept in sync with the popover'stoggleevent. 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 andaria-describedbyon the trigger, neveraria-expanded. A trigger announced as "collapsed" suggestsEnterwould expand it, butEnternavigates 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-expandedfor 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, nofocus()in aninterestlistener). 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:
- Run in every browser, before loading the
interestforpolyfill, 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 onfocusorinterestis too late, and so is waiting for the polyfill, which wires its ARIA only onloadand adds norole="tooltip"to plain hints. - For every
[interestfor]trigger whose target haspopover="hint", normalize whitespace and compare the hint text with the trigger's accessible name (aria-labelledbytargets, elsearia-label, else text content including imagealt, elsetitle). Equal: do nothing. Otherwise setrole="tooltip"on the hint if it has no role. - When the trigger has no name, point
aria-labelledbyat the hint unless it already has one. Otherwise, if the trigger also opens the hint on activation (apopovertargetorcommandfornaming the hint), setaria-expandedto the popover's current state (:popover-open) unless it already hasaria-expanded, and remember which triggers the script owns; if not, pointaria-describedbyat the hint unless it already has one. - Keep
aria-expandedin sync from the popover'stoggleevent in every browser with a single capturing listener ondocument, updating every trigger with a matchinginterestforthat the script owns (theinterestforpolyfill does not syncaria-expandedon plain-text hints or when a toggletip is opened viapopovertarget).
Things that are easy to get wrong:
toggledoes not bubble. A bubbling listener ondocumentnever fires; use the capture phase (or a listener per popover).- Sync
aria-expandedfromtoggle, not frominterest/loseinterest. The popover is the source of truth: it can be closed byEscape, light dismiss, or another invoker, and several triggers can share one hint. - A hidden popover is a valid
aria-describedbyoraria-labelledbytarget. Do not force the hint open or duplicate its text to make it "visible". - Do not add
aria-haspopupor a live region to any hint. - Do not override author ARIA: an existing
aria-describedby,aria-labelledby, oraria-expandedon 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
popoverIDL 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. UsesetAttribute()/removeAttribute()instead. - Styling caveat: The polyfill cannot define the real
:popover-openpseudo-class, so it applies a.\:popover-openclass instead. MANDATORY: Combine the two with:is()or:where(), otherwise browsers that lack:popover-opendiscard 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-sourceand:interest-targetpseudo-classes, so it applies.interest-sourceand.interest-targetclasses 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, andinterest-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, orshowPopover({ source })). You MUST set a unique explicitanchor-nameon each trigger and a matchingposition-anchoron the positioned element. - No
position-areawithposition-try-fallbacks:position-areawithposition-try-fallbacksis not supported by the polyfill. When using the polyfill on elements needing fallbacks, position elements usinganchor()functions on inset properties (top,left,right,bottom) withinset: autoinstead ofposition-area. - Do not wrap polyfilled rules in
@supports: Browsers without native anchor positioning evaluate@supports (position-area: ...)and@supports (anchor-name: --a)tofalseand 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.