Hover Card
pnpm dlx shadcn@latest add …/r/hover-card.jsonAn accessible React hover card for Next.js and Tailwind CSS: a preview card for a link, like a profile on an @mention, built on Radix Hover Card.
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card"
export function Mention() {
return (
<HoverCard>
<HoverCardTrigger href="/people/maya">@maya</HoverCardTrigger>
<HoverCardContent>
<p className="text-sm font-medium">Maya Chen</p>
<p className="text-sm text-muted-foreground">Design engineer.</p>
</HoverCardContent>
</HoverCard>
)
}import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { CalendarIcon } from "lucide-react";
import { Fragment } from "react";
type Person = { handle: string; name: string; initials: string; bio: string; joined: string };
const people: Person[] = [
{
handle: "maya",
name: "Maya Chen",
initials: "MC",
bio: "Design engineer. Motion, type and interfaces that work for everyone.",
joined: "Joined March 2024",
},
{
handle: "theo",
name: "Theo Laurent",
initials: "TL",
bio: "Looks after the docs and the release notes.",
joined: "Joined June 2024",
},
{
handle: "ines",
name: "Inés Romero",
initials: "IR",
bio: "Front-end developer at a small studio. Writes about CSS.",
joined: "Joined January 2025",
},
];
function Mention({ person }: { person: Person }) {
return (
<HoverCard>
<HoverCardTrigger
href={`https://example.com/${person.handle}`}
className="font-medium text-foreground underline decoration-border underline-offset-4 transition-colors duration-(--duration-fast) ease-out hover:decoration-current outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)"
>
@{person.handle}
</HoverCardTrigger>
<HoverCardContent>
<div className="flex gap-3">
{/* Initials stand in for a photo */}
<span
aria-hidden
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium"
>
{person.initials}
</span>
<div className="grid gap-2">
<div>
<p className="text-sm font-medium">{person.name}</p>
<p className="text-sm text-muted-foreground">@{person.handle}</p>
</div>
<p className="text-sm">{person.bio}</p>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CalendarIcon aria-hidden className="size-(--size-icon-sm)" />
{person.joined}
</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
/** Rest on a name to see who it is, then move along: the next card opens at once */
export default function HoverCardProfile() {
return (
<p className="max-w-sm text-center text-sm text-muted-foreground">
Reviewed by{" "}
{people.map((person, index) => (
<Fragment key={person.handle}>
{index === 0 ? null : index === people.length - 1 ? " and " : ", "}
<Mention person={person} />
</Fragment>
))}
.
</p>
);
}Installation
Install the dependencies.
$ pnpm add motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { blur, duration, ease } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { type AnimationPlaybackControls, animate, useReducedMotion } from "motion/react"; import { HoverCard as HoverCardPrimitive } from "radix-ui"; import { type ComponentProps, createContext, type RefObject, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, } from "react"; /* * Hover Card, built on Radix Hover Card: a preview of what a link points to (a profile, a page) for sighted * users. It opens when the pointer rests on the link or the link takes keyboard focus, stays open while the * pointer moves onto it, and closes on Escape. Screen readers don't get the card, so the link has to make * sense on its own. * - The surface and motion are Popover's: it opens out of the link through the blur, from Radix's * transform origin, and closes back into it. * - It waits 300ms before opening, as Tooltip does (Radix waits 700ms, which made the first card feel slow). * - From one link to the next (names in a comment, people in a list): once a card has been open, the next * opens at once and takes its place, with no delay. The two read as one card that moves: the new one * starts where the last one was, at its size, and glides to its own place and size (Navigation Menu's * panel: transform for the place, width and height for the size, duration.base + ease.out), while its * content slides 12px in from the side it moved towards, through the blur. The last one goes in the same * frame. With reduced motion it simply appears in its place. * - Nothing animates on first paint. */ /** How far the content slides in as the card moves to the next link, in px (Navigation Menu's) */ const SLIDE = 12; /** The inline styles the glide leaves on a card and its content, cleared when it's done */ const LEFTOVERS = ["width", "height", "overflow", "transform"]; const CONTENT_LEFTOVERS = ["opacity", "transform", "filter"]; type Shown = { hide: () => void; measure: () => DOMRect | null }; /** Page-wide: the card on show (open, or the last one that closed), whether it's open, and when it closed */ const shown: { card: Shown | null; open: boolean; closedAt: number } = { card: null, open: false, closedAt: Number.NEGATIVE_INFINITY, }; /** One card's way of coming and going, read by its trigger and content */ const CardContext = createContext<{ following: boolean; hidden: boolean; openAtOnce: () => void; /** The card's surface on the page, so the next card can start from where it is */ surface: RefObject<HTMLDivElement | null>; /** Where the card this one replaced was, taken by the content as it mounts */ from: RefObject<DOMRect | null>; } | null>(null); export type HoverCardProps = ComponentProps<typeof HoverCardPrimitive.Root> & { /** Milliseconds after a card closes during which the next one opens at once, without the delay */ skipDelayDuration?: number; }; function HoverCard({ open: openProp, defaultOpen = false, onOpenChange, openDelay = 300, closeDelay = 300, skipDelayDuration = 300, ...props }: HoverCardProps) { const [uncontrolled, setUncontrolled] = useState(defaultOpen); // Open from the start counts as following: it's simply there, with nothing animating on first paint const [following, setFollowing] = useState(defaultOpen); const [hidden, setHidden] = useState(false); const open = openProp ?? uncontrolled; // This card in the page-wide record. Its methods are refreshed after every render, so they see the latest state. const card = useRef<Shown>({ hide: () => {}, measure: () => null }); const surface = useRef<HTMLDivElement | null>(null); const from = useRef<DOMRect | null>(null); const setOpen = (next: boolean, follows = false) => { // Radix's timers can report a state it's already in, for example after the card opened at once if (next === open) return; if (next) { // One card at a time: the one on show gives way in the same frame, and this one starts from where it was from.current = null; if (shown.card && shown.card !== card.current) { if (follows && shown.open) from.current = shown.card.measure(); shown.card.hide(); } shown.card = card.current; shown.open = true; setFollowing(follows); setHidden(false); } else if (shown.card === card.current) { shown.open = false; shown.closedAt = performance.now(); } if (openProp === undefined) setUncontrolled(next); onOpenChange?.(next); }; useLayoutEffect(() => { card.current.hide = () => { setHidden(true); setOpen(false); }; card.current.measure = () => surface.current?.isConnected ? surface.current.getBoundingClientRect() : null; }); useEffect(() => { const self = card.current; return () => { if (shown.card !== self) return; shown.card = null; shown.open = false; }; }, []); /** Called as the pointer or focus reaches the link: skip the delay if another card was just on show */ const openAtOnce = () => { if (open) return; if (shown.open || performance.now() - shown.closedAt < skipDelayDuration) setOpen(true, true); }; return ( <CardContext.Provider value={{ following, hidden, openAtOnce, surface, from }}> <HoverCardPrimitive.Root data-slot="hover-card" open={open} onOpenChange={(next) => setOpen(next)} openDelay={openDelay} closeDelay={closeDelay} {...props} /> </CardContext.Provider> ); } function HoverCardTrigger({ onPointerEnter, onFocus, ...props }: ComponentProps<typeof HoverCardPrimitive.Trigger>) { const card = useContext(CardContext); return ( <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" // These run before Radix's own, which still start its delay; opening at once makes that a no-op onPointerEnter={(event) => { onPointerEnter?.(event); if (!event.defaultPrevented && event.pointerType !== "touch") card?.openAtOnce(); }} onFocus={(event) => { onFocus?.(event); if (!event.defaultPrevented) card?.openAtOnce(); }} {...props} /> ); } type Edge = "start" | "end" | "center"; /** A point along one axis of a box: its start, its end or its centre */ const along = (start: number, size: number, edge: Edge) => edge === "start" ? start : edge === "end" ? start + size : start + size / 2; /** The edge Radix holds still on an axis while the card's size changes: the one by the link, or the aligned one */ function edgeOf(side: string | undefined, align: string | undefined, axis: "x" | "y"): Edge { const [before, after] = axis === "x" ? ["left", "right"] : ["top", "bottom"]; if (side === before) return "end"; if (side === after) return "start"; return align === "start" ? "start" : align === "end" ? "end" : "center"; } /** * Glides a card that took another's place from where that one was: its place (transform) and its size (width * and height), while its content comes in through the blur from the side it moved towards. Returns a stop. */ function glideFrom(node: HTMLDivElement, from: DOMRect) { const to = node.getBoundingClientRect(); if (to.width === 0 || to.height === 0) return () => {}; const { side, align } = node.dataset; const xEdge = edgeOf(side, align, "x"); const yEdge = edgeOf(side, align, "y"); const dx = along(from.left, from.width, xEdge) - along(to.left, to.width, xEdge); const dy = along(from.top, from.height, yEdge) - along(to.top, to.height, yEdge); const timing = { duration: duration.base, ease: ease.out }; const content = node.firstElementChild instanceof HTMLElement ? node.firstElementChild : null; // Clipped only while the size moves node.style.overflow = "hidden"; const runs: AnimationPlaybackControls[] = [ animate( node, { x: [dx, 0], y: [dy, 0], width: [from.width, to.width], height: [from.height, to.height] }, timing, ), ]; if (content) { const horizontal = Math.abs(dx) >= Math.abs(dy); // Moving right, the content comes in from the right (as Navigation Menu's) const towards = Math.sign(horizontal ? -dx : -dy); const slide = horizontal ? { x: [towards * SLIDE, 0] } : { y: [towards * SLIDE, 0] }; runs.push(animate(content, { opacity: [0, 1], filter: [blur.hidden, blur.shown], ...slide }, timing)); } // A filter or size left behind would break the next glide and its closing keyframes const clear = () => { for (const property of LEFTOVERS) node.style.removeProperty(property); if (content) for (const property of CONTENT_LEFTOVERS) content.style.removeProperty(property); }; Promise.all(runs).then(clear); return () => { for (const run of runs) run.stop(); clear(); }; } function HoverCardContent({ className, align = "center", sideOffset = 4, children, ref, ...props }: ComponentProps<typeof HoverCardPrimitive.Content>) { const card = useContext(CardContext); const reduce = useReducedMotion() ?? false; const surface = card?.surface; const start = card?.from; // Stable, so a re-render while it glides doesn't detach and re-attach it (which would stop the glide) const attach = useCallback( (node: HTMLDivElement | null) => { if (typeof ref === "function") ref(node); else if (ref) ref.current = node; if (!surface || !start || !node) return; surface.current = node; // Read, not taken: React attaches a ref twice in development (Strict Mode), and taking it on the // first attach left the real one with nothing to glide from. Each opening sets it afresh. const from = start.current; let stop: (() => void) | undefined; let observer: MutationObserver | undefined; if (from && from.width > 0 && !reduce) { // Radix places the card just after it mounts; until then its wrapper is parked off-screen. That // change lands before the browser paints, so the glide starts on the first frame shown. const wrapper = node.parentElement; const placed = () => !wrapper?.style.transform.includes("-200%"); if (placed()) stop = glideFrom(node, from); else if (wrapper) { observer = new MutationObserver(() => { if (!placed()) return; observer?.disconnect(); stop = glideFrom(node, from); }); observer.observe(wrapper, { attributes: true, attributeFilter: ["style"] }); } } return () => { observer?.disconnect(); stop?.(); if (surface.current === node) surface.current = null; }; }, [ref, surface, start, reduce], ); return ( <HoverCardPrimitive.Portal> <HoverCardPrimitive.Content ref={attach} data-slot="hover-card-content" data-following={card?.following ? "" : undefined} align={align} sideOffset={sideOffset} className={cn( "z-(--z-popover) w-64 rounded-lg border border-border bg-popover p-4 text-popover-foreground shadow-md outline-hidden", "origin-(--radix-hover-card-content-transform-origin) data-[state=closed]:animate-popover-out", // A card that takes another's place glides from it instead !card?.following && "data-[state=open]:animate-popover-in", // Handed over to the next card: gone at once, while Radix waits out the closing keyframes card?.hidden && "invisible", className, )} {...props} > {/* One element round the content, so it can come in through the blur while the card glides */} <div data-slot="hover-card-body">{children}</div> </HoverCardPrimitive.Content> </HoverCardPrimitive.Portal> ); } export { HoverCard, HoverCardContent, HoverCardTrigger };Update the import paths to match your project.
API reference
HoverCard
The root. Also accepts every Radix Hover Card Root prop.
| Prop | Type | Default | Description |
|---|---|---|---|
open? | boolean | – | Whether it's open, when you control it. |
defaultOpen? | boolean | false | Whether it starts open. |
onOpenChange? | (open: boolean) => void | – | Called when it opens or closes. |
openDelay? | number | 300 | Milliseconds the pointer rests on the link, or focus stays on it, before the card opens. |
closeDelay? | number | 300 | Milliseconds before it closes once the pointer leaves, so it can move onto the card. |
skipDelayDuration? | number | 300 | Milliseconds after a card closes during which the next one, anywhere on the page, opens at once without the delay, gliding over from the last one instead of opening from its link. |
HoverCardTrigger
The link the card previews; it renders an <a>, so give it an href. It has to make sense on its own, because screen readers don't get the card. Use asChild to make your own link the trigger.
| Prop | Type | Default | Description |
|---|---|---|---|
href? | string | – | Where the link goes. |
asChild? | boolean | false | Render the single child (for example a Next.js Link) as the trigger. |
HoverCardContent
The card. It opens out of the link through a soft blur and stays open while the pointer is on it. Links inside it are skipped by Tab. Also accepts every Radix Hover Card Content prop.
| Prop | Type | Default | Description |
|---|---|---|---|
side? | "top" | "right" | "bottom" | "left" | "bottom" | The side of the link it opens on. It flips when there's no room. |
align? | "start" | "center" | "end" | "center" | How it lines up with the link along that side. |
sideOffset? | number | 4 | Gap between the link and the card, in px. |
Related components
An accessible React popover for Next.js and Tailwind CSS: forms, settings and other rich content anchored to a button, built on Radix Popover.
An accessible React tooltip for Next.js and Tailwind CSS: a short hint on hover and keyboard focus, fast across a toolbar, built on Radix Tooltip.
An accessible React alert dialog for Next.js that asks people to confirm destructive actions, with focus starting on Cancel, built on Radix Alert Dialog.