GitHub

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

Features

  • Hover-only: Does not open on click — hover-activated only
  • Configurable delays: openDelay (default 700ms) and closeDelay (default 300ms)
  • 4-side positioning: top, right, bottom, left with start/center/end alignment
  • Controlled/uncontrolled: Use open + onOpenChange or 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

PropTypeDefaultDescription
openbooleanControlled open state.
defaultOpenbooleanfalseUncontrolled initial state.
onOpenChange(open: boolean) => voidCalled when open state changes.
openDelaynumber700Milliseconds before opening on hover.
closeDelaynumber300Milliseconds 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.
classNamestringAdditional CSS classes for the container.
React Principles