Hover Card

For sighted users to preview content available behind a link.

The Hover Card component displays a card when hovering over a trigger element. It's useful for showing additional information or previews without navigating away. Built from scratch using React and native HTML elements. No dependencies on any UI library.

Code

TypeScript: Copy this code into components/ui/hover-card.tsx:

tsx
"use client"

import * as React from "react"
import * as ReactDOM from "react-dom"
import { cn } from "@/lib/utils"

interface HoverCardContextValue {
  open: boolean
  setOpen: (open: boolean) => void
  triggerRef: React.RefObject<HTMLElement>
}

const HoverCardContext = React.createContext<HoverCardContextValue | undefined>(undefined)

const useHoverCard = () => {
  const context = React.useContext(HoverCardContext)
  if (!context) {
    throw new Error("HoverCard components must be used within HoverCard")
  }
  return context
}

interface HoverCardProps {
  openDelay?: number
  closeDelay?: number
  children: React.ReactNode
}

const HoverCard = ({ openDelay = 200, closeDelay = 200, children }: HoverCardProps) => {
  const [open, setOpen] = React.useState(false)
  const triggerRef = React.useRef<HTMLElement>(null)

  return (
    <HoverCardContext.Provider value={{ open, setOpen, triggerRef }}>
      {children}
    </HoverCardContext.Provider>
  )
}

interface HoverCardTriggerProps extends React.HTMLAttributes<HTMLElement> {
  asChild?: boolean
}

const HoverCardTrigger = React.forwardRef<HTMLElement, HoverCardTriggerProps>(
  ({ className, children, asChild, ...props }, ref) => {
    const { setOpen, triggerRef: contextTriggerRef } = useHoverCard()
    const localTriggerRef = React.useRef<HTMLElement>(null)

    React.useImperativeHandle(ref, () => localTriggerRef.current as HTMLElement)

    React.useEffect(() => {
      if (localTriggerRef.current) {
        contextTriggerRef.current = localTriggerRef.current
      }
    }, [contextTriggerRef])

    const handleMouseEnter = () => {
      setOpen(true)
    }

    const handleMouseLeave = () => {
      setOpen(false)
    }

    if (asChild && React.isValidElement(children)) {
      return React.cloneElement(children, {
        onMouseEnter: handleMouseEnter,
        onMouseLeave: handleMouseLeave,
        ref: localTriggerRef,
        ...props,
      } as any)
    }

    return (
      <span
        ref={localTriggerRef as any}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        className={cn("cursor-pointer", className)}
        {...props}
      >
        {children}
      </span>
    )
  }
)
HoverCardTrigger.displayName = "HoverCardTrigger"

interface HoverCardContentProps extends React.HTMLAttributes<HTMLDivElement> {
  align?: "start" | "center" | "end"
  side?: "top" | "right" | "bottom" | "left"
  sideOffset?: number
}

const HoverCardContent = React.forwardRef<HTMLDivElement, HoverCardContentProps>(
  ({ className, align = "center", side = "bottom", sideOffset = 4, children, ...props }, ref) => {
    const { open, triggerRef } = useHoverCard()
    const contentRef = React.useRef<HTMLDivElement>(null)
    const [isVisible, setIsVisible] = React.useState(false)

    React.useImperativeHandle(ref, () => contentRef.current as HTMLDivElement)

    React.useEffect(() => {
      if (open) {
        setIsVisible(true)
      } else {
        const timer = setTimeout(() => setIsVisible(false), 200)
        return () => clearTimeout(timer)
      }
    }, [open])

    React.useEffect(() => {
      if (!open || !contentRef.current || !triggerRef.current) return

      const content = contentRef.current
      const trigger = triggerRef.current

      const triggerRect = trigger.getBoundingClientRect()
      const contentRect = content.getBoundingClientRect()
      const viewportWidth = window.innerWidth
      const viewportHeight = window.innerHeight

      let top = 0
      let left = 0

      switch (side) {
        case "top":
          top = triggerRect.top - contentRect.height - sideOffset
          break
        case "bottom":
          top = triggerRect.bottom + sideOffset
          break
        case "left":
          top = triggerRect.top + (triggerRect.height - contentRect.height) / 2
          break
        case "right":
          top = triggerRect.top + (triggerRect.height - contentRect.height) / 2
          break
      }

      switch (align) {
        case "start":
          left = triggerRect.left
          break
        case "center":
          left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
          break
        case "end":
          left = triggerRect.right - contentRect.width
          break
      }

      if (side === "left") {
        left = triggerRect.left - contentRect.width - sideOffset
      } else if (side === "right") {
        left = triggerRect.right + sideOffset
      }

      if (left + contentRect.width > viewportWidth) {
        left = viewportWidth - contentRect.width - 8
      }
      if (left < 8) left = 8
      if (top + contentRect.height > viewportHeight) {
        top = viewportHeight - contentRect.height - 8
      }
      if (top < 8) top = 8

      content.style.position = "fixed"
      content.style.top = `${top}px`
      content.style.left = `${left}px`
    }, [open, align, side, sideOffset, triggerRef])

    if (!isVisible) return null

    const content = (
      <div
        ref={contentRef}
        data-hover-card-content
        className={cn(
          "fixed z-50 w-64 rounded-md border-2 border-foreground bg-background p-4 neobrutalism-shadow",
          "transition-opacity duration-200",
          open ? "opacity-100" : "opacity-0",
          className
        )}
        {...props}
      >
        {children}
      </div>
    )

    return ReactDOM.createPortal(content, document.body)
  }
)
HoverCardContent.displayName = "HoverCardContent"

export {
  HoverCard,
  HoverCardTrigger,
  HoverCardContent,
}

JavaScript: Copy this code into components/ui/hover-card.jsx:

jsx
"use client"

import * as React from "react"
import * as ReactDOM from "react-dom"
import { cn } from "@/lib/utils"

const HoverCardContext = React.createContext(undefined)

const useHoverCard = () => {
  const context = React.useContext(HoverCardContext)
  if (!context) {
    throw new Error("HoverCard components must be used within HoverCard")
  }
  return context
}

const HoverCard = ({ openDelay = 200, closeDelay = 200, children }) => {
  const [open, setOpen] = React.useState(false)
  const triggerRef = React.useRef(null)

  return (
    <HoverCardContext.Provider value={{ open, setOpen, triggerRef }}>
      {children}
    </HoverCardContext.Provider>
  )
}

const HoverCardTrigger = React.forwardRef(
  ({ className, children, asChild, ...props }, ref) => {
    const { setOpen, triggerRef: contextTriggerRef } = useHoverCard()
    const localTriggerRef = React.useRef(null)

    React.useImperativeHandle(ref, () => localTriggerRef.current)

    React.useEffect(() => {
      if (localTriggerRef.current) {
        contextTriggerRef.current = localTriggerRef.current
      }
    }, [contextTriggerRef])

    const handleMouseEnter = () => {
      setOpen(true)
    }

    const handleMouseLeave = () => {
      setOpen(false)
    }

    if (asChild && React.isValidElement(children)) {
      return React.cloneElement(children, {
        onMouseEnter: handleMouseEnter,
        onMouseLeave: handleMouseLeave,
        ref: localTriggerRef,
        ...props,
      })
    }

    return (
      <span
        ref={localTriggerRef}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        className={cn("cursor-pointer", className)}
        {...props}
      >
        {children}
      </span>
    )
  }
)
HoverCardTrigger.displayName = "HoverCardTrigger"

const HoverCardContent = React.forwardRef(
  ({ className, align = "center", side = "bottom", sideOffset = 4, children, ...props }, ref) => {
    const { open, triggerRef } = useHoverCard()
    const contentRef = React.useRef(null)
    const [isVisible, setIsVisible] = React.useState(false)

    React.useImperativeHandle(ref, () => contentRef.current)

    React.useEffect(() => {
      if (open) {
        setIsVisible(true)
      } else {
        const timer = setTimeout(() => setIsVisible(false), 200)
        return () => clearTimeout(timer)
      }
    }, [open])

    React.useEffect(() => {
      if (!open || !contentRef.current || !triggerRef.current) return

      const content = contentRef.current
      const trigger = triggerRef.current

      const triggerRect = trigger.getBoundingClientRect()
      const contentRect = content.getBoundingClientRect()
      const viewportWidth = window.innerWidth
      const viewportHeight = window.innerHeight

      let top = 0
      let left = 0

      switch (side) {
        case "top":
          top = triggerRect.top - contentRect.height - sideOffset
          break
        case "bottom":
          top = triggerRect.bottom + sideOffset
          break
        case "left":
          top = triggerRect.top + (triggerRect.height - contentRect.height) / 2
          break
        case "right":
          top = triggerRect.top + (triggerRect.height - contentRect.height) / 2
          break
      }

      switch (align) {
        case "start":
          left = triggerRect.left
          break
        case "center":
          left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
          break
        case "end":
          left = triggerRect.right - contentRect.width
          break
      }

      if (side === "left") {
        left = triggerRect.left - contentRect.width - sideOffset
      } else if (side === "right") {
        left = triggerRect.right + sideOffset
      }

      if (left + contentRect.width > viewportWidth) {
        left = viewportWidth - contentRect.width - 8
      }
      if (left < 8) left = 8
      if (top + contentRect.height > viewportHeight) {
        top = viewportHeight - contentRect.height - 8
      }
      if (top < 8) top = 8

      content.style.position = "fixed"
      content.style.top = `${top}px`
      content.style.left = `${left}px`
    }, [open, align, side, sideOffset, triggerRef])

    if (!isVisible) return null

    const content = (
      <div
        ref={contentRef}
        data-hover-card-content
        className={cn(
          "fixed z-50 w-64 rounded-md border-2 border-foreground bg-background p-4 neobrutalism-shadow",
          "transition-opacity duration-200",
          open ? "opacity-100" : "opacity-0",
          className
        )}
        {...props}
      >
        {children}
      </div>
    )

    return ReactDOM.createPortal(content, document.body)
  }
)
HoverCardContent.displayName = "HoverCardContent"

export {
  HoverCard,
  HoverCardTrigger,
  HoverCardContent,
}

Usage

TypeScript:

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

function MyComponent() {
  return (
    <HoverCard>
      <HoverCardTrigger asChild>
        <a href="https://nextjs.org">Next.js</a>
      </HoverCardTrigger>
      <HoverCardContent>
        <div className="space-y-1">
          <h4 className="text-sm font-bold">The React Framework</h4>
          <p className="text-sm text-muted-foreground">
            Created and maintained by @vercel.
          </p>
        </div>
      </HoverCardContent>
    </HoverCard>
  )
}

JavaScript:

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

function MyComponent() {
  return (
    <HoverCard>
      <HoverCardTrigger asChild>
        <a href="https://nextjs.org">Next.js</a>
      </HoverCardTrigger>
      <HoverCardContent>
        <div className="space-y-1">
          <h4 className="text-sm font-bold">The React Framework</h4>
          <p className="text-sm text-muted-foreground">
            Created and maintained by @vercel.
          </p>
        </div>
      </HoverCardContent>
    </HoverCard>
  )
}

Make sure you also have the lib/utils.ts file with the cn helper function.

Examples

Default

With Link

Next.js