Hover Card
Preview
V
@vivek
Building webjs.
<ui-hover-card open-delay="300" close-delay="200">
<ui-hover-card-trigger>
<a class=${buttonClass({ variant: 'link' })} href="#">@vivek</a>
</ui-hover-card-trigger>
<ui-hover-card-content>
<div class="flex gap-3">
<span class=${avatarClass()} data-size="default">
<span class=${avatarFallbackClass()}>V</span>
</span>
<div>
<h4 class="font-semibold">@vivek</h4>
<p class="text-sm text-muted-foreground">Building webjs.</p>
</div>
</div>
</ui-hover-card-content>
</ui-hover-card> Installation
webjs ui add hover-card API Reference
Parts
| Name | Description |
|---|---|
<ui-hover-card> |
Root, open-delay / close-delay attributes. |
<ui-hover-card-trigger> |
Hoverable anchor. |
<ui-hover-card-content> |
Floating card. |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
open-delay |
number (ms) |
700 | |
close-delay |
number (ms) |
300 | |
side |
"top" | "right" | "bottom" | "left" |
"bottom" | Attribute on <ui-hover-card-content>. |
align |
"start" | "center" | "end" |
"center" | Attribute on <ui-hover-card-content>. |
side-offset |
number (px) |
4 | Attribute on <ui-hover-card-content>. |
align-offset |
number (px) |
0 | Attribute on <ui-hover-card-content>. Pixels offset along the align axis. |
Source: components/ui/hover-card.ts
/**
* HoverCard: popover-like panel triggered by hover with configurable
* open / close delays. Tier-2. The content uses the native Popover API
* in `popover="manual"` mode for top-layer rendering; the custom
* element owns the hover-with-linger state machine and JS positioning.
*
* shadcn parity:
* HoverCard → <ui-hover-card open-delay close-delay>
* HoverCardTrigger → <ui-hover-card-trigger>
* HoverCardContent → <ui-hover-card-content side align side-offset align-offset>
*
* Attributes on <ui-hover-card>:
* `open`: boolean (reflected). Open state.
* `open-delay`: ms, default 700. Hover delay before opening.
* `close-delay`: ms, default 300. Linger delay before closing once
* cursor leaves trigger + content.
*
* Attributes on <ui-hover-card-content>:
* `side`: "top" | "right" | "bottom" (default) | "left".
* `align`: "center" (default) | "start" | "end".
* `side-offset`: number, default 4. Pixels between trigger and content.
* `align-offset`: number, default 0. Pixels of cross-axis shift.
*
* Events: none dispatched at present; observe the reflected `open`
* attribute from CSS or JS.
*
* Programmatic API on <ui-hover-card>: `.show()` · `.hide()`.
*
* Keyboard:
* Tab from the trigger into the card, whose content stays open while it
* holds focus, so links and buttons inside it are reachable
* Escape dismiss the card, returning focus to the trigger when the card
* held it
*
* A11y (owned by the element, mostly nothing to supply):
* The trigger opens on focus as well as hover and carries `aria-haspopup` /
* `aria-expanded` / `aria-controls`. The panel is a `role="dialog"`, which
* REQUIRES an accessible name, so the element always gives it one, from the
* first of: an `aria-label` / `aria-labelledby` you put on
* `<ui-hover-card-content>`, a `[data-slot="hover-card-title"]` or heading
* inside the card, or the trigger itself (the card is about whatever the
* trigger names). Escape dismisses it and hands focus back when the card had
* it, and the content keeps itself open while focus is inside.
* What you DO supply: a name for the trigger control if it is icon-only, and
* ideally a title node in the card so its name describes the card rather
* than falling back to the trigger's text.
* Touch: the hover and focus linger paths are gated to pointer devices, so on
* a no-hover device the card is tap-toggled and dismissed by an outside tap
* (#745). Do not remove that gate to "fix" keyboard behaviour.
*
* Design tokens used: --popover, --popover-foreground, --border.
*
* @example
* ```html
* <ui-hover-card open-delay="700" close-delay="300">
* <ui-hover-card-trigger>
* <a href="/user/vivek">@vivek</a>
* </ui-hover-card-trigger>
* <ui-hover-card-content>
* <div class="flex gap-3">
* <img class="size-10 rounded-full" src="/avatars/vivek.jpg" alt="Vivek Khandelwal">
* <div>
* <div class="text-sm font-semibold" data-slot="hover-card-title">Vivek Khandelwal</div>
* <p class="text-sm text-muted-foreground">Builds the platform, not against it.</p>
* <a class="text-sm underline" href="/user/vivek/posts">Read the posts</a>
* </div>
* </div>
* </ui-hover-card-content>
* </ui-hover-card>
* ```
*/
import { WebComponent, html, prop } from '@webjsdev/core';
import { ensureId } from '../lib/utils.ts';
import { onBeforeCache } from '../lib/dom.ts';
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
// `fixed m-0` opts out of the UA `[popover]` auto-centering margin so
// JS-computed top/left from positionFloating lands correctly. shadcn's
// visual layer sits on top. UA `[popover]:not(:popover-open) {
// display: none }` handles closed-state hiding.
export const hoverCardContentClass = (): string =>
'fixed z-50 w-64 m-0 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden';
// --------------------------------------------------------------------------
// <ui-hover-card>
// --------------------------------------------------------------------------
export class UiHoverCard extends WebComponent({
open: prop(Boolean, { reflect: true }),
// `openDelay` / `closeDelay` ride the `open-delay` / `close-delay`
// attributes (shadcn parity), read as typed props.
openDelay: Number,
closeDelay: Number,
}) {
_showTimer: number | undefined;
_hideTimer: number | undefined;
_keyHandler = (e: KeyboardEvent): void => this._onKeyDown(e);
constructor() {
super();
this.open = false;
this.openDelay = 700;
this.closeDelay = 300;
}
_disposeBeforeCache?: () => void;
connectedCallback(): void {
super.connectedCallback?.();
// webjs projects slotted light-DOM children after the first render, so
// the trigger control is not in place at connect. Defer to the next
// frame, when the projection has run.
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => this._wireAria());
}
// Close before the page is cached for back/forward so a restored snapshot
// does not come back frozen open (#766).
this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
}
disconnectedCallback(): void {
this._unbindEscape();
this._disposeBeforeCache?.();
super.disconnectedCallback?.();
}
// A hover card can hold interactive content and cover what is under it, so a
// keyboard user needs a way out that is not "tab through the whole card".
// Bound only WHILE OPEN so a closed card cannot swallow Escape.
_onKeyDown(e: KeyboardEvent): void {
if (e.key !== 'Escape' || !this.open) return;
e.preventDefault();
this._dismiss();
}
// Immediate, unlike hide()'s close-delay linger, since a dismissal is
// deliberate. Focus moves out BEFORE the panel hides: the content is
// popover="manual", so hiding it while a descendant holds focus drops focus
// to <body> and the user loses their place on the page.
_suppressReopen = false;
_dismiss(): void {
clearTimeout(this._showTimer);
clearTimeout(this._hideTimer);
this._closeReleasingFocus();
}
_focusIsInContent(): boolean {
if (typeof document === 'undefined') return false;
const content = this.querySelector('ui-hover-card-content');
const active = document.activeElement;
return !!content && !!active && content.contains(active);
}
_bindEscape(): void {
if (typeof document === 'undefined') return;
document.addEventListener('keydown', this._keyHandler);
}
_unbindEscape(): void {
if (typeof document === 'undefined') return;
document.removeEventListener('keydown', this._keyHandler);
}
// The trigger also opens on focus (see the @focusin handler), so it is
// keyboard-reachable: expose the popup relationship on the focusable
// control. aria-expanded is refreshed on every open transition.
_control(): HTMLElement | null {
const t = this.querySelector('ui-hover-card-trigger');
if (!t) return null;
return (
t.querySelector<HTMLElement>('a[href], button, [tabindex], [role="button"]') ??
(t as HTMLElement)
);
}
_wireAria(): void {
const control = this._control();
if (!control) return;
control.setAttribute('aria-haspopup', 'dialog');
control.setAttribute('aria-expanded', String(this.open));
const content = this.querySelector<HTMLElement>('ui-hover-card-content [role="dialog"]');
if (!content) return;
control.setAttribute('aria-controls', ensureId(content, 'ui-hovercard'));
this._nameContent(content, control);
}
// role="dialog" REQUIRES an accessible name. Without one a screen reader
// announces a bare "dialog" with no hint of what it holds, which is an ARIA
// defect rather than a missing nicety. Three sources, most specific first:
// 1. what the author put on <ui-hover-card-content>, which always wins
// 2. a title node inside the card (the dialog.ts idiom)
// 3. the trigger, since the card is ABOUT whatever the trigger names
// ("@vivek" names that user's card). Same fallback the dropdown menu
// already uses to label its panel back to its trigger.
// Source 3 is what makes the name unconditional: there is no shape of this
// component that can end up with an unnamed dialog role.
_nameContent(content: HTMLElement, control: HTMLElement): void {
const host = this.querySelector('ui-hover-card-content');
// aria-labelledby is checked FIRST because it beats aria-label per accname,
// matching dialog / alert-dialog / toggle. Checking aria-label first would
// name this panel by the attribute that loses everywhere else, so an author
// writing both would get a different name here than in a dialog.
const authoredLabelledBy = host?.getAttribute('aria-labelledby');
if (authoredLabelledBy) {
content.setAttribute('aria-labelledby', authoredLabelledBy);
return;
}
const authoredLabel = host?.getAttribute('aria-label');
if (authoredLabel) {
content.setAttribute('aria-label', authoredLabel);
// This method re-runs on every open change, and the fallback below writes
// aria-labelledby unconditionally. So a card named from its title on an
// earlier pass still carries that reference, and it would outrank the
// author's aria-label. Drop it, as dialog / alert-dialog do.
content.removeAttribute('aria-labelledby');
return;
}
// Already named by an earlier pass (this runs again on every open change).
if (content.hasAttribute('aria-label') || content.hasAttribute('aria-labelledby')) return;
const title = content.querySelector<HTMLElement>(
'[data-slot="hover-card-title"], h1, h2, h3, h4, h5, h6',
);
content.setAttribute(
'aria-labelledby',
title
? ensureId(title, 'ui-hovercard-title')
: ensureId(control, 'ui-hovercard-trigger'),
);
}
// Back-compat getter.
get isOpen(): boolean { return this.open; }
show(): void {
// A keyboard dismissal has to stick: _dismiss() moves focus to the trigger,
// and that focusin lands right back here, which would reopen the card and
// make Escape look broken. The guard is released once the transfer flushes.
if (this._suppressReopen) return;
clearTimeout(this._hideTimer);
this._showTimer = window.setTimeout(() => { this.open = true; }, this.openDelay);
}
hide(): void {
clearTimeout(this._showTimer);
// Clear the PENDING hide before scheduling the next one. Overwriting the
// handle alone orphans the old timer, which then fires on its own and
// closes the card after a later show() thought it had cancelled the close.
// Reachable whenever two leave paths fire without a show() between them,
// and the focus linger below doubles how many of those paths exist.
clearTimeout(this._hideTimer);
this._hideTimer = window.setTimeout(() => {
// The card KEEPS ITSELF OPEN while focus is inside it. That is what makes
// in-card content Tab-reachable and what both doc surfaces promise, and a
// mouseleave can schedule this close while a keyboard user still holds
// focus on an in-card link. Closing then would pull the card out from
// under them, so leave it open: the content's own focusout schedules the
// close once focus has actually left, and that pass takes this branch.
if (this._focusIsInContent()) return;
this.open = false;
}, this.closeDelay);
}
// Every close of this popover="manual" panel owes the same focus care, not
// just Escape. The focus linger added here makes in-card content Tab-
// reachable, so a user CAN be holding focus on a link inside the card when a
// mouseleave-scheduled close fires; hiding the panel then drops focus to
// <body>. Guarded, so a close while focus is elsewhere leaves it alone.
//
// The reopen suppression is what makes this terminate. Moving focus to the
// trigger fires the trigger's own focusin, which calls show() and would
// reopen the card the close just closed. It is released on the next
// macrotask, once that synchronous focus transfer has been refused, so a
// genuinely new hover or a fresh Tab back in still opens the card.
_closeReleasingFocus(): void {
this._suppressReopen = true;
if (this._focusIsInContent()) this._control()?.focus();
this.open = false;
setTimeout(() => { this._suppressReopen = false; }, 0);
}
// Touch open: there is no hover delay and no mouseleave to close it, so open
// immediately and arm a one-shot outside-tap dismiss (a tap anywhere outside
// this card closes it). Deferred a tick so the opening tap itself does not
// immediately dismiss it.
openByTouch(): void {
clearTimeout(this._showTimer);
clearTimeout(this._hideTimer);
this.open = true;
const onOutside = (ev: Event): void => {
// Close on an outside tap; also self-remove if the card was already
// closed by other means (a re-tap toggle), so the listener never lingers.
if (!this.open || !this.contains(ev.target as Node)) {
// Same focus care as every other close path: the card may hold focus.
this._closeReleasingFocus();
document.removeEventListener('pointerdown', onOutside, true);
}
};
setTimeout(() => document.addEventListener('pointerdown', onOutside, true), 0);
}
render() {
return html`<div
data-slot="hover-card"
data-state=${this.open ? 'open' : 'closed'}
><slot></slot></div>`;
}
updated(changedProperties: Map<string, unknown>): void {
if (!changedProperties.has('open')) return;
if (changedProperties.get('open') === undefined) return;
// Bind synchronously rather than in the microtask below, which bails on
// engines with no Popover API: Escape has to work regardless of that.
if (this.open) this._bindEscape();
else this._unbindEscape();
// Wait one microtask for <ui-hover-card-content>'s inner [popover]
// element to commit; we drive its showPopover() / hidePopover() and
// refresh the trigger's aria-expanded.
queueMicrotask(() => {
this._wireAria();
this._syncContent();
});
}
_syncContent(): void {
// Same nested-popover pattern as tooltip: <ui-hover-card-content>
// renders an inner <div popover="manual">; the Popover API lives on
// that inner div, not the host.
const popover = this.querySelector<HTMLElement>('ui-hover-card-content [popover]');
const host = this.querySelector<HTMLElement>('ui-hover-card-content');
if (!popover || !popover.isConnected) return;
const p = popover as HTMLElement & {
showPopover?: () => void;
hidePopover?: () => void;
matches: (s: string) => boolean;
};
if (typeof p.showPopover !== 'function') return;
if (this.open) {
if (!p.matches(':popover-open')) p.showPopover();
if (host) this._reposition(host, popover);
} else if (p.matches(':popover-open')) {
p.hidePopover();
}
}
_reposition(contentHost: HTMLElement, popover: HTMLElement): void {
const trigger = this.querySelector<HTMLElement>('ui-hover-card-trigger');
if (!trigger) return;
positionFloating(trigger, popover, {
side: (contentHost.getAttribute('side') ?? 'bottom') as PopoverSide,
align: (contentHost.getAttribute('align') ?? 'center') as PopoverAlign,
sideOffset: Number(contentHost.getAttribute('side-offset') ?? 4),
alignOffset: Number(contentHost.getAttribute('align-offset') ?? 0),
});
}
}
UiHoverCard.register('ui-hover-card');
// --------------------------------------------------------------------------
// <ui-hover-card-trigger>
// --------------------------------------------------------------------------
export class UiHoverCardTrigger extends WebComponent {
render() {
return html`<div
data-slot="hover-card-trigger"
@mouseenter=${this._onEnter}
@mouseleave=${this._onLeave}
@focusin=${this._onEnter}
@focusout=${this._onLeave}
@click=${this._onClick}
><slot></slot></div>`;
}
// Hover/focus open + close are MOUSE affordances. On a no-hover (touch)
// device, iOS Safari still fires SYNTHETIC mouseenter/mouseleave (and
// focusin/focusout from the inner link) around a tap, which would otherwise
// immediately re-close a tap-opened card. Gate the hover handlers to pointer
// devices so on touch the card is driven only by the tap path (#745).
_noHover = (): boolean => !!window.matchMedia?.('(hover: none)').matches;
_onEnter = (): void => {
if (this._noHover()) return;
(this.closest('ui-hover-card') as UiHoverCard | null)?.show();
};
_onLeave = (): void => {
if (this._noHover()) return;
(this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
};
// Touch path. A touch device has no `mouseenter`, so a tap would fall through
// to the inner `<a href>` and navigate. On a no-hover device the trigger tap
// TOGGLES the card and NEVER navigates (the real link is reachable inside the
// opened card). It must ALWAYS preventDefault, including while open: the
// client router pushState()s on any bubble-phase `<a>` click that is not
// defaultPrevented, so a re-tap that fell through would push a history entry
// every time and Back would need N presses (#745).
_onClick = (e: Event): void => {
if (!window.matchMedia?.('(hover: none)').matches) return;
const card = this.closest('ui-hover-card') as UiHoverCard | null;
if (!card) return;
e.preventDefault();
e.stopPropagation();
if (card.open) card.open = false;
else card.openByTouch();
};
}
UiHoverCardTrigger.register('ui-hover-card-trigger');
// --------------------------------------------------------------------------
// <ui-hover-card-content>
// The mouseenter/mouseleave handlers keep the card open while the cursor
// is over the content itself (so it does not close during a brief
// mouseleave on the trigger if the user is moving toward the card).
// focusin/focusout mirror that linger for the KEYBOARD: the trigger closes
// the card on its own focusout, so without these, Tabbing from the trigger
// toward the card scheduled the close before focus could land inside, and
// the interactive content the JSDoc example shows was unreachable.
// --------------------------------------------------------------------------
export class UiHoverCardContent extends WebComponent {
render() {
return html`<div
data-slot="hover-card-content"
role="dialog"
popover="manual"
class=${hoverCardContentClass()}
@mouseenter=${this._onEnter}
@mouseleave=${this._onLeave}
@focusin=${this._onEnter}
@focusout=${this._onLeave}
><slot></slot></div>`;
}
// Hover/focus open + close are MOUSE affordances. On a no-hover (touch)
// device, iOS Safari still fires SYNTHETIC mouseenter/mouseleave (and
// focusin/focusout from the inner link) around a tap, which would otherwise
// immediately re-close a tap-opened card. Gate the hover handlers to pointer
// devices so on touch the card is driven only by the tap path (#745).
_noHover = (): boolean => !!window.matchMedia?.('(hover: none)').matches;
_onEnter = (): void => {
if (this._noHover()) return;
(this.closest('ui-hover-card') as UiHoverCard | null)?.show();
};
_onLeave = (): void => {
if (this._noHover()) return;
(this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
};
}
UiHoverCardContent.register('ui-hover-card-content');