Preview a link on hover or keyboard focus while keeping its normal navigation.

A hover card shows a preview of where a link goes, like a person's profile, to someone who pauses on it. The link still works as a link.

import "@ui-sdk/web/hover-card"

function mount(root: HTMLElement) {
  root.innerHTML = `
    <ui-hover-card>
      <ui-hover-card-trigger><a href="https://example.com/people/hollis">@hollis</a></ui-hover-card-trigger>
      <ui-hover-card-content hidden>
        <div data-slot="row">
          <span data-slot="initials" aria-hidden="true">HG</span>
          <div data-slot="stack">
            <p>Hollis Grant</p>
            <p>Design engineer on the payments team. Writes about forms and motion.</p>
            <p data-slot="hint">Joined March 2021</p>
          </div>
        </div>
      </ui-hover-card-content>
    </ui-hover-card>
  `
}

export { mount }
Hover @hollis, or Tab to it and wait, to open Hollis Grant's profile preview. Move the pointer away or press Escape to close it.

Installation

npm install @ui-sdk/web @ui-sdk/core

Import the component to register its elements. The import is safe on the server.

import "@ui-sdk/web/hover-card"

Composition

Use the following composition to build a ui-hover-card:

ui-hover-card
├── ui-hover-card-trigger
│   └── a
└── ui-hover-card-content
    └── ui-hover-card-arrow

The trigger wraps your own link (an <a>, so give it an href), and pressing it follows the link. The content is the preview, and the arrow is optional.

The card only adds a preview for people who pause on the link: everything in it should be reachable from the page the link goes to, since touch and screen reader users may never open it. For a tip on a button, use a Tooltip. For content that opens with a press, use a Popover.

Usage

Import the parts and put them together:

<script type="module">
  import "@ui-sdk/web/hover-card"
</script>

<ui-hover-card>
  <ui-hover-card-trigger
    ><a href="/people/hollis">@hollis</a></ui-hover-card-trigger
  >
  <ui-hover-card-content hidden>
    <p>Hollis Grant</p>
    <p>Design engineer on the payments team.</p>
  </ui-hover-card-content>
</ui-hover-card>

Positioning

side picks where the card opens, bottom by default, and align lines it up with the start, center or end of the link. The card flips to the other side when there isn't room and shifts along the link to stay on screen.

ui-hover-card-arrow follows the side the card actually lands on, including after a flip, and gets the same data-side. It points at the middle of the link and keeps arrow-padding away from the corners; data-arrow-clamped tells you when it can't reach the middle.

import "@ui-sdk/web/hover-card"
import "@ui-sdk/web/select"
import { Check, ChevronDown, createElement } from "lucide"

const sides = [
  { value: "top", label: "Top" },
  { value: "right", label: "Right" },
  { value: "bottom", label: "Bottom" },
  { value: "left", label: "Left" },
]
const aligns = [
  { value: "start", label: "Start" },
  { value: "center", label: "Center" },
  { value: "end", label: "End" },
]

const select = (
  name: string,
  label: string,
  options: { value: string; label: string }[],
  value: string
) => `
  <ui-select data-control="${name}" default-value="${value}">
    <ui-select-label>${label}</ui-select-label>
    <ui-select-trigger>
      <button>
        <ui-select-value></ui-select-value>
        ${createElement(ChevronDown, { "aria-hidden": "true" }).outerHTML}
      </button>
    </ui-select-trigger>
    <ui-select-content hidden>
      ${options
        .map(
          (option) => `
            <ui-select-item value="${option.value}">
              ${option.label}
              <ui-select-item-indicator>${createElement(Check, { "aria-hidden": "true" }).outerHTML}</ui-select-item-indicator>
            </ui-select-item>
          `
        )
        .join("")}
    </ui-select-content>
  </ui-select>
`

function mount(root: HTMLElement) {
  root.innerHTML = `
    <div data-slot="stack">
      <div data-slot="controls">
        ${select("side", "Side", sides, "bottom")}
        ${select("align", "Align", aligns, "center")}
      </div>
      <ui-hover-card>
        <ui-hover-card-trigger><a href="https://example.com/people/ines">@ines</a></ui-hover-card-trigger>
        <ui-hover-card-content hidden side="bottom" align="center" side-offset="10">
          <ui-hover-card-arrow></ui-hover-card-arrow>
          <p>Inès Moreau</p>
          <p>Product designer. Leads the checkout redesign.</p>
        </ui-hover-card-content>
      </ui-hover-card>
    </div>
  `
  const content = root.querySelector("ui-hover-card-content")!
  for (const control of root.querySelectorAll("ui-select")) {
    control.addEventListener("valuechange", (event) => {
      if (event.detail.value) {
        content.setAttribute(
          control.dataset.control!,
          String(event.detail.value)
        )
      }
    })
  }
}

export { mount }
Choose a side and an alignment, then hover @ines. The card opens there and its arrow points at the link.

side-offset is the gap to the link, 4 pixels by default. align-offset shifts the card along it, and collision-padding is the space kept from the edges of the window. anchor positions the card against something other than the link, named by a CSS selector.

<ui-hover-card-content
  side="right"
  align="start"
  side-offset="8"
  collision-padding="16"
  anchor="#row"
  hidden
></ui-hover-card-content>

Delays

delay is how long the pointer rests on the link, or keyboard focus stays on it, before the card opens: 600 milliseconds by default. close-delay is how long it stays open once the pointer leaves the link and the card: 300 milliseconds by default. The pointer can cross the gap between the link and the card without closing it.

disabled stops the card from opening and leaves a plain link.

<ui-hover-card delay="300" close-delay="150" disabled></ui-hover-card>

Controlled

Set the element's open property and listen for openchange; the open attribute sets where it starts. Every change carries a reason in event.detail.reason, like trigger-hover, trigger-focus, focus-out, escape-key or outside-press. Call event.preventDefault() to stop it.

<ui-hover-card></ui-hover-card>

<script type="module">
  const card = document.querySelector("ui-hover-card")
  card.addEventListener("openchange", (event) => (open = event.detail.value))
  card.open = true
</script>

A feed or a list can share one card instead of rendering one per link. Give the card an id and name it from each trigger with for. Each trigger takes a payload property, like its person, read when it connects, so set it before the trigger goes into the page. The card's payloadchange event carries it in event.detail.value; render the content from it there. Only the link that opened the card gets data-open. The card's api.open({ payload }) opens it from code.

<ui-hover-card id="profile">
  <ui-hover-card-content hidden><p></p></ui-hover-card-content>
</ui-hover-card>

<script type="module">
  const card = document.querySelector("#profile")
  for (const person of people) {
    const trigger = Object.assign(
      document.createElement("ui-hover-card-trigger"),
      { payload: person }
    )
    trigger.setAttribute("for", "profile")
    trigger.innerHTML = `<a href="${person.url}">${person.name}</a>`
    feed.append(trigger)
  }
  card.addEventListener("payloadchange", (event) => {
    card.querySelector("p").textContent = event.detail.value?.bio
  })
</script>

A card can have links of its own. Tab from a link with an open card moves into the card, and Tab past its end moves on to the page after the link. Focus leaving the card closes it.

Container

Cards render in place and show in the top layer, so no ancestor clips or covers them. container renders the card somewhere else, named by a CSS selector, for iframes, shadow roots or a specific stacking context. See the shared popup guide.

<ui-hover-card-content container="#card-host" hidden></ui-hover-card-content>

Animation

The content has data-starting-style while it opens and data-ending-style while it closes. Transition from those states, and the card stays mounted until the exit finishes. openchangecomplete fires after the transition, and keep-mounted keeps the closed card in the page, hidden, for animation libraries that drive the exit from state.

[data-slot="hover-card-content"] {
  transition:
    opacity 150ms,
    scale 150ms;

  &:is([data-starting-style], [data-ending-style]) {
    opacity: 0;
    scale: 0.96;
  }
}

Custom Rendering

Elements have no render functions. The trigger wraps your own link, so the link can hold anything, like an avatar and a name. The link gets data-open while the card is open from it.

<ui-hover-card-trigger>
  <a href="/people/hollis">
    <span aria-hidden="true">HG</span>
    Hollis Grant
  </a>
</ui-hover-card-trigger>

Styling

Each element has a data-slot, such as hover-card-content, and shows its state through the attributes listed under Data Attributes. Use data-open on the trigger's link to mark the link whose card is showing, and data-side to style the content and arrow for the side they landed on. Put transform-origin: var(--transform-origin) on the content so it scales from the link.

Keep overflow visible around an arrow that extends outside the card. If the body needs to scroll, put scrolling on an inner element.

<ui-hover-card-content>

VariableWhat it holds
--anchor-widthWidth of the anchor, usually the trigger. Use it to match widths.
--anchor-heightHeight of the anchor.
--available-widthSpace beside the popup, up to the viewport edge. Not inherited either.
--available-heightSpace between the anchor and the viewport edge. Use it for max-height. Not inherited, so a child that needs it copies it on the popup, like [--list-height:var(--available-height)], and reads max-h-(--list-height). The first popup registers both names for the whole page with CSS.registerProperty, so they don't inherit anywhere. A page that needs them to inherit calls it first, with inherits: true. An @property rule can't, since a registration in script wins.
--transform-originThe point on the anchor the popup opens from. Use it to scale from the right place.
--arrow-offsetWhere the anchor's center falls along the popup's edge, kept arrowPadding from the corners. Position a ::before arrow with it.

Data Attributes

<ui-hover-card-trigger>

[data-slot="hover-card-trigger"]

AttributeWhat it means
data-closedPresent while the card is closed, including while it animates out.
data-openPresent while the card is open. On a trigger, while it's open from that trigger.

<ui-hover-card-content>

[data-slot="hover-card-content"]

AttributeWhat it means
data-closedPresent while the card is closed, including while it animates out.
data-openPresent while the card is open. On a trigger, while it's open from that trigger.
data-starting-stylePresent for the first frame. Style the state you animate from.
data-ending-stylePresent while the card animates out. Style the state you animate to.

<ui-hover-card-arrow>

[data-slot="hover-card-arrow"]

AttributeWhat it means
data-closedPresent while the card is closed, including while it animates out.
data-openPresent while the card is open. On a trigger, while it's open from that trigger.

API Reference

Props are attributes in kebab case, like close-delay and side-offset: "false" is false, and numbers are numbers. anchor and container take CSS selectors, and controller is a property. Callbacks are DOM events.

HoverCard

container?DocumentFragment | Element | null
Renders the popup into this element instead of in place, like a container inside a shadow root or an iframe. On web components, a CSS selector. Nested popups stay inside their parent.
open?boolean
Whether the card is open. Pass it to control the card.
defaultOpen?boolean
Whether the card starts open, when uncontrolled.
openchange event
Runs when the card wants to open or close. details.reason says why, and details.cancel() stops it.
openchangecomplete event
Runs once the card has finished opening or closing, after any animation.
payloadchange event
Runs when the payload the content shows changes: a trigger with another payload opens the card, or takes the open card over, which fires no openchange, and so does a controller's open({ payload }) on the open card. details.cancel() keeps the previous payload.
delay?number
Milliseconds the pointer rests on the trigger, or keyboard focus stays on it, before the card opens.
closeDelay?number
Milliseconds before the card closes once the pointer leaves the trigger and the card.
disabled?boolean
Stops the card from opening. The trigger keeps working as a link.
controller?PopupController<any, HoverCardOpenChangeReason, {}, string, HoverCardPayloadChangeReason>
Opens, closes and inspects the card from anywhere, and connects triggers outside it. Create one with createHoverCardController().
debug?boolean
Logs every state change with its reason to the console, in development builds.

HoverCardTrigger

A trigger outside the card names it with for="card-id", and takes payload as a property.

payload?unknown
Data for the content to render when this trigger opens the card, like the profile it links to.
controller?PopupController<any, HoverCardOpenChangeReason, {}, string, HoverCardPayloadChangeReason>
Connects a trigger outside the card, like every author link in a feed, to the card with this controller.

HoverCardContent

side?Side
Which side of the anchor the popup opens on. Flips to the other side when there isn't room. Which side of the trigger the card opens on.
align?Align
How the popup lines up with the anchor along that side. start and end follow the reading direction. How the card lines up with the trigger along that side.
sideOffset?number
Gap between the anchor and the popup, in pixels. Gap between the trigger and the card, in pixels.
alignOffset?number
Shift along the anchor, in pixels. Shift along the trigger, in pixels.
collisionPadding?number
Space to keep from the viewport edges, in pixels.
arrowPadding?number
Space to keep between the arrow and the popup's corners, in pixels.
anchor?string | Anchor | null
Positions the popup against this instead of its trigger: an element, a CSS selector, a point like { x, y }, or anything with getBoundingClientRect(). It's read on every check, so it can move. A controller's open({ anchor }) wins for that open.
container?DocumentFragment | Element | null
Renders the popup into this element instead of in place, like a container inside a shadow root or an iframe. On web components, a CSS selector. Nested popups stay inside their parent.
keepMounted?boolean
Whether the card stays in the page, hidden, while closed. For animation libraries that drive the exit from state.

HoverCardArrow

Points at the link from the side the card landed on. <ui-hover-card-arrow> is positioned on the content's edge.

State

The same state shows as data attributes on each element.

HoverCardTrigger:

openboolean
Whether the card is open from this trigger.

HoverCardContent:

openboolean
Whether the card is open. False while it animates out.
payloadPayload | undefined
The payload of the last open, from the trigger that opened it or a controller, typed as the controller's Payload.

HoverCardArrow:

openboolean
Whether the card is open. False as soon as it starts closing, while it still animates out.

Reasons

event.detail.reason says why the card opened or closed.

trigger-hoverPointerEvent
The pointer rested on a trigger, or left it, with hover opening.
trigger-focusFocusEvent
Keyboard focus reached a trigger that opens on focus, like a tooltip's.
trigger-pressMouseEvent | KeyboardEvent | TouchEvent
Click, tap, Enter or Space on the part that opens, closes or toggles. Arrow keys that open a menu or submenu count too. A tap on a combobox's input that opens a sheet reports its touchend.
focus-outKeyboardEvent | FocusEvent
Focus left the layer, or Tab moved it out.
escape-keyKeyboardEvent
Escape, or a submenu's close key (ArrowLeft in left-to-right layouts).
close-pressMouseEvent | KeyboardEvent | SubmitEvent
A Close part was pressed, or a form with method="dialog" was submitted.
outside-pressMouseEvent
A press outside the layer.
window-blurFocusEvent
The window lost focus.
backEvent
Going back: the Android back button or gesture, or Backspace in an empty command input, which returns to the previous page.
imperativenone
A controller or api call, or a property set on a web component.

For a payload change, it says what opened the card or took it over.

trigger-hoverPointerEvent
The pointer rested on a trigger, or left it, with hover opening.
trigger-focusFocusEvent
Keyboard focus reached a trigger that opens on focus, like a tooltip's.
trigger-pressMouseEvent | KeyboardEvent | TouchEvent
Click, tap, Enter or Space on the part that opens, closes or toggles. Arrow keys that open a menu or submenu count too. A tap on a combobox's input that opens a sheet reports its touchend.
imperativenone
A controller or api call, or a property set on a web component.

Controller

Create one with createHoverCardController<Payload>() from @ui-sdk/web/hover-card and assign it to the root element's controller property. The root element's api property has the same methods, and its open property reads and sets whether the card is open. Payload is the type of the data triggers and open() pass.

Every write method takes an optional { reason, event }, passed to openchange.

isOpen()() => boolean
Whether it's open. False as soon as it starts closing, while it still animates out.
setOpen(open, options?)(open: boolean, options?: PopupChangeOptions<HoverCardOpenChangeReason> | undefined) => boolean
Opens or closes it. Returns false when the change was canceled or it was already open or closed.
open(options?)(options?: (PopupChangeOptions<HoverCardPayloadChangeReason> & { payload?: Payload | undefined; anchor?: Anchor | undefined; signal?: AbortSignal | undefined; }) | undefined) => Promise<...>
Opens it, and resolves once it closes, with why, how and any return value. Resolves exactly once and never rejects: on the next close from any source, when signal aborts, or with reason "unmount" when the component leaves the page.
close(options?)(options?: (PopupChangeOptions<HoverCardOpenChangeReason> & { returnValue?: string | undefined; }) | undefined) => boolean
Closes it. returnValue is passed to the promise open() returned. Returns false when the close was canceled or it was already closed.
toggle(options?)(options?: PopupChangeOptions<HoverCardOpenChangeReason> | undefined) => boolean
Opens it when it's closed, and closes it when it's open. Returns false when the change was canceled.
getPayload()() => Payload | undefined
The payload of the last open, from a controller or the trigger that opened it.
focus()() => void
Moves focus to the trigger that opened it last, or the first trigger.

Events

Events fire on their own element and don't bubble, with the new state in event.detail.value and the reason and event beside it. Calling preventDefault() on openchange cancels the change.

MemberOnDescription
open property, attributeui-hover-cardWhether the card is open. The attribute sets where it starts, and is live
api propertyui-hover-cardThe controller
controller propertyui-hover-cardA controller from createHoverCardController(), to drive it from code
openchange eventui-hover-carddetail: { value: open, reason, event }
openchangecomplete eventui-hover-carddetail: { value: open }, after the animation
payloadchange eventui-hover-carddetail: { value: payload, reason, event }, when the content's payload changes
payload propertyui-hover-card-triggerData for the content, read when the trigger connects
for attributeui-hover-card-triggerThe id of a card the trigger opens from outside it

Accessibility

The trigger stays a plain link, and the card is a preview beside it, not a dialog. Every line below is an automated test.

Keyboard

KeyDoes
TabOn the link, opens the card after delay; with the card open, moves into it
EnterFollows the link
EscapeCloses the card and leaves focus on the link

Keyboard focus opens the card after the same delay as the pointer, so tabbing through a page of links doesn't flash a card at every stop. Focus leaving the card closes it.

Touch screens have no hover, so a tap follows the link without opening the card. Anything in the card should be on the page the link goes to.

Tested Behavior

Pointer
  • Resting the pointer on the link opens the card.
  • The card waits for its delay before opening.
  • Moving the pointer away closes the card after its close delay.
  • Moving the pointer into the card keeps it open.
  • Opening on hover leaves focus where it was.
  • Clicking the link follows it.
Keyboard
  • Keyboard focus on the link opens the card after the delay.
  • Escape closes the card and leaves focus on the link.
  • Enter follows the link.
Positioning
  • The card opens below the link.
  • The card opens on the requested side.
Animation
  • The card animates in from its starting style.
  • The card stays in the page while it animates out.
Focus, touch and reasons
  • A click that focuses the link doesn't open the card from focus.
  • Leaving the link before the delay keeps keyboard focus from opening it later.
  • A tap follows the link without opening the card.
  • Changes report why the card opened and closed.

Runs in React, Vue and web components.

Also tested:

Enhancements
  • Tab from the link moves into the open card.
  • Shift+Tab from the card returns to the link.
  • Tab out of the card moves on past the link and closes it.
  • Escape inside the card closes it and returns focus to the link.
  • The card stays open while focus is in it, even when the pointer leaves.

Runs in React, Vue and web components.

Screen readers hear the same thing in each tested framework, recorded by a virtual screen reader:

  • Announcements while tabbing into the card and out.

Runs in React, Vue and web components.