bigbullui logobigbullui
Componentfeedback

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-card

Or install the full npm package:

npm install bigbullui

Usage

import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";

<HoverCard>
  <HoverCardTrigger asChild><button>Hover Pass</button></HoverCardTrigger>
  <HoverCardContent>Ticket details</HoverCardContent>
</HoverCard>

Source code

hover-card.tsx

Zero 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;
}

export function HoverCardContent({
  align = "center",
  side = "bottom",
  showBeak = true,
  children,
  className,
  ...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 animate-[scale-in_0.16s_ease-out_both] select-none",
        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-left", 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-left", 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

PropTypeDescription
openDelaynumberDelay in ms before hover card reveals (default 200).
closeDelaynumberDelay in ms before hover card dismisses (default 250).
side"top" | "bottom"Placement side relative to trigger (default 'bottom').
showBeakbooleanRenders pointer beak notch directed towards trigger (default true).