Skip to content
goodtabs UI
BrowseBrowse components

Hover card

A rich preview that opens on hover and keyboard focus without stealing focus.

npx shadcn@latest add https://ui.goodtabs.co/r/hover-card.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/hover-card

A link that previews its destination.

Profile in text

The motion curves were tuned by Maya Okafor last week.

A mention in running text with an avatar card.

Usage

import { HoverCard } from "@/components/ui/hover-card"

<HoverCard label="Profile of Maya" card={<p>Design engineer.</p>}>
  <a href="/maya">Maya</a>
</HoverCard>

Props

PropTypeDefaultDescription
childrenReactElementThe link or button that triggers the card. Any focusable element.
cardReactNodeThe preview content.
labelstringAccessible name of the card.
side"top" | "right" | "bottom" | "left""bottom"Preferred side.
openDelaynumber300Milliseconds before hover opens it.
closeDelaynumber150Milliseconds before leaving closes it.
classNamestringClasses for the card, for example a different width.

Source

hover-card/hover-card.tsx
"use client";

import * as React from "react";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { cn } from "@/lib/utils";

type HoverCardProps = {
  /** The link or button that triggers the card. Wrapped in an inline span that listens for hover and focus. */
  children: React.ReactElement;
  /** The preview. Supplementary only: the same information must exist behind the link. */
  card: React.ReactNode;
  /** Accessible name of the card. */
  label: string;
  side?: "top" | "right" | "bottom" | "left";
  /** Milliseconds before hover opens the card. */
  openDelay?: number;
  /** Milliseconds before leaving closes it, so the pointer can cross the gap. */
  closeDelay?: number;
  className?: string;
};

/**
 * A rich preview that opens on hover and on keyboard focus. It is built on the popover primitive
 * and never takes focus, so the trigger keeps working as an ordinary link. Touch does not open it.
 */
export function HoverCard({
  children,
  card,
  label,
  side = "bottom",
  openDelay = 300,
  closeDelay = 150,
  className,
}: HoverCardProps) {
  const [open, setOpen] = React.useState(false);
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  const schedule = (next: boolean, ms: number) => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setOpen(next), ms);
  };
  React.useEffect(() => () => clearTimeout(timer.current), []);

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverAnchor asChild>
        <span
          className="inline"
          onPointerEnter={(e) => e.pointerType === "mouse" && schedule(true, openDelay)}
          onPointerLeave={(e) => e.pointerType === "mouse" && schedule(false, closeDelay)}
          onFocus={() => schedule(true, 0)}
          onBlur={() => schedule(false, 0)}
          onKeyDown={(e) => e.key === "Escape" && schedule(false, 0)}
        >
          {children}
        </span>
      </PopoverAnchor>
      <PopoverContent
        side={side}
        sideOffset={8}
        aria-label={label}
        onOpenAutoFocus={(e) => e.preventDefault()}
        onCloseAutoFocus={(e) => e.preventDefault()}
        onPointerEnter={() => clearTimeout(timer.current)}
        onPointerLeave={() => schedule(false, closeDelay)}
        className={cn("w-64", className)}
      >
        {card}
      </PopoverContent>
    </Popover>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/hover-card/prompt.md, or the Copy AI prompt button above.

When to use

  • Previewing a link target, a person or a resource without leaving the page
  • Extra detail that is nice to have, never essential
  • Dense text or tables where a full popover would be heavy

When not to use

  • A label for an icon button, use a tooltip
  • Anything with buttons or inputs inside, use a popover that takes focus
  • Information touch users must see, it does not open on tap

Agent notes

  • Client component built on the popover primitive; there is no separate hover-card primitive in components/ui
  • Opens on mouse hover and keyboard focus; touch and pen do nothing, so the trigger must stay a normal link or button
  • The card never takes focus and is not in the tab order, so keep interactive controls out of it
  • Escape closes it; moving the pointer onto the card keeps it open
  • Children are wrapped in an inline span that listens for pointer and focus events, so any link or button works