logo

Nurav UI

Community
Components

Popover

A premium, animated popover component using Framer Motion for highly interactive overlays.Copy Markdown

Preview

The Popover component comes in three distinct visual styles to fit any design system.

Variant V1 (Modern Minimalist)

The default style, featuring clean lines, a subtle border, and sophisticated backdrop blur.

Loading...

Variant V2 (Glassmorphism Deep)

A high-end visual treatment with increased blur and a glowing shadow, perfect for dark mode or photography-heavy sites.

Loading...

Variant V3 (Brutalist Edge)

Bold, square, and high-contrast. Features a hard shadow and thick borders for a neo-brutalist aesthetic.

Installation

Automatic (CLI)

The fastest way to get started is using the shadcn CLI.

This component can be installed directly via URL — no components.json setup required. If you prefer the @nurav-ui alias shorthand, add the registry to your components.json first (see the Installation guide).

Terminal
npx shadcn@latest add https://nurav-ui.vercel.app/r/popover.json

Manual Setup

If you prefer manual configuration, follow these steps:

Install Dependencies

Install the necessary animation and utility libraries:

Terminal
npm install motion clsx tailwind-merge

Create Component

Copy the following code into your project (e.g., components/nurav-ui/Popover.tsx). This implementation includes the Portal logic for guaranteed visibility:

Popover.tsx
"use client";

import React, { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";

interface PopoverProps {
  children: React.ReactNode;
  content: React.ReactNode;
  side?: "top" | "bottom" | "left" | "right";
  className?: string;
  trigger?: "hover" | "click";
  variant?: "v1" | "v2" | "v3";
}

export function Popover({
  children,
  content,
  side = "top",
  className,
  trigger = "hover",
  variant = "v1",
}: PopoverProps) {
  const [show, setShow] = useState(false);
  const triggerRef = useRef<HTMLDivElement>(null);
  const [mounted, setMounted] = useState(false);
  const [coords, setCoords] = useState({ top: 0, left: 0, width: 0, height: 0 });

  useEffect(() => {
    setMounted(true);
  }, []);

  const updatePosition = useCallback(() => {
    if (!triggerRef.current) return;

    const rect = triggerRef.current.getBoundingClientRect();
    const newCoords = {
      top: rect.top,
      left: rect.left,
      width: rect.width,
      height: rect.height,
    };

    setCoords((prev) => {
      if (
        prev.top === newCoords.top &&
        prev.left === newCoords.left &&
        prev.width === newCoords.width &&
        prev.height === newCoords.height
      ) {
        return prev;
      }
      return newCoords;
    });
  }, []);

  useEffect(() => {
    if (show) {
      updatePosition();

      let frameId: number;
      const handleEvent = () => {
        cancelAnimationFrame(frameId);
        frameId = requestAnimationFrame(updatePosition);
      };

      window.addEventListener("scroll", handleEvent, true);
      window.addEventListener("resize", handleEvent);

      return () => {
        cancelAnimationFrame(frameId);
        window.removeEventListener("scroll", handleEvent, true);
        window.removeEventListener("resize", handleEvent);
      };
    }
  }, [show, updatePosition]);

  const open = () => setShow(true);
  const close = () => setShow(false);
  const toggle = () => setShow(!show);

  const triggerProps =
    trigger === "hover"
      ? { onMouseEnter: open, onMouseLeave: close }
      : { onClick: toggle };

  const getPopoverStyle = (): React.CSSProperties => {
    const gap = 8;
    const baseStyle: React.CSSProperties = {
      position: "fixed",
      zIndex: 9999,
      pointerEvents: "none",
    };

    switch (side) {
      case "top":
        return {
          ...baseStyle,
          top: coords.top - gap,
          left: coords.left + coords.width / 2,
          transform: "translate(-50%, -100%)",
        };
      case "bottom":
        return {
          ...baseStyle,
          top: coords.top + coords.height + gap,
          left: coords.left + coords.width / 2,
          transform: "translateX(-50%)",
        };
      case "left":
        return {
          ...baseStyle,
          top: coords.top + coords.height / 2,
          left: coords.left - gap,
          transform: "translate(-100%, -50%)",
        };
      case "right":
        return {
          ...baseStyle,
          top: coords.top + coords.height / 2,
          left: coords.left + coords.width + gap,
          transform: "translateY(-50%)",
        };
      default:
        return baseStyle;
    }
  };

  const getArrowStyle = (side: string): string => {
    switch (side) {
      case "top":
        return cn(
          "top-full left-1/2 -translate-x-1/2 border-t-current",
          variant === "v1" && "text-border",
          variant === "v2" && "text-white/10",
          variant === "v3" && "text-foreground",
        );
      case "bottom":
        return cn(
          "bottom-full left-1/2 -translate-x-1/2 border-b-current",
          variant === "v1" && "text-border",
          variant === "v2" && "text-white/10",
          variant === "v3" && "text-foreground",
        );
      case "left":
        return cn(
          "left-full top-1/2 -translate-y-1/2 border-l-current",
          variant === "v1" && "text-border",
          variant === "v2" && "text-white/10",
          variant === "v3" && "text-foreground",
        );
      case "right":
        return cn(
          "right-full top-1/2 -translate-y-1/2 border-r-current",
          variant === "v1" && "text-border",
          variant === "v2" && "text-white/10",
          variant === "v3" && "text-foreground",
        );
      default:
        return "";
    }
  };

  return (
    <>
      <div ref={triggerRef} className="inline-flex" {...triggerProps}>
        {children}
      </div>
      {mounted &&
        createPortal(
          <AnimatePresence>
            {show && (
              <motion.div
                initial={{
                  opacity: 0,
                  scale: 0.95,
                  y: side === "top" ? 5 : side === "bottom" ? -5 : 0,
                }}
                animate={{ opacity: 1, scale: 1, y: 0 }}
                exit={{
                  opacity: 0,
                  scale: 0.95,
                  y: side === "top" ? 5 : side === "bottom" ? -5 : 0,
                }}
                transition={{ duration: 0.1, ease: "easeOut" }}
                style={getPopoverStyle()}
                className={cn(
                  "px-2.5 py-1.5 backdrop-blur-md shadow-xl whitespace-nowrap",
                  variant === "v1" &&
                    "rounded-xl border border-border bg-background/80",
                  variant === "v2" &&
                    "rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl shadow-[0_0_20px_rgba(0,0,0,0.2)]",
                  variant === "v3" &&
                    "rounded-none border-2 border-foreground bg-background shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,1)]",
                  className,
                )}
              >
                <div
                  className={cn(
                    "text-xs font-semibold flex items-center gap-2",
                    variant === "v1" && "text-foreground/90",
                    variant === "v2" && "text-white/90",
                    variant === "v3" &&
                      "text-foreground font-bold uppercase tracking-wider",
                  )}
                >
                  {content}
                </div>
                <div
                  className={cn(
                    "absolute border-[6px] border-transparent",
                    getArrowStyle(side),
                    variant === "v3" && "hidden",
                  )}
                />
              </motion.div>
            )}
          </AnimatePresence>,
          document.body,
        )}
    </>
  );
}

Usage

Modern Minimalist (V1)

The default style, best for tooltips and supplemental information.

import { Popover } from "@/components/nurav-ui/Popover";

export default function Example() {
  return (
    <Popover variant="v1" content="Minimalist style">
      <button>Hover Me</button>
    </Popover>
  );
}

Glassmorphism (V2)

A visually rich style for high-end digital products.

<Popover variant="v2" content="Glassmorphism style">
  <button>Glassy Popover</button>
</Popover>

Brutalist Edge (V3)

A bold, square style with a hard shadow.

<Popover variant="v3" content="Brutalist style">
  <button>Bold Popover</button>
</Popover>

Props

PropTypeDefaultDescription
contentrequired
ReactNodeundefinedThe content to display inside the popover.
side
'top' | 'bottom' | 'left' | 'right''top'Preferred placement of the popover.
trigger
'hover' | 'click''hover'Interaction type that opens the popover.
variant
'v1' | 'v2' | 'v3''v1'Visual style variant for the popover.
className
stringundefinedAdditional CSS classes for the popover container.
childrenrequired
ReactNodeundefinedThe trigger element for the popover.

Built by Varun

Last Updated:April 9, 2026

On this page