Entity preview on hover with a 200ms open delay, origin-aware physics, and a ready profile layout: avatar, name, meta row.
npx shadcn@latest add @paragon/hover-card"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
/*
* Entity-preview card. 200ms open delay so casual cursor passes never
* trigger it; origin-aware 0.97 -> 1 scale enter at 150ms, 100ms
* non-retracing exit. HoverCardProfile is the useful zero-config layout:
* avatar, name, title, optional body and meta row.
*/
const hoverCardStyles = `
@keyframes pg-hover-card-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-hover-card-out { to { opacity: 0; scale: 0.99; } }
@media (prefers-reduced-motion: reduce) {
@keyframes pg-hover-card-in { from { opacity: 0; } }
@keyframes pg-hover-card-out { to { opacity: 0; } }
}
`;
function HoverCard({
openDelay = 200,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return (
<HoverCardPrimitive.Root
data-slot="hover-card"
openDelay={openDelay}
{...props}
/>
);
}
function HoverCardTrigger(
props: React.ComponentProps<typeof HoverCardPrimitive.Trigger>,
) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 6,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal>
<style href="paragon-hover-card" precedence="paragon">{hoverCardStyles}</style>
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-hover-card-content-transform-origin) rounded-lg bg-popover p-4 text-popover-foreground shadow-overlay outline-none",
"data-[state=open]:animate-[pg-hover-card-in_150ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-hover-card-out_100ms_var(--ease-exit)_forwards]",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
function initialsOf(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("");
}
export interface HoverCardProfileProps extends React.ComponentProps<"div"> {
/** Display name — also seeds the avatar fallback initials. */
name: string;
/** Secondary line under the name (role, team, handle). */
title?: string;
/** Avatar image URL. Falls back to initials. */
avatarSrc?: string;
/** Small trailing row for counts, location, joined date. */
meta?: React.ReactNode;
}
function HoverCardProfile({
name,
title,
avatarSrc,
meta,
children,
className,
...props
}: HoverCardProfileProps) {
return (
<div
data-slot="hover-card-profile"
className={cn("flex flex-col gap-3", className)}
{...props}
>
<div className="flex items-center gap-3">
<AvatarPrimitive.Root className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted">
{avatarSrc && (
<AvatarPrimitive.Image
src={avatarSrc}
alt=""
className="size-full object-cover"
/>
)}
<AvatarPrimitive.Fallback
delayMs={avatarSrc ? 300 : 0}
className="text-sm font-medium text-muted-foreground"
>
{initialsOf(name)}
</AvatarPrimitive.Fallback>
</AvatarPrimitive.Root>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{name}</p>
{title && (
<p className="truncate text-xs text-muted-foreground">{title}</p>
)}
</div>
</div>
{children && (
<div className="text-sm text-pretty text-foreground/80">{children}</div>
)}
{meta && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{meta}
</div>
)}
</div>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent, HoverCardProfile };