Hover card
A preview card that opens when a link is hovered or focused, for content sighted users can glance at.
pnpm dlx shadcn@latest add https://hextaui.com/r/hover-card.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
Add the theme tokens to your global CSS, if you haven’t yet.
Install the dependencies.
pnpm add @base-ui/react cnCopy and paste the following code into your project.
components/ui/hover-card.tsx lib/motion.ts Update the import paths to match your project setup.
A hover card is a preview, not a menu or a dialog. The trigger stays a normal link, so everything in the card must also be on the page it links to.
Side
Set side and align on <HoverCardContent />. Logical sides like inline-end follow the reading direction, and the card flips or shifts when it would leave the screen.
Delay
delay and closeDelay on the trigger set how long the pointer must rest before the card opens and how long it lingers after leaving. The 600ms default stops cards from flashing open as the pointer crosses a page.
Inline link
Use render to make the trigger any link, including one inside a sentence. When a link wraps onto two lines, the card anchors to the line you hovered.
Built on Base UI primitives, styled with Tailwind CSS and theme tokens, and ready to copy into your project.
Interactive content
Move the pointer from the link into the card and it stays open, so links and buttons inside can be clicked. The path between them is forgiving, so a diagonal move doesn’t close it.
Shared card
One card serves many links. Create a handle with createHoverCardHandle, give each trigger a payload, and read it in the card. Moving between names glides the card to the new link instead of closing and reopening it. The old content slides out the way you moved, the new content slides in, and the height eases between the two.
Arrow
arrow adds a pointer that joins the card's border without a seam. The side offset grows to make room for it, and it follows the card when it flips.
Loading content
Start fetching in onOpenChange and show a skeleton until the data arrives. When the content changes, the card eases to its new height instead of jumping.
Controlled
Pass open and onOpenChange. The second argument says why it changed, such as trigger-hover, trigger-focus or escape-key.
Open: false · last reason: none
Long content
Unbroken text wraps inside the card, and a card taller than the space beside the trigger scrolls instead of leaving the screen.
Right to left
The card reads the trigger’s direction, so logical sides and alignment flip and the scale animation grows from the correct corner.
| Key | Action |
|---|---|
| Tab | Focusing the trigger opens the card after the same delay as hover. Moving focus on closes it. |
| Enter | Follows the link, like any other link. |
| Esc | Closes the card. |
- The card is a visual extra for sighted mouse and keyboard users. Screen readers only hear the link, so they aren’t forced through a preview on every link they pass.
- Nothing opens on touch screens, where there is no hover. A tap follows the link, which is why the destination has to hold the same information.
- Focus never moves into the card. If it needs controls that must be reachable from the keyboard, use a popover instead.
- With reduced motion on, the card fades without scaling, and a shared card jumps between links instead of gliding.
Built on the Base UI preview card. Every part accepts the props of the primitive it wraps.
| Prop | Type | Default |
|---|---|---|
open | boolean | – |
defaultOpen | boolean | false |
onOpenChangedetails.reason is trigger-hover, trigger-focus, trigger-press, outside-press, escape-key, imperative-action or none. | (open: boolean, details) => void | – |
onOpenChangeCompleteCalled after the open or close animation ends. | (open: boolean) => void | – |
handleConnects triggers rendered outside the root. | HoverCardHandle<Payload> | – |
childrenUse the function form to read the payload of the trigger that opened the card. | ReactNode | ({ payload }) => ReactNode | – |
actionsRef | RefObject<{ close, unmount }> | – |
| Prop | Type | Default |
|---|---|---|
href | string | – |
delayMilliseconds before hover or focus opens the card. | number | 600 |
closeDelayMilliseconds the card stays open after leaving. | number | 300 |
handle | HoverCardHandle<Payload> | – |
payloadPassed to the card when this trigger opens it. | Payload | – |
renderRender your own link, such as <Button variant="link" /> or a router link. | ReactElement | (props, state) => ReactElement | <a> |
| Attribute | Description |
|---|---|
data-slot="hover-card-trigger" | Target the trigger in CSS. |
data-popup-open | Present while this trigger’s card is open. |
| Prop | Type | Default |
|---|---|---|
side | "top" | "bottom" | "left" | "right" | "inline-start" | "inline-end" | "bottom" |
align | "start" | "center" | "end" | "center" |
arrowShow a pointer toward the trigger. | boolean | false |
sideOffset | number | OffsetFunction | 6, or 10 with arrow |
alignOffset | number | OffsetFunction | 0 |
collisionPaddingSpace kept between the card and the viewport edge. | number | Rect | 8 |
collisionAvoidanceWhether the card flips, shifts or does nothing on collision. | CollisionAvoidance | – |
sticky | boolean | false |
anchorPosition against something other than the trigger. | Element | RefObject | VirtualElement | – |
positionMethod | "absolute" | "fixed" | "absolute" |
disableAnchorTracking | boolean | false |
portalPropsProps for the portal, such as container. | HoverCardPortalProps | – |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="hover-card-content" | Target the card in CSS. |
data-open | Present while the card is open. |
data-starting-style | Present while the card animates in. |
data-ending-style | Present while the card animates out. |
data-instant | focus when keyboard focus opened the card, dismiss when Escape or an outside press closed it. The exit animation is skipped while it’s set. |
data-side | The side the card settled on after collisions. |
data-align | The alignment it settled on. |
--transform-origin | Where the scale animation grows from, next to the trigger. |
--available-width | Room left beside the trigger. The card never grows past it. |
--available-height | Room left above or below. Taller content scrolls. |
| Positioner attribute | Description |
|---|---|
data-slot="hover-card-positioner" | The element that moves. It glides when a shared card switches links. |
data-anchor-hidden | Present when the trigger scrolls out of view. |
| Inner parts | Description |
|---|---|
data-slot="hover-card-viewport" | Wraps the content. Carries data-activation-direction while a shared card switches links. |
data-slot="hover-card-body" | Your content. Its height eases when it changes. |
data-slot="hover-card-arrow" | The pointer, with data-side for its edge. |
--popup-height | Set on the card while it resizes between links. |
| Prop | Type | Default |
|---|---|---|
container | HTMLElement | ShadowRoot | RefObject | null | document.body |
keepMounted | boolean | false |
Returns a handle for detached triggers. Its open(triggerId) and close() methods control the card from event handlers, and isOpen reads its state. Pass a type argument to type the payload.