Tooltip

A popup that displays information related to an element when it receives keyboard focus or mouse hover.

The Tooltip component displays a small popup with additional information when users hover over or focus on an element. It provides contextual help without cluttering the interface. Built from scratch using React and native HTML elements. No dependencies on any UI library.

Code

TypeScript: Copy this code into components/ui/tooltip.tsx:

tsx
"use client"

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

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

const TooltipContext = React.createContext<TooltipContextValue | undefined>(undefined)

const useTooltip = () => {
  const context = React.useContext(TooltipContext)
  if (!context) {
    throw new Error("Tooltip components must be used within Tooltip")
  }
  return context
}

interface TooltipProps {
  delayDuration?: number
  children: React.ReactNode
}

const Tooltip = ({ delayDuration = 0, children }: TooltipProps) => {
  const [open, setOpen] = React.useState(false)
  const triggerRef = React.useRef<HTMLElement>(null)
  const openTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)
  const closeTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)

  const handleOpen = React.useCallback(() => {
    if (closeTimeoutRef.current) {
      clearTimeout(closeTimeoutRef.current)
      closeTimeoutRef.current = null
    }
    if (openTimeoutRef.current) {
      clearTimeout(openTimeoutRef.current)
    }
    openTimeoutRef.current = setTimeout(() => {
      setOpen(true)
      openTimeoutRef.current = null
    }, delayDuration)
  }, [delayDuration])

  const handleClose = React.useCallback(() => {
    if (openTimeoutRef.current) {
      clearTimeout(openTimeoutRef.current)
      openTimeoutRef.current = null
    }
    if (closeTimeoutRef.current) {
      clearTimeout(closeTimeoutRef.current)
    }
    closeTimeoutRef.current = setTimeout(() => {
      setOpen(false)
      closeTimeoutRef.current = null
    }, 100)
  }, [])

  React.useEffect(() => {
    return () => {
      if (openTimeoutRef.current) {
        clearTimeout(openTimeoutRef.current)
      }
      if (closeTimeoutRef.current) {
        clearTimeout(closeTimeoutRef.current)
      }
    }
  }, [])

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

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

const TooltipTrigger = React.forwardRef<HTMLElement, TooltipTriggerProps>(
  ({ className, children, asChild, ...props }, ref) => {
    const { handleOpen, handleClose, triggerRef } = useTooltip()
    const localTriggerRef = React.useRef<HTMLElement>(null)

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

    React.useEffect(() => {
      if (localTriggerRef.current) {
        ;(triggerRef as React.MutableRefObject<HTMLElement | null>).current = localTriggerRef.current
      }
    }, [triggerRef])

    const handleMouseEnter = (e: React.MouseEvent<HTMLElement>) => {
      handleOpen()
      props.onMouseEnter?.(e)
    }

    const handleMouseLeave = (e: React.MouseEvent<HTMLElement>) => {
      handleClose()
      props.onMouseLeave?.(e)
    }

    const handleFocus = (e: React.FocusEvent<HTMLElement>) => {
      handleOpen()
      props.onFocus?.(e)
    }

    const handleBlur = (e: React.FocusEvent<HTMLElement>) => {
      handleClose()
      props.onBlur?.(e)
    }

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

    return (
      <div
        ref={localTriggerRef as any}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        onFocus={handleFocus}
        onBlur={handleBlur}
        className={className}
        {...props}
      >
        {children}
      </div>
    )
  }
)
TooltipTrigger.displayName = "TooltipTrigger"

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

const TooltipContent = React.forwardRef<HTMLDivElement, TooltipContentProps>(
  ({ className, side = "top", align = "center", children, ...props }, ref) => {
    const { open, triggerRef } = useTooltip()
    const [position, setPosition] = React.useState({ top: 0, left: 0 })
    const contentRef = React.useRef<HTMLDivElement>(null)

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

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

      const updatePosition = () => {
        const trigger = triggerRef.current
        const content = contentRef.current
        if (!trigger || !content) return

        const triggerRect = trigger.getBoundingClientRect()
        const contentRect = content.getBoundingClientRect()
        const scrollX = window.scrollX || window.pageXOffset
        const scrollY = window.scrollY || window.pageYOffset

        let top = 0
        let left = 0

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

        switch (align) {
          case "start":
            if (side === "top" || side === "bottom") {
              left = triggerRect.left + scrollX
            } else {
              if (side === "left" || side === "right") {
                top = triggerRect.top + scrollY
              }
            }
            break
          case "end":
            if (side === "top" || side === "bottom") {
              left = triggerRect.right + scrollX - contentRect.width
            } else {
              if (side === "left" || side === "right") {
                top = triggerRect.bottom + scrollY - contentRect.height
              }
            }
            break
          case "center":
          default:
            if (side === "top" || side === "bottom") {
              left = triggerRect.left + scrollX + triggerRect.width / 2 - contentRect.width / 2
            }
            break
        }

        setPosition({ top, left })
      }

      updatePosition()
      window.addEventListener("scroll", updatePosition, true)
      window.addEventListener("resize", updatePosition)

      return () => {
        window.removeEventListener("scroll", updatePosition, true)
        window.removeEventListener("resize", updatePosition)
      }
    }, [open, side, align, triggerRef])

    if (!open) return null

    const content = (
      <div
        ref={contentRef}
        data-tooltip-content
        className={cn(
          "z-50 rounded-md border-2 border-foreground bg-primary px-3 py-1.5 text-sm font-bold text-primary-foreground neobrutalism-shadow-sm",
          className
        )}
        style={{
          position: "absolute",
          top: `${position.top}px`,
          left: `${position.left}px`,
        }}
        {...props}
      >
        {children}
      </div>
    )

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

export { Tooltip, TooltipTrigger, TooltipContent }

JavaScript: Copy this code into components/ui/tooltip.jsx:

jsx
"use client"

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

const TooltipContext = React.createContext(undefined)

const useTooltip = () => {
  const context = React.useContext(TooltipContext)
  if (!context) {
    throw new Error("Tooltip components must be used within Tooltip")
  }
  return context
}

const Tooltip = ({ delayDuration = 0, children }) => {
  const [open, setOpen] = React.useState(false)
  const triggerRef = React.useRef(null)
  const openTimeoutRef = React.useRef(null)
  const closeTimeoutRef = React.useRef(null)

  const handleOpen = React.useCallback(() => {
    if (closeTimeoutRef.current) {
      clearTimeout(closeTimeoutRef.current)
      closeTimeoutRef.current = null
    }
    if (openTimeoutRef.current) {
      clearTimeout(openTimeoutRef.current)
    }
    openTimeoutRef.current = setTimeout(() => {
      setOpen(true)
      openTimeoutRef.current = null
    }, delayDuration)
  }, [delayDuration])

  const handleClose = React.useCallback(() => {
    if (openTimeoutRef.current) {
      clearTimeout(openTimeoutRef.current)
      openTimeoutRef.current = null
    }
    if (closeTimeoutRef.current) {
      clearTimeout(closeTimeoutRef.current)
    }
    closeTimeoutRef.current = setTimeout(() => {
      setOpen(false)
      closeTimeoutRef.current = null
    }, 100)
  }, [])

  React.useEffect(() => {
    return () => {
      if (openTimeoutRef.current) {
        clearTimeout(openTimeoutRef.current)
      }
      if (closeTimeoutRef.current) {
        clearTimeout(closeTimeoutRef.current)
      }
    }
  }, [])

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

const TooltipTrigger = React.forwardRef(
  ({ className, children, asChild, ...props }, ref) => {
    const { handleOpen, handleClose, triggerRef } = useTooltip()
    const localTriggerRef = React.useRef(null)

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

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

    const handleMouseEnter = (e) => {
      handleOpen()
      props.onMouseEnter?.(e)
    }

    const handleMouseLeave = (e) => {
      handleClose()
      props.onMouseLeave?.(e)
    }

    const handleFocus = (e) => {
      handleOpen()
      props.onFocus?.(e)
    }

    const handleBlur = (e) => {
      handleClose()
      props.onBlur?.(e)
    }

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

    return (
      <div
        ref={localTriggerRef}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        onFocus={handleFocus}
        onBlur={handleBlur}
        className={className}
        {...props}
      >
        {children}
      </div>
    )
  }
)
TooltipTrigger.displayName = "TooltipTrigger"

const TooltipContent = React.forwardRef(
  ({ className, side = "top", align = "center", children, ...props }, ref) => {
    const { open, triggerRef } = useTooltip()
    const [position, setPosition] = React.useState({ top: 0, left: 0 })
    const contentRef = React.useRef(null)

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

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

      const updatePosition = () => {
        const trigger = triggerRef.current
        const content = contentRef.current
        if (!trigger || !content) return

        const triggerRect = trigger.getBoundingClientRect()
        const contentRect = content.getBoundingClientRect()
        const scrollX = window.scrollX || window.pageXOffset
        const scrollY = window.scrollY || window.pageYOffset

        let top = 0
        let left = 0

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

        switch (align) {
          case "start":
            if (side === "top" || side === "bottom") {
              left = triggerRect.left + scrollX
            } else {
              if (side === "left" || side === "right") {
                top = triggerRect.top + scrollY
              }
            }
            break
          case "end":
            if (side === "top" || side === "bottom") {
              left = triggerRect.right + scrollX - contentRect.width
            } else {
              if (side === "left" || side === "right") {
                top = triggerRect.bottom + scrollY - contentRect.height
              }
            }
            break
          case "center":
          default:
            if (side === "top" || side === "bottom") {
              left = triggerRect.left + scrollX + triggerRect.width / 2 - contentRect.width / 2
            }
            break
        }

        setPosition({ top, left })
      }

      updatePosition()
      window.addEventListener("scroll", updatePosition, true)
      window.addEventListener("resize", updatePosition)

      return () => {
        window.removeEventListener("scroll", updatePosition, true)
        window.removeEventListener("resize", updatePosition)
      }
    }, [open, side, align, triggerRef])

    if (!open) return null

    const content = (
      <div
        ref={contentRef}
        data-tooltip-content
        className={cn(
          "z-50 rounded-md border-2 border-foreground bg-primary px-3 py-1.5 text-sm font-bold text-primary-foreground neobrutalism-shadow-sm",
          className
        )}
        style={{
          position: "absolute",
          top: `${position.top}px`,
          left: `${position.left}px`,
        }}
        {...props}
      >
        {children}
      </div>
    )

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

export { Tooltip, TooltipTrigger, TooltipContent }

Usage

TypeScript:

tsx
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { Button } from "@/components/ui/button"

function MyComponent() {
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button>Hover</Button>
      </TooltipTrigger>
      <TooltipContent>
        <p>Add to library</p>
      </TooltipContent>
    </Tooltip>
  )
}

JavaScript:

jsx
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { Button } from "@/components/ui/button"

function MyComponent() {
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button>Hover</Button>
      </TooltipTrigger>
      <TooltipContent>
        <p>Add to library</p>
      </TooltipContent>
    </Tooltip>
  )
}

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

Examples

Default

Different Positions