Hover card
A preview that opens while a link is hovered or focused.
Vibra draws the card as the kit's elev-1 floating layer and fades it in over --duration-base without shadcn's zoom, sliding 4px from its side rather than 8. It is Base UI's PreviewCard, so it opens on keyboard focus as well as on hover, and its trigger is a link that goes where the card previews; keep anything essential out of it — the card is a shortcut, and nothing in it can be reached from the keyboard.
Install
npx shadcn@latest add @vibra/hover-cardNeeds the @vibra registry in your components.json — set it up once.
Examples
Deal owner: Saoirse Byrne
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { avatarFor } from "@/lib/avatars"
export default function HoverCardDemo() {
return (
<p className="text-sm text-muted-foreground">
Deal owner:{" "}
<HoverCard>
<HoverCardTrigger href="/people/directory" className="font-medium text-foreground underline underline-offset-4">
Saoirse Byrne
</HoverCardTrigger>
<HoverCardContent className="flex w-64 gap-3">
<Avatar size="lg">
<AvatarImage src={avatarFor("Saoirse Byrne")} alt="" />
<AvatarFallback>SB</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-foreground">Saoirse Byrne</p>
<p className="text-xs text-muted-foreground">Account executive, Dublin</p>
<p className="text-xs text-muted-foreground tabular-nums">14 open deals · $212,400 in pipeline</p>
</div>
</HoverCardContent>
</HoverCard>
</p>
)
}A team of faces
Each face is its person's link, named for them; resting on it previews their role and whether it is a sensible hour to message them.
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
const TEAM = [
{ name: "Saoirse Byrne", role: "Account executive", place: "Dublin", time: "14:05", open: 6 },
{ name: "Tomás Ortega", role: "Solutions engineer", place: "Madrid", time: "15:05", open: 3 },
{ name: "Hiro Tanaka", role: "Finance lead", place: "Tokyo", time: "22:05", open: 9 },
]
// Who is on the Harbour Logistics renewal. Each face is a link to the
// person's page, named for the person; resting on it — or focusing it —
// previews who they are and whether it's a sensible hour to message them.
// Nothing in the card is needed to act: the link goes to the full profile.
export default function HoverCardTeam() {
return (
<div className="flex items-center gap-3">
<span id="hover-card-team-label" className="text-sm text-muted-foreground">
Renewal team
</span>
<ul aria-labelledby="hover-card-team-label" className="flex -space-x-2">
{TEAM.map((person) => (
<li key={person.name}>
<HoverCard>
<HoverCardTrigger href={`/people/${person.name.toLowerCase().replace(/\s+/g, "-")}`} aria-label={person.name} className="block rounded-full ring-2 ring-background focus-ring">
<Avatar aria-hidden="true">
<AvatarImage src={avatarFor(person.name)} alt="" />
<AvatarFallback>{getInitials(person.name)}</AvatarFallback>
</Avatar>
</HoverCardTrigger>
<HoverCardContent className="flex w-64 items-center gap-3">
<Avatar size="lg" aria-hidden="true">
<AvatarImage src={avatarFor(person.name)} alt="" />
<AvatarFallback>{getInitials(person.name)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col gap-0.5 text-xs text-muted-foreground">
<p className="text-sm font-medium text-foreground">{person.name}</p>
<p>{person.role}</p>
<p className="tabular-nums">
{person.time} in {person.place} · {person.open} open tasks
</p>
</div>
</HoverCardContent>
</HoverCard>
</li>
))}
</ul>
</div>
)
}A record
A customer named in an activity feed previews what they pay and how they stand, without leaving the feed.
Harbour Logistics moved to the Scale plan, adding 6 seats. Renewal is due on October 1.
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { LogoMark } from "@/components/ui/logo-mark"
import { StatusBadge } from "@/components/ui/status-badge"
const FACTS = [
{ term: "Plan", value: "Scale, yearly" },
{ term: "MRR", value: "$4,820" },
{ term: "Seats", value: "18 of 20" },
]
// A customer named in an activity feed previews as a record: what they pay
// and how they stand, read without leaving the feed. The link still goes to
// the customer's page, so the card is a shortcut, never the only way there.
export default function HoverCardRecord() {
return (
<p className="max-w-sm text-sm text-muted-foreground">
<HoverCard>
<HoverCardTrigger href="/crm/customers/harbour-logistics" className="rounded-sm font-medium text-foreground underline underline-offset-4 focus-ring">
Harbour Logistics
</HoverCardTrigger>
<HoverCardContent className="flex w-64 flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<LogoMark name="Harbour" size="sm" />
<StatusBadge status="active" size="sm" />
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
{FACTS.map((fact) => (
<div key={fact.term} className="contents">
<dt className="text-muted-foreground">{fact.term}</dt>
<dd className="text-end tabular-nums">{fact.value}</dd>
</div>
))}
</dl>
</HoverCardContent>
</HoverCard>{" "}
moved to the Scale plan, adding 6 seats. Renewal is due on October 1.
</p>
)
}Loads on open
The order is fetched the first time the card opens: busy and said while it comes, in the shape of the answer, and kept for the next visit.
Refund requested on NW-4821, three days after delivery.
"use client"
import * as React from "react"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { Skeleton } from "@/components/ui/skeleton"
// The preview is fetched the first time the card opens and kept after. The
// card opens at once, busy, in the shape of what is coming; the skeletons
// stand still under reduced motion. A second visit shows the answer at once.
export default function HoverCardLoading() {
const [state, setState] = React.useState<"idle" | "loading" | "ready">("idle")
React.useEffect(() => {
if (state !== "loading") return
const timer = window.setTimeout(() => setState("ready"), 800)
return () => window.clearTimeout(timer)
}, [state])
return (
<p className="text-sm text-muted-foreground">
Refund requested on{" "}
<HoverCard onOpenChange={(open) => open && state === "idle" && setState("loading")}>
<HoverCardTrigger href="/ecommerce/orders/NW-4821" className="rounded-sm font-mono text-foreground underline underline-offset-4 focus-ring">
NW-4821
</HoverCardTrigger>
<HoverCardContent aria-busy={state !== "ready"} className="w-64">
{state === "ready" ? (
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<p className="text-sm font-medium text-foreground">Order <span className="font-mono">NW-4821</span></p>
<p>2 items · $1,240.00 · paid by card</p>
<p>Delivered to Harbour Logistics on September 19</p>
</div>
) : (
<div className="flex flex-col gap-2" aria-label="Loading the order" role="status">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-2/3" />
</div>
)}
</HoverCardContent>
</HoverCard>
, three days after delivery.
</p>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| HoverCardTrigger.href | string | — | Where the link goes: the page the card previews. |
| onOpenChange | (open: boolean) => void | — | Start fetching the preview the first time it opens, and keep the answer. |
Dependencies
Registry
Source
"use client"
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
import { cn } from "@/lib/utils"
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
return (
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 4,
...props
}: PreviewCardPrimitive.Popup.Props &
Pick<
PreviewCardPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
className={cn(
"z-50 w-64 origin-(--transform-origin) elev-1 p-2.5 text-sm text-popover-foreground outline-hidden data-[side=bottom]:slide-in-from-top-1 data-[side=inline-end]:slide-in-from-left-1 data-[side=inline-start]:slide-in-from-right-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-open:animate-in data-open:fade-in-0 data-open:duration-(--duration-base) data-open:ease-(--ease-standard) data-closed:animate-out data-closed:fade-out-0 data-closed:duration-(--duration-fast) data-closed:ease-(--ease-exit)",
className
)}
{...props}
/>
</PreviewCardPrimitive.Positioner>
</PreviewCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }