Docs/Components/Hover Card

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 HoverCard

Install 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"
2
3import type { ReactNode, HTMLAttributes } from "react"
4import { createContext, useContext, useState, useEffect, useCallback, useRef } from "react"
5import { cn } from "@/lib/utils"
6
7// Context
8type HoverCardContextType = {
9 open: boolean
10 onOpenChange: (open: boolean) => void
11 triggerRef: React.RefObject<HTMLElement | null>
12 timeoutRef: React.RefObject<NodeJS.Timeout | null>
13 openDelay: number
14 closeDelay: number
15}
16
17const HoverCardContext = createContext<HoverCardContextType | undefined>(undefined)
18
19function useHoverCard() {
20 const context = useContext(HoverCardContext)
21 if (!context) {
22 throw new Error("HoverCard components must be used within a HoverCard")
23 }
24 return context
25}
26
27// Root
28type HoverCardProps = {
29 children: ReactNode
30 open?: boolean
31 defaultOpen?: boolean
32 onOpenChange?: (open: boolean) => void
33 openDelay?: number
34 closeDelay?: number
35}
36
37export 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)
48
49 const isControlled = controlledOpen !== undefined
50 const open = isControlled ? controlledOpen : internalOpen
51
52 const handleOpenChange = useCallback((newOpen: boolean) => {
53 if (!isControlled) {
54 setInternalOpen(newOpen)
55 }
56 onOpenChange?.(newOpen)
57 }, [isControlled, onOpenChange])
58
59 useEffect(() => {
60 return () => {
61 if (timeoutRef.current) {
62 clearTimeout(timeoutRef.current)
63 }
64 }
65 }, [])
66
67 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}
75
76// Trigger
77type HoverCardTriggerProps = {
78 children: ReactNode
79 className?: string
80 asChild?: boolean
81} & HTMLAttributes<HTMLElement>
82
83export function HoverCardTrigger({ children, className, asChild, ...props }: HoverCardTriggerProps) {
84 const { onOpenChange, triggerRef, timeoutRef, openDelay, closeDelay } = useHoverCard()
85
86 const handleMouseEnter = () => {
87 if (timeoutRef.current) {
88 clearTimeout(timeoutRef.current)
89 timeoutRef.current = null
90 }
91 timeoutRef.current = setTimeout(() => {
92 onOpenChange(true)
93 timeoutRef.current = null
94 }, openDelay)
95 }
96
97 const handleMouseLeave = () => {
98 if (timeoutRef.current) {
99 clearTimeout(timeoutRef.current)
100 timeoutRef.current = null
101 }
102 timeoutRef.current = setTimeout(() => {
103 onOpenChange(false)
104 timeoutRef.current = null
105 }, closeDelay)
106 }
107
108 if (asChild) {
109 return (
110 <span
111 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 }
123
124 return (
125 <span
126 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}
139
140// Content
141type HoverCardContentProps = {
142 children: ReactNode
143 className?: string
144 side?: "top" | "bottom" | "left" | "right"
145 align?: "start" | "center" | "end"
146 sideOffset?: number
147} & HTMLAttributes<HTMLDivElement>
148
149export function HoverCardContent({
150 children,
151 className,
152 side = "bottom",
153 align = "center",
154 sideOffset = 8,
155 ...props
156}: HoverCardContentProps) {
157 const { open, onOpenChange, timeoutRef, closeDelay } = useHoverCard()
158
159 const handleMouseEnter = () => {
160 if (timeoutRef.current) {
161 clearTimeout(timeoutRef.current)
162 timeoutRef.current = null
163 }
164 }
165
166 const handleMouseLeave = () => {
167 if (timeoutRef.current) {
168 clearTimeout(timeoutRef.current)
169 timeoutRef.current = null
170 }
171 timeoutRef.current = setTimeout(() => {
172 onOpenChange(false)
173 timeoutRef.current = null
174 }, closeDelay)
175 }
176
177 // Close on escape
178 useEffect(() => {
179 if (!open) return
180
181 const handleEscape = (e: KeyboardEvent) => {
182 if (e.key === "Escape") {
183 onOpenChange(false)
184 }
185 }
186
187 document.addEventListener("keydown", handleEscape)
188 return () => document.removeEventListener("keydown", handleEscape)
189 }, [open, onOpenChange])
190
191 if (!open) return null
192
193 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 }
199
200 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 }
205
206 return (
207 <div
208 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 className
219 )}
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}
234

Usages

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>