Overlays

Hover Card

Entity preview on hover with a 200ms open delay, origin-aware physics, and a ready profile layout: avatar, name, meta row.

Install

npx shadcn@latest add @paragon/hover-card

hover-card.tsx

"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 };