Hover Card
Rich preview that opens when a link or trigger is hovered or focused. Delays, sides and alignment.
New1'use client';23import { Avatar } from '@/components/ui/avatar';4import { Button } from '@/components/ui/button';5import { HoverCard } from '@/components/ui/hover-card';6import { CalendarIcon } from '@/components/ui/calendar';78export function Default() {9 return (10 <HoverCard>11 <HoverCard.Trigger asChild>12 <Button variant="link">@figueroaignacio</Button>13 </HoverCard.Trigger>14 <HoverCard.Content className="w-72">15 <div className="flex gap-3">16 <Avatar>17 <Avatar.Image src="https://github.com/figueroaignacio.png" alt="Ignacio Figueroa" />18 <Avatar.Fallback>IF</Avatar.Fallback>19 </Avatar>20 <div className="flex flex-col gap-1">21 <p className="text-sm font-semibold">@figueroaignacio</p>22 <p className="text-muted-foreground text-xs">23 Building NachUI, a copy-paste component library with zero dependencies.24 </p>25 <p className="text-muted-foreground flex items-center gap-1.5 pt-1 text-xs">26 <CalendarIcon size={14} />27 Joined March 202428 </p>29 </div>30 </div>31 </HoverCard.Content>32 </HoverCard>33 );34}
Installation
Copy and paste the following code into your project.
1'use client';23import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion } from 'motion/react';4import * as React from 'react';5import { cn } from '../lib/cn';6import { FloatingPortal, useFloating } from '../lib/floating';7import { floatingOrigin, floatingVariants, reveal } from '../lib/motion';8import { assignRef, getFocusable, useLayer } from '../lib/overlay';9import { useControllableState } from '../lib/use-controllable-state';1011const HOVER_CARD_STYLE = { willChange: 'opacity, transform, filter' } as const;1213function nextFocusableAfter(anchor: HTMLElement, skip: HTMLElement) {14 return getFocusable(document.body).find(15 (element) =>16 !skip.contains(element) &&17 !anchor.contains(element) &&18 Boolean(anchor.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING),19 );20}2122type HoverCardSide = 'top' | 'bottom' | 'left' | 'right';23type HoverCardAlign = 'start' | 'center' | 'end';2425interface HoverCardContextValue {26 open: boolean;27 id: string;28 scheduleOpen: () => void;29 scheduleClose: () => void;30 openNow: () => void;31 closeNow: () => void;32 toggle: () => void;33 isInside: (node: Node | null) => boolean;34 rootRef: React.RefObject<HTMLElement | null>;35 triggerRef: React.RefObject<HTMLElement | null>;36 contentRef: React.RefObject<HTMLDivElement | null>;37}3839const HoverCardContext = React.createContext<HoverCardContextValue | null>(null);4041function useHoverCardContext(): HoverCardContextValue {42 const context = React.use(HoverCardContext);43 if (!context) throw new Error('HoverCard parts must be used within HoverCard');44 return context;45}4647interface HoverCardProps {48 children: React.ReactNode;49 open?: boolean;50 defaultOpen?: boolean;51 onOpenChange?: (open: boolean) => void;52 openDelay?: number;53 closeDelay?: number;54}5556const HoverCardRoot = ({57 children,58 open: controlledOpen,59 defaultOpen = false,60 onOpenChange,61 openDelay = 500,62 closeDelay = 200,63}: HoverCardProps) => {64 const [open, setOpen] = useControllableState({65 value: controlledOpen,66 defaultValue: defaultOpen,67 onChange: onOpenChange,68 });69 const id = React.useId();70 const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);71 const rootRef = React.useRef<HTMLSpanElement>(null);72 const triggerRef = React.useRef<HTMLElement>(null);73 const contentRef = React.useRef<HTMLDivElement>(null);74 const layer = useLayer(open, false, contentRef);7576 const clearTimer = React.useCallback(() => {77 if (timer.current) clearTimeout(timer.current);78 timer.current = null;79 }, []);8081 const scheduleOpen = React.useCallback(() => {82 clearTimer();83 timer.current = setTimeout(() => setOpen(true), openDelay);84 }, [clearTimer, openDelay, setOpen]);8586 const scheduleClose = React.useCallback(() => {87 clearTimer();88 timer.current = setTimeout(() => setOpen(false), closeDelay);89 }, [clearTimer, closeDelay, setOpen]);9091 const openNow = React.useCallback(() => {92 clearTimer();93 setOpen(true);94 }, [clearTimer, setOpen]);9596 const closeNow = React.useCallback(() => {97 clearTimer();98 setOpen(false);99 }, [clearTimer, setOpen]);100101 const toggle = React.useCallback(() => {102 clearTimer();103 setOpen((current) => !current);104 }, [clearTimer, setOpen]);105106 const isInside = React.useCallback(107 (node: Node | null) =>108 Boolean(node && (rootRef.current?.contains(node) || contentRef.current?.contains(node))),109 [],110 );111112 React.useEffect(() => clearTimer, [clearTimer]);113114 React.useEffect(() => {115 if (!open) return;116 const handleEscape = (event: KeyboardEvent) => {117 if (event.key !== 'Escape' || event.defaultPrevented || !layer.isTopmost()) return;118 event.preventDefault();119 closeNow();120 };121 const handlePointerDown = (event: PointerEvent) => {122 const target = event.target as Node | null;123 if (isInside(target) || layer.isInsideUpperLayer(target)) return;124 closeNow();125 };126 document.addEventListener('keydown', handleEscape);127 document.addEventListener('pointerdown', handlePointerDown);128 return () => {129 document.removeEventListener('keydown', handleEscape);130 document.removeEventListener('pointerdown', handlePointerDown);131 };132 }, [open, closeNow, isInside, layer]);133134 const context = React.useMemo(135 () => ({136 open,137 id,138 scheduleOpen,139 scheduleClose,140 openNow,141 closeNow,142 toggle,143 isInside,144 rootRef,145 triggerRef,146 contentRef,147 }),148 [open, id, scheduleOpen, scheduleClose, openNow, closeNow, toggle, isInside],149 );150151 return (152 <HoverCardContext value={context}>153 <span ref={rootRef} className="relative inline-flex">154 {children}155 </span>156 </HoverCardContext>157 );158};159HoverCardRoot.displayName = 'HoverCard';160161interface HoverCardTriggerProps extends React.HTMLAttributes<HTMLElement> {162 asChild?: boolean;163}164165type TriggerChildProps = {166 onPointerEnter?: (event: React.PointerEvent<HTMLElement>) => void;167 onPointerLeave?: (event: React.PointerEvent<HTMLElement>) => void;168 onPointerDown?: (event: React.PointerEvent<HTMLElement>) => void;169 onClick?: (event: React.MouseEvent<HTMLElement>) => void;170 onFocus?: (event: React.FocusEvent<HTMLElement>) => void;171 onBlur?: (event: React.FocusEvent<HTMLElement>) => void;172 onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void;173 className?: string;174 tabIndex?: number;175 ref?: React.Ref<HTMLElement>;176};177178const HoverCardTrigger = ({179 asChild = false,180 children,181 className,182 onPointerEnter,183 onPointerLeave,184 onFocus,185 onBlur,186 onPointerDown,187 onClick,188 onKeyDown,189 ref,190 ...props191}: HoverCardTriggerProps & { ref?: React.Ref<HTMLElement> }) => {192 const {193 open,194 id,195 scheduleOpen,196 scheduleClose,197 openNow,198 toggle,199 isInside,200 triggerRef,201 contentRef,202 } = useHoverCardContext();203 const lastPointerType = React.useRef<string | null>(null);204 const childRef = React.isValidElement(children)205 ? (children.props as TriggerChildProps).ref206 : undefined;207208 const mergedRef = React.useCallback(209 (node: HTMLElement | null) => {210 triggerRef.current = node;211 assignRef(ref, node);212 assignRef(childRef, node);213 },214 [ref, childRef, triggerRef],215 );216217 const handlers = {218 onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {219 onPointerEnter?.(event);220 if (event.pointerType !== 'touch') scheduleOpen();221 },222 onPointerLeave: (event: React.PointerEvent<HTMLElement>) => {223 onPointerLeave?.(event);224 if (event.pointerType !== 'touch') scheduleClose();225 },226 onPointerDown: (event: React.PointerEvent<HTMLElement>) => {227 onPointerDown?.(event);228 lastPointerType.current = event.pointerType;229 },230 onClick: (event: React.MouseEvent<HTMLElement>) => {231 onClick?.(event);232 if (lastPointerType.current === 'touch') toggle();233 lastPointerType.current = null;234 },235 onFocus: (event: React.FocusEvent<HTMLElement>) => {236 onFocus?.(event);237 if (lastPointerType.current !== 'touch') openNow();238 },239 onBlur: (event: React.FocusEvent<HTMLElement>) => {240 onBlur?.(event);241 if (isInside(event.relatedTarget as Node | null)) return;242 scheduleClose();243 },244 onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {245 onKeyDown?.(event);246 if (event.defaultPrevented || event.key !== 'Tab' || event.shiftKey || !open) return;247 const first = contentRef.current ? getFocusable(contentRef.current)[0] : undefined;248 if (!first) return;249 event.preventDefault();250 first.focus();251 },252 };253254 const shared = {255 'data-slot': 'hover-card-trigger',256 'data-state': open ? 'open' : 'closed',257 'aria-describedby': open ? id : undefined,258 };259260 if (asChild && React.isValidElement(children)) {261 const child = children as React.ReactElement<Record<string, unknown>>;262 const childProps = child.props as TriggerChildProps;263264 return React.cloneElement(child, {265 ...props,266 ...shared,267 ref: mergedRef,268 tabIndex: childProps.tabIndex ?? props.tabIndex ?? 0,269 className: cn(className, childProps.className),270 onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {271 childProps.onPointerEnter?.(event);272 handlers.onPointerEnter(event);273 },274 onPointerLeave: (event: React.PointerEvent<HTMLElement>) => {275 childProps.onPointerLeave?.(event);276 handlers.onPointerLeave(event);277 },278 onPointerDown: (event: React.PointerEvent<HTMLElement>) => {279 childProps.onPointerDown?.(event);280 handlers.onPointerDown(event);281 },282 onClick: (event: React.MouseEvent<HTMLElement>) => {283 childProps.onClick?.(event);284 handlers.onClick(event);285 },286 onFocus: (event: React.FocusEvent<HTMLElement>) => {287 childProps.onFocus?.(event);288 handlers.onFocus(event);289 },290 onBlur: (event: React.FocusEvent<HTMLElement>) => {291 childProps.onBlur?.(event);292 handlers.onBlur(event);293 },294 onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {295 childProps.onKeyDown?.(event);296 handlers.onKeyDown(event);297 },298 });299 }300301 return (302 <span303 ref={mergedRef as React.Ref<HTMLSpanElement>}304 tabIndex={0}305 className={cn('inline-flex cursor-default', className)}306 {...shared}307 {...handlers}308 {...props}309 >310 {children}311 </span>312 );313};314HoverCardTrigger.displayName = 'HoverCardTrigger';315316interface HoverCardContentProps extends HTMLMotionProps<'div'> {317 side?: HoverCardSide;318 align?: HoverCardAlign;319 sideOffset?: number;320 children?: React.ReactNode;321}322323const HoverCardPanel = ({324 side: preferredSide = 'bottom',325 align = 'center',326 sideOffset = 4,327 className,328 children,329 style,330 onPointerEnter,331 onPointerLeave,332 onFocus,333 onBlur,334 onKeyDown,335 ...props336}: HoverCardContentProps) => {337 const { id, openNow, scheduleClose, isInside, triggerRef, contentRef } = useHoverCardContext();338 const shouldReduceMotion = useReducedMotion();339 const floating = useFloating({340 open: true,341 anchorRef: triggerRef,342 floatingRef: contentRef,343 side: preferredSide,344 align,345 sideOffset,346 });347 const side = floating.side;348349 const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {350 onKeyDown?.(event);351 if (event.defaultPrevented || event.key !== 'Tab') return;352 const content = contentRef.current;353 const trigger = triggerRef.current;354 if (!content || !trigger) return;355 const focusable = getFocusable(content);356 const active = document.activeElement;357358 if (event.shiftKey) {359 if (active === focusable[0]) {360 event.preventDefault();361 trigger.focus();362 }363 return;364 }365366 if (active === focusable[focusable.length - 1]) {367 const next = nextFocusableAfter(trigger, content);368 if (!next) return;369 event.preventDefault();370 next.focus();371 }372 };373374 return (375 <motion.div376 ref={contentRef}377 id={id}378 data-slot="hover-card-content"379 data-side={side}380 data-align={align}381 variants={shouldReduceMotion ? reveal : floatingVariants[side]}382 initial="hidden"383 animate="visible"384 exit="exit"385 style={{ ...HOVER_CARD_STYLE, ...style, ...floating.style }}386 onPointerEnter={(event) => {387 onPointerEnter?.(event);388 if (event.pointerType !== 'touch') openNow();389 }}390 onPointerLeave={(event) => {391 onPointerLeave?.(event);392 if (event.pointerType !== 'touch') scheduleClose();393 }}394 onFocus={(event) => {395 onFocus?.(event);396 openNow();397 }}398 onBlur={(event) => {399 onBlur?.(event);400 if (isInside(event.relatedTarget as Node | null)) return;401 scheduleClose();402 }}403 onKeyDown={handleKeyDown}404 className={cn(405 'bg-popover text-popover-foreground border-border z-500 max-h-(--available-height) w-64 max-w-[calc(100vw-2rem)] overflow-y-auto rounded-md border p-4 shadow-md outline-none',406 floatingOrigin[side],407 className,408 )}409 {...props}410 >411 {children}412 </motion.div>413 );414};415416const HoverCardContent = (props: HoverCardContentProps) => {417 const { open } = useHoverCardContext();418419 return (420 <FloatingPortal>421 <AnimatePresence>{open && <HoverCardPanel {...props} />}</AnimatePresence>422 </FloatingPortal>423 );424};425HoverCardContent.displayName = 'HoverCardContent';426427const HoverCard = Object.assign(HoverCardRoot, {428 Trigger: HoverCardTrigger,429 Content: HoverCardContent,430});431432export { HoverCard, useHoverCardContext };433export type {434 HoverCardProps,435 HoverCardTriggerProps,436 HoverCardContentProps,437 HoverCardSide,438 HoverCardAlign,439};
Update the import paths to match your project structure.
Anatomy
1import { HoverCard } from '@/components/ui/hover-card';
1<HoverCard>2 <HoverCard.Trigger asChild>3 <a href="/u/nacho">@nacho</a>4 </HoverCard.Trigger>5 <HoverCard.Content>Profile preview</HoverCard.Content>6</HoverCard>
The card opens after
openDelay when the pointer enters the trigger and closes after closeDelay when it leaves, unless it moved onto the card. Keyboard focus opens it at once and blur closes it, and Tab moves into any links on the card before continuing with the page. Escape closes it without closing a drawer or dialog around it. On touch screens a tap on the trigger toggles the card and a tap anywhere else closes it; the trigger's own click still runs, so a link trigger still navigates.The card renders in a portal, so
overflow: hidden on a parent never clips it, and it flips to the other side when there is no room.Composition
HoverCardโโโ HoverCard.Triggerโโโ HoverCard.Content
Variants
Default
A profile card on a mention.
1'use client';23import { Avatar } from '@/components/ui/avatar';4import { Button } from '@/components/ui/button';5import { HoverCard } from '@/components/ui/hover-card';6import { CalendarIcon } from '@/components/ui/calendar';78export function Default() {9 return (10 <HoverCard>11 <HoverCard.Trigger asChild>12 <Button variant="link">@figueroaignacio</Button>13 </HoverCard.Trigger>14 <HoverCard.Content className="w-72">15 <div className="flex gap-3">16 <Avatar>17 <Avatar.Image src="https://github.com/figueroaignacio.png" alt="Ignacio Figueroa" />18 <Avatar.Fallback>IF</Avatar.Fallback>19 </Avatar>20 <div className="flex flex-col gap-1">21 <p className="text-sm font-semibold">@figueroaignacio</p>22 <p className="text-muted-foreground text-xs">23 Building NachUI, a copy-paste component library with zero dependencies.24 </p>25 <p className="text-muted-foreground flex items-center gap-1.5 pt-1 text-xs">26 <CalendarIcon size={14} />27 Joined March 202428 </p>29 </div>30 </div>31 </HoverCard.Content>32 </HoverCard>33 );34}
Link preview
Inline in a paragraph, opening above and aligned to the start of the link.
The incident started after the v2.24.0 release rolled out to the EU region, and was resolved by rolling back the pooling change.
1'use client';23import { HoverCard } from '@/components/ui/hover-card';45export function LinkPreview() {6 return (7 <p className="text-muted-foreground max-w-sm text-sm leading-relaxed">8 The incident started after the{' '}9 <HoverCard openDelay={200}>10 <HoverCard.Trigger asChild>11 <a12 href="#release-v2-24-0"13 className="text-foreground font-medium underline underline-offset-4"14 >15 v2.24.0 release16 </a>17 </HoverCard.Trigger>18 <HoverCard.Content side="top" align="start" className="w-64 p-0">19 <div className="bg-muted h-24 rounded-t-md" />20 <div className="flex flex-col gap-1 p-3">21 <p className="text-sm font-medium">Release v2.24.0</p>22 <p className="text-muted-foreground text-xs">23 Connection pooling for the EU region, new webhook retries, and 14 fixes.24 </p>25 <p className="text-muted-foreground text-xs">github.com/northwind/api</p>26 </div>27 </HoverCard.Content>28 </HoverCard>{' '}29 rolled out to the EU region, and was resolved by rolling back the pooling change.30 </p>31 );32}
API Reference
HoverCard
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | - | Controlled open state |
defaultOpen | boolean | false | Initial state when uncontrolled |
onOpenChange | (open: boolean) => void | - | Called when the state changes |
openDelay | number | 500 | Milliseconds of hover before opening |
closeDelay | number | 200 | Milliseconds after leaving before closing |
HoverCard.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Attach the handlers to the child instead of a span |
HoverCard.Content
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "bottom" | Placement relative to the trigger |
align | "start" | "center" | "end" | "center" | Alignment along that side |
sideOffset | number | 4 | Gap from the trigger in pixels |
className | string | - | Additional CSS classes |
Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.