Components
Popover
Popover
Animated popover component with customizable positioning and alignment options.
Installation
1
Install the packages
npm i motion clsx tailwind-merge2
Add util file
lib/util.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
3
Copy and paste the following code into your project
popover.tsx
"use client";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useRef, useEffect } from "react";
import { FiChevronDown } from "react-icons/fi";
interface PopoverProps {
trigger?: React.ReactNode;
content?: React.ReactNode;
position?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
}
const Popover: React.FC<PopoverProps> = ({
trigger,
content = "Popover content",
position = "bottom",
align = "center"
}) => {
const [isOpen, setIsOpen] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const positionClasses = {
top: "bottom-full mb-2",
bottom: "top-full mt-2",
left: "right-full mr-2",
right: "left-full ml-2",
};
const alignClasses = {
start: position.includes("top") || position.includes("bottom") ? "left-0" : "top-0",
center: position.includes("top") || position.includes("bottom") ? "left-1/2 transform -translate-x-1/2" : "top-1/2 transform -translate-y-1/2",
end: position.includes("top") || position.includes("bottom") ? "right-0" : "bottom-0",
};
return (
<div ref={popoverRef} className="relative">
{/* Trigger */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-4 py-2 bg-neutral-800 hover:bg-neutral-700 rounded-lg text-white transition-colors"
>
{trigger || "Click me"}
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<FiChevronDown className="h-4 w-4" />
</motion.div>
</button>
{/* Popover Content */}
<AnimatePresence>
{isOpen && (
<motion.div
className={cn(
"absolute z-50 w-64 p-4",
"bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg shadow-lg",
positionClasses[position],
alignClasses[align]
)}
initial={{ opacity: 0, scale: 0.95, y: position === "top" ? 10 : position === "bottom" ? -10 : 0, x: position === "left" ? 10 : position === "right" ? -10 : 0 }}
animate={{ opacity: 1, scale: 1, y: 0, x: 0 }}
exit={{ opacity: 0, scale: 0.95, y: position === "top" ? 10 : position === "bottom" ? -10 : 0, x: position === "left" ? 10 : position === "right" ? -10 : 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
{content}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default Popover;"use client";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useRef, useEffect } from "react";
import { FiChevronDown } from "react-icons/fi";
interface PopoverProps {
trigger?: React.ReactNode;
content?: React.ReactNode;
position?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
}
const Popover: React.FC<PopoverProps> = ({
trigger,
content = "Popover content",
position = "bottom",
align = "center"
}) => {
const [isOpen, setIsOpen] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const positionClasses = {
top: "bottom-full mb-2",
bottom: "top-full mt-2",
left: "right-full mr-2",
right: "left-full ml-2",
};
const alignClasses = {
start: position.includes("top") || position.includes("bottom") ? "left-0" : "top-0",
center: position.includes("top") || position.includes("bottom") ? "left-1/2 transform -translate-x-1/2" : "top-1/2 transform -translate-y-1/2",
end: position.includes("top") || position.includes("bottom") ? "right-0" : "bottom-0",
};
return (
<div ref={popoverRef} className="relative">
{/* Trigger */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-4 py-2 bg-neutral-800 hover:bg-neutral-700 rounded-lg text-white transition-colors"
>
{trigger || "Click me"}
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<FiChevronDown className="h-4 w-4" />
</motion.div>
</button>
{/* Popover Content */}
<AnimatePresence>
{isOpen && (
<motion.div
className={cn(
"absolute z-50 w-64 p-4",
"bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg shadow-lg",
positionClasses[position],
alignClasses[align]
)}
initial={{ opacity: 0, scale: 0.95, y: position === "top" ? 10 : position === "bottom" ? -10 : 0, x: position === "left" ? 10 : position === "right" ? -10 : 0 }}
animate={{ opacity: 1, scale: 1, y: 0, x: 0 }}
exit={{ opacity: 0, scale: 0.95, y: position === "top" ? 10 : position === "bottom" ? -10 : 0, x: position === "left" ? 10 : position === "right" ? -10 : 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
{content}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default Popover;4
Update the import paths to match your project setup
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| trigger | ReactNode | undefined | Element that triggers the popover. |
| content | ReactNode | Popover content | Content to display in the popover. |
| position | string | bottom | Position of the popover (top, bottom, left, right). |
| align | string | center | Alignment of the popover (start, center, end). |