Hover Card
A card that appears on hover over a trigger element, with configurable positioning.
Installation
Install the component Hover Card in your project using the CLI.
Hover Card.tsx
pnpm dlx behsseui@latest add HoverCardInstall the component manually.
Create a ui folder at the root of the project, then a component folder inside it, and finally a Hover Card.tsx file in that folder.
Copy and paste the following code into your project.
ui/components/HoverCard.tsx
1"use client"23import type { ReactNode, HTMLAttributes } from "react"4import { createContext, useContext, useState, useEffect, useCallback, useRef } from "react"5import { cn } from "@/lib/utils"67// Context8type HoverCardContextType = {9 open: boolean10 onOpenChange: (open: boolean) => void11 triggerRef: React.RefObject<HTMLElement | null>12 timeoutRef: React.RefObject<NodeJS.Timeout | null>13 openDelay: number14 closeDelay: number15}1617const HoverCardContext = createContext<HoverCardContextType | undefined>(undefined)1819function useHoverCard() {20 const context = useContext(HoverCardContext)21 if (!context) {22 throw new Error("HoverCard components must be used within a HoverCard")23 }24 return context25}2627// Root28type HoverCardProps = {29 children: ReactNode30 open?: boolean31 defaultOpen?: boolean32 onOpenChange?: (open: boolean) => void33 openDelay?: number34 closeDelay?: number35}3637export function HoverCard({38 children,39 open: controlledOpen,40 defaultOpen = false,41 onOpenChange,42 openDelay = 200,43 closeDelay = 150,44}: HoverCardProps) {45 const [internalOpen, setInternalOpen] = useState(defaultOpen)46 const triggerRef = useRef<HTMLElement | null>(null)47 const timeoutRef = useRef<NodeJS.Timeout | null>(null)4849 const isControlled = controlledOpen !== undefined50 const open = isControlled ? controlledOpen : internalOpen5152 const handleOpenChange = useCallback((newOpen: boolean) => {53 if (!isControlled) {54 setInternalOpen(newOpen)55 }56 onOpenChange?.(newOpen)57 }, [isControlled, onOpenChange])5859 useEffect(() => {60 return () => {61 if (timeoutRef.current) {62 clearTimeout(timeoutRef.current)63 }64 }65 }, [])6667 return (68 <HoverCardContext.Provider value={{ open, onOpenChange: handleOpenChange, triggerRef, timeoutRef, openDelay, closeDelay }}>69 <div className="relative inline-block">70 {children}71 </div>72 </HoverCardContext.Provider>73 )74}7576// Trigger77type HoverCardTriggerProps = {78 children: ReactNode79 className?: string80 asChild?: boolean81} & HTMLAttributes<HTMLElement>8283export function HoverCardTrigger({ children, className, asChild, ...props }: HoverCardTriggerProps) {84 const { onOpenChange, triggerRef, timeoutRef, openDelay, closeDelay } = useHoverCard()8586 const handleMouseEnter = () => {87 if (timeoutRef.current) {88 clearTimeout(timeoutRef.current)89 timeoutRef.current = null90 }91 timeoutRef.current = setTimeout(() => {92 onOpenChange(true)93 timeoutRef.current = null94 }, openDelay)95 }9697 const handleMouseLeave = () => {98 if (timeoutRef.current) {99 clearTimeout(timeoutRef.current)100 timeoutRef.current = null101 }102 timeoutRef.current = setTimeout(() => {103 onOpenChange(false)104 timeoutRef.current = null105 }, closeDelay)106 }107108 if (asChild) {109 return (110 <span111 ref={triggerRef as React.RefObject<HTMLSpanElement>}112 className={className}113 onMouseEnter={handleMouseEnter}114 onMouseLeave={handleMouseLeave}115 onFocus={handleMouseEnter}116 onBlur={handleMouseLeave}117 {...props}118 >119 {children}120 </span>121 )122 }123124 return (125 <span126 ref={triggerRef as React.RefObject<HTMLSpanElement>}127 className={cn("cursor-pointer", className)}128 onMouseEnter={handleMouseEnter}129 onMouseLeave={handleMouseLeave}130 onFocus={handleMouseEnter}131 onBlur={handleMouseLeave}132 tabIndex={0}133 {...props}134 >135 {children}136 </span>137 )138}139140// Content141type HoverCardContentProps = {142 children: ReactNode143 className?: string144 side?: "top" | "bottom" | "left" | "right"145 align?: "start" | "center" | "end"146 sideOffset?: number147} & HTMLAttributes<HTMLDivElement>148149export function HoverCardContent({150 children,151 className,152 side = "bottom",153 align = "center",154 sideOffset = 8,155 ...props156}: HoverCardContentProps) {157 const { open, onOpenChange, timeoutRef, closeDelay } = useHoverCard()158159 const handleMouseEnter = () => {160 if (timeoutRef.current) {161 clearTimeout(timeoutRef.current)162 timeoutRef.current = null163 }164 }165166 const handleMouseLeave = () => {167 if (timeoutRef.current) {168 clearTimeout(timeoutRef.current)169 timeoutRef.current = null170 }171 timeoutRef.current = setTimeout(() => {172 onOpenChange(false)173 timeoutRef.current = null174 }, closeDelay)175 }176177 // Close on escape178 useEffect(() => {179 if (!open) return180181 const handleEscape = (e: KeyboardEvent) => {182 if (e.key === "Escape") {183 onOpenChange(false)184 }185 }186187 document.addEventListener("keydown", handleEscape)188 return () => document.removeEventListener("keydown", handleEscape)189 }, [open, onOpenChange])190191 if (!open) return null192193 const sideClasses = {194 bottom: "top-full left-0",195 top: "bottom-full left-0",196 left: "right-full top-0",197 right: "left-full top-0",198 }199200 const alignClasses = {201 start: side === "top" || side === "bottom" ? "left-0" : "top-0",202 center: side === "top" || side === "bottom" ? "left-1/2 -translate-x-1/2" : "top-1/2 -translate-y-1/2",203 end: side === "top" || side === "bottom" ? "right-0" : "bottom-0",204 }205206 return (207 <div208 role="tooltip"209 className={cn(210 "absolute z-50 w-max rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md",211 "animate-in fade-in-0 zoom-in-95",212 side === "bottom" && "slide-in-from-top-2",213 side === "top" && "slide-in-from-bottom-2",214 side === "left" && "slide-in-from-right-2",215 side === "right" && "slide-in-from-left-2",216 sideClasses[side],217 alignClasses[align],218 className219 )}220 style={{221 ...(side === "bottom" ? { marginTop: `${sideOffset}px` } : {}),222 ...(side === "top" ? { marginBottom: `${sideOffset}px` } : {}),223 ...(side === "left" ? { marginRight: `${sideOffset}px` } : {}),224 ...(side === "right" ? { marginLeft: `${sideOffset}px` } : {}),225 }}226 onMouseEnter={handleMouseEnter}227 onMouseLeave={handleMouseLeave}228 {...props}229 >230 {children}231 </div>232 )233}234Usages
Different variants and use cases for the Hover Card component.
Default
A hover card that appears below the trigger on hover.
Default.tsx
<HoverCard>
<HoverCardTrigger asChild>
<a href="#" className="text-sm font-medium underline underline-offset-4">
@behsse
</a>
</HoverCardTrigger>
<HoverCardContent>
<div className="flex gap-4">
<div className="h-10 w-10 rounded-full bg-muted" />
<div className="space-y-1">
<h4 className="text-sm font-semibold">Behsse UI</h4>
<p className="text-sm text-muted-foreground">
A modern React component library.
</p>
</div>
</div>
</HoverCardContent>
</HoverCard>Top
Content appears above the trigger.
Top.tsx
<HoverCard>
<HoverCardTrigger asChild>
<a href="#" className="text-sm font-medium underline underline-offset-4">
Hover me (top)
</a>
</HoverCardTrigger>
<HoverCardContent side="top">
<p className="text-sm">This card appears above the trigger.</p>
</HoverCardContent>
</HoverCard>Left
Content appears to the left of the trigger.
Left.tsx
<HoverCard>
<HoverCardTrigger asChild>
<a href="#" className="text-sm font-medium underline underline-offset-4">
Hover me (left)
</a>
</HoverCardTrigger>
<HoverCardContent side="left">
<p className="text-sm">This card appears to the left.</p>
</HoverCardContent>
</HoverCard>Right
Content appears to the right of the trigger.
Right.tsx
<HoverCard>
<HoverCardTrigger asChild>
<a href="#" className="text-sm font-medium underline underline-offset-4">
Hover me (right)
</a>
</HoverCardTrigger>
<HoverCardContent side="right">
<p className="text-sm">This card appears to the right.</p>
</HoverCardContent>
</HoverCard>