HoverCard
A floating card that appears when a trigger is hovered or focused. Use for lightweight previews — user profiles, link previews, citations, or footnote definitions. Built on Base UI's preview-card primitive.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
HoverCard,
HoverCardTrigger,
HoverCardContent,
HoverCardHeader,
HoverCardTitle,
HoverCardDescription,
HoverCardBody,
HoverCardFooter,
HoverCardArrow,
} from "@tessinaui/ui";<HoverCard>
<HoverCardTrigger render={<button>@blaz</button>} />
<HoverCardContent side="bottom" align="start">
<HoverCardHeader leading={<Avatar initials="BZ" />}>
<HoverCardTitle>Blaz Zorcic</HoverCardTitle>
<HoverCardDescription>Designer · Building TessinaUI.</HoverCardDescription>
</HoverCardHeader>
<HoverCardFooter>
<Button variant="outline" size="xs">Message</Button>
<Button size="xs">Follow</Button>
</HoverCardFooter>
<HoverCardArrow />
</HoverCardContent>
</HoverCard>Examples
Default
A user profile preview with an avatar, description, metadata, and footer actions.
Sizes
Four widths — sm, md, lg, and xl — each pairing a fixed width with a text scale.
Rounded
Corner radius from none to full, matched to the trigger for visual cohesion.
Intent border
Give a status preview a coloured border with intent. The border follows the intent on its own — showIntentBorder is only there to override it.
Positioning
Place the card on any side of the trigger — top, right, bottom, or left.
Loading
HoverCardSkeleton mirrors the card's avatar, text, and optional stats while content loads.
Member card
The compact form — who they are and the two things you want to do with them.
Metadata rows
The most common card in production software: a title, a short stack of icon/label rows, and the actions. A metadata row is two spans and an icon, so it stays your composition rather than another exported part.
Media
HoverCardMedia bleeds to the frame and is clipped by the card's own corner arc. Two shapes ride the same slot: a banner behind an avatar, and a thumbnail above a link preview.
Stats
A figure row under the description, for company and entity cards.
Scrollable
A card taller than the space beneath its trigger caps at the measured height and scrolls its body. The header and footer hold their height while the body moves.
Touch
Hovering does not exist on a phone. touch="press" (the default) opens the card on a tap; touch="off" keeps the card pointer-only and leaves the trigger's own tap action alone.
When to use HoverCard vs. Tooltip vs. Popover
| Use HoverCard when… | Use Tooltip when… | Use Popover when… |
|---|---|---|
| The preview is rich (avatar, description, actions) | You only need short informational text | The user needs interactive controls (forms, lists) |
| Trigger is hover/focus (mouse + keyboard) | Trigger is hover/focus and content is non-interactive | Trigger is click |
| A tap opens the preview on touch | Touch users gracefully don't see the tip | Works equally on touch + mouse |
| Examples: user profile, link preview, citation | Examples: button label, icon meaning | Examples: filter dropdown, rename input |
The card is supplementary. Everything inside it must be reachable some other way. A portalled popup cannot sit in the tab order after its trigger, so a keyboard user tabbing forward will never land inside the card — interactive content there is a shortcut, never the only route. When the content is the point rather than a preview, reach for
Popover.
Touch
Base UI's hover interaction is mouse-only: a touch never opens a preview card. Left alone that makes the component dead on every phone, so the trigger adds a press gesture for finger and pen input.
touch | Behaviour |
|---|---|
"press" (default) | A tap opens the card. Tap again, tap outside, or press Escape to dismiss. On a link trigger the first tap opens the card instead of navigating — the card carries the real link. |
"off" | Pointer-only. The trigger keeps its own tap action; pair it with a Popover when phones need this content too. |
The gesture is keyed off the live event's pointerType, not a (pointer: coarse) media query: a touchscreen laptop reports coarse: false while its screen is being touched, and an iPad with a trackpad has both pointers at once. A query evaluated at mount answers for the device, not for the gesture actually in hand.
Anatomy
Only HoverCard, HoverCardTrigger, and HoverCardContent are required. Everything else composes optionally inside HoverCardContent.
<HoverCard>
<HoverCardTrigger>…</HoverCardTrigger>
<HoverCardContent>
<HoverCardMedia />
<HoverCardHeader>
<HoverCardTitle />
<HoverCardDescription />
</HoverCardHeader>
<HoverCardBody />
<HoverCardSeparator />
<HoverCardFooter />
<HoverCardArrow />
</HoverCardContent>
</HoverCard>Size
Widths are fluid ranges, not fixed: a compact card shrinks to its min, a rich one grows to its max, and both clamp to 100dvw - 2rem so the card never touches the bezel on a phone.
| Size | Width | Text |
|---|---|---|
"sm" | 208–256px | xs |
"md" (default) | 240–288px | sm |
"lg" | 272–320px | sm |
"xl" | 320–384px | base |
The card is also capped at the height the positioner measured (--available-height). Past that, HoverCardBody scrolls while the header and footer stay put — the popup itself is the clipping frame and never scrolls, because an overlay scrollbar would paint straight through its corner arc.
Intent
intent drives the border colour and the arrow's stroke, and it applies itself: any value but "none" paints the border. The popup is borderless at intent="none" — it takes its definition from shadow-lg and the surface contrast, which is what lets the arrow merge seamlessly with the card edge. Pass showIntentBorder={false} to keep the semantic intent without the stroke.
| Value | Border |
|---|---|
"none" (default) | none — there is no neutral border state |
"error" | border-error |
"warning" | border-warning |
"success" | border-success |
"info" | border-info |
Rounded
| Value | CSS |
|---|---|
"none" | rounded-none |
"sm" | rounded-md |
"md" (default) | rounded-lg |
"lg" | rounded-xl |
"full" | rounded-3xl |
Positioning
HoverCardContent accepts side (top | right | bottom | left), align (start | center | end), sideOffset (px gap from trigger, default 18, raised to a floor of 21 whenever the arrow is shown), and alignOffset (px shift along the side, default 0).
collisionPadding (default 8) is the minimum gap kept from the viewport edge. It is grown by the device's safe-area insets, so the card clears the home indicator and the notch rather than stopping underneath them.
<HoverCardContent side="right" align="start" sideOffset={12}>
…
</HoverCardContent>Delays
Pass delay (open) and closeDelay (close) on the root, in milliseconds. Defaults match Base UI: delay = 600, closeDelay = 300.
<HoverCard delay={300} closeDelay={150}>
…
</HoverCard>The values propagate to HoverCardTrigger via context — override per-trigger if you have multiple triggers in one card group.
Header slots
HoverCardHeader takes optional leading (avatar / icon) and trailing (badge / status) slots in addition to its title + description children.
<HoverCardHeader
leading={<Avatar initials="BZ" />}
trailing={<Badge variant="dot" color="success" />}
>
<HoverCardTitle>Blaz Zorcic</HoverCardTitle>
<HoverCardDescription>Online now</HoverCardDescription>
</HoverCardHeader>Matching button corners to the card
HoverCardFooter is a generic slot — buttons inside don't auto-inherit the card's rounded. Pass rounded explicitly on each button to keep its corners flowing into the card edge:
<HoverCard rounded="none">
<HoverCardTrigger render={<button>@blaz</button>} />
<HoverCardContent>
…
<HoverCardFooter>
{/* match the HoverCard's rounded prop */}
<Button variant="outline" size="xs" rounded="none">Message</Button>
<Button variant="primary" size="xs" rounded="none">Follow</Button>
</HoverCardFooter>
</HoverCardContent>
</HoverCard>Controlled
HoverCard accepts open, defaultOpen, and onOpenChange.
const [open, setOpen] = React.useState(false);
<HoverCard open={open} onOpenChange={setOpen}>
…
</HoverCard>Accessibility
- The trigger renders a real
<button type="button">, so it is focusable and announced. (Base UI's own default host is an<a>, which without anhrefis neither — paired with a mouse-only hover interaction that left a default trigger with no keyboard path and no touch path at all.) - The card opens on hover, keyboard focus, or a tap, and closes on Escape or an outside press. The pointer can travel from the trigger into the card without it closing, and nothing auto-hides while the trigger is still hovered — the three requirements of WCAG 1.4.13.
- The popup is a named
group, labelled byHoverCardTitle— never adialog. Nothing is modal, focus is never trapped, and a portalled popup cannot follow its trigger in the tab order. HoverCardTitlerenders a<div>, not a heading. The popup is portalled todocument.body, so an<h3>there spliced a phantom level into the page's heading outline every time a card opened.- The trigger is described by the card only when a press opened it. A preview announced on every passing focus is the disruption Base UI hides these cards from assistive tech to avoid; a deliberate press is a request for the preview, so answering it then is not an interruption.
- Give inline triggers a real 44px tap target — the examples do it with an invisible
before:pseudo-element so the line height is untouched (WCAG 2.5.5). - Use
intent="error"or"warning"to give status previews a visual signal that complements text content — the border comes with it.
Assistive tech matrix
| Screen reader | Status | What was heard |
|---|---|---|
| VoiceOver (macOS / Safari) | Verified | "@blaz button" — announced as a button with its name. After the card opened on focus, three successive captures were still the button: the reader is not walked through the preview. |
| Plain keyboard, no screen reader (WebKit) | Verified | Tab off the trigger closes the card and moves past it — the portalled content never enters the tab order. This is the contract: the card is supplementary, so nothing inside it may be the only route to an action. |
| VoiceOver, stepping past the trigger | Verified — differs from plain keyboard | With VO running the card stays open and VO does reach its Message / Follow buttons. Reachable is better than stranded, but do not design for it: the plain-keyboard row above is the guarantee. |
| VoiceOver — the press path | Structural only | A tap wires aria-describedby to the card (verified on iPhone 13 and Pixel 5 profiles), but VoiceOver on the desktop has no touch pointer to press with, so this was not confirmed audibly. |
| NVDA | Untested | No Windows machine in this project's loop. |
| JAWS | Untested | No Windows machine in this project's loop. |
RTL
Pass dir="rtl" to the root. Header alignment, the leading/trailing slots, and footer button order all flip to follow the reading direction.
<HoverCard dir="rtl">
<HoverCardTrigger render={<button>@blaz</button>} />
<HoverCardContent>
<HoverCardHeader>
<HoverCardTitle>بلاز زورتشيتش</HoverCardTitle>
<HoverCardDescription>مصمم · يبني TessinaUI.</HoverCardDescription>
</HoverCardHeader>
<HoverCardArrow />
</HoverCardContent>
</HoverCard>API Reference
HoverCard (root)
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | "md" | Width + text size of the popup |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Corner radius |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Used by the optional intent border |
showIntentBorder | boolean | intent !== "none" | Overrides whether the border picks up the intent colour; unset, it follows intent |
dir | "ltr" | "rtl" | "ltr" | Reading direction |
delay | number | 600 (Base UI) | Open delay in ms (forwarded to Trigger) |
closeDelay | number | 300 (Base UI) | Close delay in ms (forwarded to Trigger) |
touch | "press" | "off" | "press" | What a finger or pen tap does — see Touch |
open | boolean | — | Controlled open state |
defaultOpen | boolean | false | Uncontrolled initial state |
onOpenChange | (open, event) => void | — | Fired when open state changes |
HoverCardContent
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "right" | "bottom" | "left" | "bottom" | Which side of the trigger to position on |
align | "start" | "center" | "end" | "center" | Alignment along the side axis |
arrow | boolean | false | Render the built-in arrow tail. An explicit <HoverCardArrow /> child does the same and wins when both are present |
sideOffset | number | 18 | Distance in px from the trigger (floored at 21 when the arrow is shown) |
alignOffset | number | 0 | Shift along the side axis |
collisionPadding | number | 8 | Minimum gap from the viewport edge, grown by the device's safe-area insets |
portal | boolean | true | Render inside a portal |
container | HTMLElement | null | document.body | Portal target |
size, rounded, intent, showIntentBorder | — | inherited | Override the root's variant |
HoverCardHeader
| Prop | Type | Default | Description |
|---|---|---|---|
leading | ReactNode | — | Avatar or icon rendered beside the title |
trailing | ReactNode | — | Slot for badge, status dot, or follow button |
HoverCardTrigger
Renders a <button>. Use render={<a href="…" />} to swap in a link, or render={<MyComponent />} for any element. Accepts delay / closeDelay to override the values inherited from the root.
HoverCardMedia
A band that bleeds to the card's frame — a banner behind an avatar, or a thumbnail above a link preview. The card clips it, so the band needs no radius of its own.
| Prop | Type | Default | Description |
|---|---|---|---|
height | number | string | — | Block size for the band; a number is read as px. Leave unset to let the content size it |
HoverCardSkeleton
Paints the card's surface with placeholder bars while content loads.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | "md" | Width + text size of the placeholder surface |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Corner radius |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Passed through to the surface variants |
showAvatar | boolean | true | Show the round avatar placeholder |
lines | number | 2 | Number of body text bars |
showStats | boolean | false | Show the three short stat bars |
label | string | "Loading" | What assistive tech announces. Rendered as sr-only text, never aria-label — a role="status" region announces its content, so a label-only region is silent on insertion |
Sub-components
HoverCardTitle, HoverCardDescription, HoverCardBody, HoverCardSeparator, HoverCardFooter, HoverCardArrow, HoverCardBackdrop — all accept className and any HTML props.
Every part publishes a stable data-slot (hover-card-trigger, -content, -media, -header, -title, -description, -body, -separator, -footer, -arrow, -backdrop, -skeleton) for styling and test targeting.
Notes
- Built on
@base-ui/react/preview-card— Base UI's name for the HoverCard primitive. - Enter/exit animations use Base UI's
data-[starting-style]/data-[ending-style]plus position-awaredata-[side=*]attributes, so the popup "lifts off" from whichever side it opens on. - The arrow is an SVG bubble contour on a 32×32 square canvas. Its fill closes over the popup's edge so the two surfaces merge with no seam, and whenever the card carries an intent border the contour is stroked in the same colour, so the outline carries around the tip.
HoverCardContentpublishes--hover-card-inner-radius, so your own content inside the frame can writerounded-[var(--hover-card-inner-radius)]and square off exactly when the card does.