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
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
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactElement | The link or button that triggers the card. Any focusable element. | |
| card | ReactNode | The preview content. | |
| label | string | Accessible name of the card. | |
| side | "top" | "right" | "bottom" | "left" | "bottom" | Preferred side. |
| openDelay | number | 300 | Milliseconds before hover opens it. |
| closeDelay | number | 150 | Milliseconds before leaving closes it. |
| className | string | Classes 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