HoverCard
A popover that appears on hover, used for non-critical previews like user profiles, link previews, or term definitions.
AccessibleDark Mode4 Sides
Install
$
npx react-principles add hover-card01
Features
- ✓Hover-only: Does not open on click — hover-activated only
- ✓Configurable delays:
openDelay(default 700ms) andcloseDelay(default 300ms) - ✓4-side positioning: top, right, bottom, left with start/center/end alignment
- ✓Controlled/uncontrolled: Use
open+onOpenChangeor let it manage its own state - ✓Non-intrusive: Does not interfere with keyboard navigation
02
Live Demo
Default (bottom, start)
Hover over me
Rich content (user profile preview)
Hover to preview profile
03
Code Snippet
HoverCard.tsx
import { HoverCard } from "@/ui/HoverCard"; function MyComponent() { return ( <HoverCard> <HoverCard.Trigger>Hover over me</HoverCard.Trigger> <HoverCard.Content> <p>Card content appears here</p> </HoverCard.Content> </HoverCard> ); }
04
Copy-Paste (Single File)
HoverCard.tsx
"use client"; import { createContext, useCallback, useContext, useEffect, useRef, useState, type HTMLAttributes, type ReactNode, } from "react"; import { cn } from "@/shared/utils/cn"; // ─── Types ──────────────────────────────────────────────────────────────────── export type HoverCardSide = "top" | "right" | "bottom" | "left"; export type HoverCardAlign = "start" | "center" | "end"; export interface HoverCardProps { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; openDelay?: number; closeDelay?: number; side?: HoverCardSide; align?: HoverCardAlign; children: ReactNode; className?: string; } export interface HoverCardTriggerProps extends HTMLAttributes<HTMLSpanElement> { children: ReactNode; } export interface HoverCardContentProps extends HTMLAttributes<HTMLDivElement> { children: ReactNode; } // ─── Context ────────────────────────────────────────────────────────────────── interface HoverCardContextValue { open: boolean; setOpen: (open: boolean) => void; openDelay: number; closeDelay: number; side: HoverCardSide; align: HoverCardAlign; } const HoverCardContext = createContext<HoverCardContextValue | null>(null); function useHoverCardContext() { const ctx = useContext(HoverCardContext); if (!ctx) throw new Error("HoverCard sub-components must be used inside <HoverCard>"); return ctx; } // ─── Root ───────────────────────────────────────────────────────────────────── export function HoverCard({ open, defaultOpen = false, onOpenChange, openDelay = 700, closeDelay = 300, side = "bottom", align = "start", children, className, }: HoverCardProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const isControlled = open !== undefined; const isOpen = isControlled ? open : internalOpen; const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const setOpen = useCallback((next: boolean) => { if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, [isControlled, onOpenChange]); const handleMouseEnter = useCallback(() => { if (closeTimerRef.current) { clearTimeout(closeTimerRef.current); closeTimerRef.current = null; } if (isOpen) return; openTimerRef.current = setTimeout(() => { setOpen(true); openTimerRef.current = null; }, openDelay); }, [isOpen, openDelay, setOpen]); const handleMouseLeave = useCallback(() => { if (openTimerRef.current) { clearTimeout(openTimerRef.current); openTimerRef.current = null; } if (!isOpen) return; closeTimerRef.current = setTimeout(() => { setOpen(false); closeTimerRef.current = null; }, closeDelay); }, [isOpen, closeDelay, setOpen]); useEffect(() => () => { if (openTimerRef.current) clearTimeout(openTimerRef.current); if (closeTimerRef.current) clearTimeout(closeTimerRef.current); }, []); return ( <HoverCardContext.Provider value={{ open: isOpen, setOpen, openDelay, closeDelay, side, align }}> <div ref={useRef<HTMLDivElement>(null)} className={cn("relative inline-flex", className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}> {children} </div> </HoverCardContext.Provider> ); } // ─── Trigger ───────────────────────────────────────────────────────────────── HoverCard.Trigger = function HoverCardTrigger({ children, className, ...props }: HoverCardTriggerProps) { return <span className={cn("inline-flex", className)} {...props}>{children}</span>; }; // ─── Content ────────────────────────────────────────────────────────────────── const SIDE_CLASS: Record<HoverCardSide, string> = { top: "bottom-[calc(100%+8px)]", bottom: "top-[calc(100%+8px)]", left: "right-[calc(100%+8px)]", right: "left-[calc(100%+8px)]", }; const ALIGN_CLASS: Record<HoverCardAlign, string> = { start: "left-0", center: "left-1/2 -translate-x-1/2", end: "right-0", }; const VERTICAL_ALIGN_CLASS: Record<HoverCardAlign, string> = { start: "top-0", center: "top-1/2 -translate-y-1/2", end: "bottom-0", }; HoverCard.Content = function HoverCardContent({ children, className, ...props }: HoverCardContentProps) { const { open, side, align } = useHoverCardContext(); if (!open) return null; const isHorizontal = side === "left" || side === "right"; return ( <div className={cn( "absolute z-50 w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-lg dark:border-[#1f2937] dark:bg-[#161b22]", SIDE_CLASS[side], isHorizontal ? VERTICAL_ALIGN_CLASS[align] : ALIGN_CLASS[align], className, )} {...props}> {children} </div> ); };
05
Usage Examples
Custom side and alignment
<HoverCard side="top" align="center"> <HoverCard.Trigger>Hover me</HoverCard.Trigger> <HoverCard.Content> <p>Centered above the trigger</p> </HoverCard.Content> </HoverCard>
Custom delays
<HoverCard openDelay={0} closeDelay={200}> <HoverCard.Trigger>Instant open</HoverCard.Trigger> <HoverCard.Content> <p>Opens immediately, closes after 200ms</p> </HoverCard.Content> </HoverCard>
Controlled state
const [open, setOpen] = useState(false); <HoverCard open={open} onOpenChange={setOpen}> <HoverCard.Trigger>Programmatically controlled</HoverCard.Trigger> <HoverCard.Content> <p>This card is controlled</p> </HoverCard.Content> </HoverCard>
06
Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. |
defaultOpen | boolean | false | Uncontrolled initial state. |
onOpenChange | (open: boolean) => void | — | Called when open state changes. |
openDelay | number | 700 | Milliseconds before opening on hover. |
closeDelay | number | 300 | Milliseconds before closing when hover leaves. |
side | "top" | "right" | "bottom" | "left" | "bottom" | Which side of the trigger the card appears on. |
align | "start" | "center" | "end" | "start" | Alignment of the card along the trigger edge. |
className | string | — | Additional CSS classes for the container. |