Componentfeedback3.46 KB gzipZero dependencies
Hover Card
Popover preview on hover and focus with configurable delays and alignment.
Preview
Live Preview
Installation
Tokens guide →Add to your project via CLI (zero dependencies, code you own):
npx bigbullui add hover-cardOr install the full npm package:
npm install bigbulluiUsage
Open in StackBlitzimport { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
<HoverCard>
<HoverCardTrigger asChild><button>Hover Pass</button></HoverCardTrigger>
<HoverCardContent>Ticket details</HoverCardContent>
</HoverCard>Usage guidance
Use Hover Card when a ticket stub surface needs Popover preview on hover and focus with configurable delays and alignment. Keep props minimal and prefer composition with sibling stubs.
Avoid Hover Card for long form flows or dense data grids where a dedicated layout block fits better.
Source code
hover-card.tsxZero dependencies (only React + utils). Copy and paste directly into your project:
"use client";
import * as React from "react";
import { cn } from "./lib/utils";
interface HoverCardContextValue {
open: boolean;
setOpen: (open: boolean) => void;
openDelay: number;
closeDelay: number;
handleTriggerEnter: () => void;
handleTriggerLeave: () => void;
handleContentEnter: () => void;
handleContentLeave: () => void;
handleFocus: () => void;
handleBlur: () => void;
triggerId: string;
contentId: string;
}
const HoverCardContext = React.createContext<HoverCardContextValue | null>(null);
function useHoverCardContext() {
const ctx = React.useContext(HoverCardContext);
if (!ctx) {
throw new Error("HoverCard subcomponents must be used within a HoverCard root");
}
return ctx;
}
export interface HoverCardProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
openDelay?: number;
closeDelay?: number;
children: React.ReactNode;
}
export function HoverCard({
open: controlledOpen,
defaultOpen = false,
onOpenChange,
openDelay = 200,
closeDelay = 250,
children,
}: HoverCardProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
const openTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerId = React.useId();
const contentId = React.useId();
const setOpen = React.useCallback(
(nextOpen: boolean) => {
if (!isControlled) {
setUncontrolledOpen(nextOpen);
}
onOpenChange?.(nextOpen);
},
[isControlled, onOpenChange]
);
const clearTimers = React.useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const handleTriggerEnter = React.useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
openTimerRef.current = setTimeout(() => {
setOpen(true);
}, openDelay);
}, [openDelay, setOpen]);
const handleTriggerLeave = React.useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
closeTimerRef.current = setTimeout(() => {
setOpen(false);
}, closeDelay);
}, [closeDelay, setOpen]);
const handleContentEnter = React.useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const handleContentLeave = React.useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
closeTimerRef.current = setTimeout(() => {
setOpen(false);
}, closeDelay);
}, [closeDelay, setOpen]);
const handleFocus = React.useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
openTimerRef.current = setTimeout(() => {
setOpen(true);
}, Math.min(openDelay, 100));
}, [openDelay, setOpen]);
const handleBlur = React.useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
closeTimerRef.current = setTimeout(() => {
setOpen(false);
}, closeDelay);
}, [closeDelay, setOpen]);
React.useEffect(() => {
return () => clearTimers();
}, [clearTimers]);
React.useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open, setOpen]);
return (
<HoverCardContext.Provider
value={{
open,
setOpen,
openDelay,
closeDelay,
handleTriggerEnter,
handleTriggerLeave,
handleContentEnter,
handleContentLeave,
handleFocus,
handleBlur,
triggerId,
contentId,
}}
>
<div className="relative inline-block">{children}</div>
</HoverCardContext.Provider>
);
}
export interface HoverCardTriggerProps extends React.HTMLAttributes<HTMLElement> {
asChild?: boolean;
children: React.ReactNode;
}
export function HoverCardTrigger({
asChild = false,
children,
className,
...props
}: HoverCardTriggerProps) {
const ctx = useHoverCardContext();
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<React.HTMLAttributes<HTMLElement>>;
return React.cloneElement(child, {
id: ctx.triggerId,
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
onMouseEnter: (e: React.MouseEvent<HTMLElement>) => {
child.props.onMouseEnter?.(e);
ctx.handleTriggerEnter();
},
onMouseLeave: (e: React.MouseEvent<HTMLElement>) => {
child.props.onMouseLeave?.(e);
ctx.handleTriggerLeave();
},
onFocus: (e: React.FocusEvent<HTMLElement>) => {
child.props.onFocus?.(e);
ctx.handleFocus();
},
onBlur: (e: React.FocusEvent<HTMLElement>) => {
child.props.onBlur?.(e);
ctx.handleBlur();
},
});
}
return (
<span
id={ctx.triggerId}
role="button"
tabIndex={0}
aria-haspopup="dialog"
aria-expanded={ctx.open}
aria-controls={ctx.open ? ctx.contentId : undefined}
onMouseEnter={ctx.handleTriggerEnter}
onMouseLeave={ctx.handleTriggerLeave}
onFocus={ctx.handleFocus}
onBlur={ctx.handleBlur}
className={cn(
"inline-flex items-center cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm",
className
)}
{...props}
>
{children}
</span>
);
}
export interface HoverCardContentProps extends React.HTMLAttributes<HTMLDivElement> {
align?: "start" | "center" | "end";
side?: "top" | "bottom";
showBeak?: boolean;
children: React.ReactNode;
className?: string;
/** Dual-mode: false renders instantly with no motion. Default true. */
animated?: boolean;
}
export function HoverCardContent({
align = "center",
side = "bottom",
showBeak = true,
children,
className,
animated = true,
...props
}: HoverCardContentProps) {
const ctx = useHoverCardContext();
const contentRef = React.useRef<HTMLDivElement>(null);
if (!ctx.open) return null;
return (
<div
ref={contentRef}
role="dialog"
id={ctx.contentId}
aria-labelledby={ctx.triggerId}
onMouseEnter={ctx.handleContentEnter}
onMouseLeave={ctx.handleContentLeave}
className={cn(
"absolute z-50 w-80 rounded-lg border-2 border-foreground bg-card text-card-foreground p-4 shadow-2xl outline-1 outline-dashed outline-offset-[-6px] outline-border select-none",
animated && "animate-[scale-in_0.16s_ease-out_both]",
side === "top"
? "bottom-[calc(100%+10px)]"
: "top-[calc(100%+10px)]",
align === "start" && "left-0",
align === "center" && "left-1/2 -translate-x-1/2",
align === "end" && "right-0",
className
)}
{...props}
>
{/* Pointer Beak Notch */}
{showBeak && (
<span
className={cn(
"pointer-events-none absolute size-3.5 rotate-45 bg-card z-10",
side === "bottom"
? "-top-[8px] border-t-2 border-l-2 border-foreground"
: "-bottom-[8px] border-b-2 border-r-2 border-foreground",
align === "start" && "left-6",
align === "center" && "left-1/2 -translate-x-1/2",
align === "end" && "right-6"
)}
aria-hidden="true"
/>
)}
{/* Ticket Stub Side Punch Notches */}
<span
className="pointer-events-none absolute -left-2.5 top-1/2 -translate-y-1/2 size-4 rounded-full bg-background border-2 border-foreground z-10"
aria-hidden="true"
/>
<span
className="pointer-events-none absolute -right-2.5 top-1/2 -translate-y-1/2 size-4 rounded-full bg-background border-2 border-foreground z-10"
aria-hidden="true"
/>
{children}
</div>
);
}
export interface HoverCardSeatSummaryProps extends React.HTMLAttributes<HTMLDivElement> {
badge?: string;
ticketNo?: string;
attendeeName: string;
attendeeRole?: string;
avatarUrl?: string;
avatarFallback?: string;
eventTitle?: string;
date?: string;
venue?: string;
tier?: string;
section?: string;
row?: string;
seat?: string;
barcode?: string;
status?: "admitted" | "confirmed" | "vip" | "reserved";
footerAction?: React.ReactNode;
}
export function HoverCardSeatSummary({
badge = "ADMISSION PASS",
ticketNo = "#TKT-0491",
attendeeName,
attendeeRole,
avatarUrl,
avatarFallback,
eventTitle,
date,
venue,
tier = "VIP GOLD",
section = "A1",
row = "04",
seat = "18",
barcode = "ADMIT-ONE",
status = "confirmed",
footerAction,
className,
children,
...props
}: HoverCardSeatSummaryProps) {
const initials =
avatarFallback ??
attendeeName
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<div className={cn("flex flex-col text-start", className)} {...props}>
{/* Top micro metadata header */}
<div className="flex items-center justify-between gap-2 border-b border-dashed border-border pb-2.5">
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.18em] text-accent">
{badge}
</span>
<span className="font-mono text-[10px] font-semibold text-muted-foreground">
{ticketNo}
</span>
</div>
{/* Attendee Profile Row */}
<div className="mt-3 flex items-center gap-3">
{avatarUrl ? (
<img
src={avatarUrl}
alt={attendeeName}
className="size-10 shrink-0 rounded-md border-[1.5px] border-foreground object-cover shadow-sm"
/>
) : (
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border-[1.5px] border-foreground bg-secondary font-mono text-xs font-bold text-foreground shadow-sm">
{initials}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-sans text-sm font-bold text-foreground">
{attendeeName}
</span>
{status && (
<span
className={cn(
"shrink-0 rounded px-1 py-0.5 font-mono text-[9px] font-bold uppercase tracking-wider",
status === "vip" && "bg-accent text-accent-foreground",
status === "confirmed" &&
"border border-foreground/20 bg-secondary text-secondary-foreground",
status === "admitted" && "bg-foreground text-background",
status === "reserved" && "bg-muted text-muted-foreground"
)}
>
{status}
</span>
)}
</div>
{attendeeRole && (
<p className="truncate font-mono text-[11px] text-muted-foreground">
{attendeeRole}
</p>
)}
</div>
</div>
{/* Event Details */}
{(eventTitle || date || venue) && (
<div className="mt-2.5 rounded border border-border/70 bg-secondary/40 p-2 text-xs">
{eventTitle && (
<p className="truncate font-semibold text-foreground">{eventTitle}</p>
)}
<div className="mt-0.5 flex items-center justify-between font-mono text-[11px] text-muted-foreground">
{date && <span>{date}</span>}
{venue && <span className="max-w-[120px] truncate">{venue}</span>}
</div>
</div>
)}
{/* Seating Grid */}
<div className="mt-3 grid grid-cols-4 gap-1.5 border-y-2 border-dashed border-border py-2 text-center">
<div>
<span className="block font-mono text-[9px] uppercase tracking-widest text-muted-foreground">
TIER
</span>
<span className="block truncate font-mono text-xs font-extrabold text-foreground">
{tier}
</span>
</div>
<div>
<span className="block font-mono text-[9px] uppercase tracking-widest text-muted-foreground">
SEC
</span>
<span className="block font-mono text-xs font-extrabold text-foreground">
{section}
</span>
</div>
<div>
<span className="block font-mono text-[9px] uppercase tracking-widest text-muted-foreground">
ROW
</span>
<span className="block font-mono text-xs font-extrabold text-foreground">
{row}
</span>
</div>
<div>
<span className="block font-mono text-[9px] uppercase tracking-widest text-muted-foreground">
SEAT
</span>
<span className="block font-mono text-xs font-extrabold text-accent">
{seat}
</span>
</div>
</div>
{/* Barcode Strip & Footer */}
<div className="mt-2.5 flex items-center justify-between pt-1">
<div className="flex flex-col">
<div className="flex h-4 items-center gap-[2px] opacity-85" aria-hidden="true">
<span className="h-full w-[2px] bg-foreground" />
<span className="h-full w-[1px] bg-foreground" />
<span className="h-full w-[3px] bg-foreground" />
<span className="h-full w-[1px] bg-foreground" />
<span className="h-full w-[2px] bg-foreground" />
<span className="h-full w-[4px] bg-foreground" />
<span className="h-full w-[1px] bg-foreground" />
<span className="h-full w-[2px] bg-foreground" />
<span className="h-full w-[3px] bg-foreground" />
<span className="h-full w-[1px] bg-foreground" />
<span className="h-full w-[2px] bg-foreground" />
<span className="h-full w-[1px] bg-foreground" />
<span className="h-full w-[3px] bg-foreground" />
<span className="h-full w-[1px] bg-foreground" />
<span className="h-full w-[2px] bg-foreground" />
</div>
<span className="mt-0.5 font-mono text-[8px] uppercase tracking-widest text-muted-foreground">
{barcode}
</span>
</div>
{footerAction}
</div>
{children}
</div>
);
}
export function HoverCardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("mb-2 flex flex-col gap-1 text-start", className)} {...props} />;
}
export function HoverCardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h4 className={cn("font-sans text-sm font-bold tracking-tight text-foreground", className)} {...props} />;
}
export function HoverCardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("font-mono text-xs text-muted-foreground", className)} {...props} />;
}
export function HoverCardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("mt-3 flex items-center justify-between border-t border-dashed border-border pt-2", className)}
{...props}
/>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| open? | boolean | |
| defaultOpen? | boolean | |
| onOpenChange? | (open: boolean) => void | |
| openDelay? | number | |
| closeDelay? | number | |
| children | React.ReactNode |