Popover
A floating panel triggered by a click that displays rich content such as forms, additional details, or action menus, positioned relative to its trigger element.
Search across all documentation pages
A floating panel triggered by a click that displays rich content such as forms, additional details, or action menus, positioned relative to its trigger element.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
import { useState, useRef } from "react";
interface PopoverProps {
trigger: React.ReactNode;
children: React.ReactNode;
}
export function Popover({ trigger, children }: PopoverProps) {
const [open, setOpen] = useState(false);
return (
<div className="relative inline-block">
<button type="button" onClick={() => setOpen(!open)}>
{trigger}
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-2 w-64 rounded-lg border border-gray-200 bg-white p-4 shadow-lg">
{children}
</div>
)}
</div>
);
}A minimal toggle popover. The panel is positioned with absolute relative to the relative wrapper. Clicking the trigger toggles visibility. This needs "use client" because of useState.
"use client";
import { useState, useRef, useEffect } from "react";
interface PopoverProps {
trigger: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export function Popover({ trigger, children, className }: PopoverProps) {
const [open, setOpen] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div ref={popoverRef} className="relative inline-block">
<button type="button" onClick={() => setOpen(!open)}>
{trigger}
</button>
{open && (
<div
className={`absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-gray-200 bg-white p-4 shadow-lg ${className ?? ""}`}
>
{children}
</div>
)}
</div>
);
}Adds click-outside-to-close behavior with a mousedown listener on document. The listener is only attached while the popover is open and cleaned up on close or unmount to avoid memory leaks.
"use client";
import { useState, useRef, useEffect } from "react";
interface PopoverFormProps {
trigger: React.ReactNode;
onSubmit: (value: string) => void;
}
export function PopoverForm({ trigger, onSubmit }: PopoverFormProps) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const popoverRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit(value);
setValue("");
setOpen(false);
}
return (
<div ref={popoverRef} className="relative inline-block">
<button type="button" onClick={() => setOpen(!open)}>
{trigger}
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-2 w-80 rounded-lg border border-gray-200 bg-white p-4 shadow-lg">
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-700">
Add a note
</label>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Type here..."
className="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100"
>
Cancel
</button>
<button
type="submit"
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
Save
</button>
</div>
</form>
</div>
)}
</div>
);
}Auto-focuses the input when the popover opens for immediate typing. The form resets and closes on submit. Cancel closes without submitting. This is a common pattern for inline editing and quick-add flows.
"use client";
import { useState } from "react";
interface PopoverProps {
trigger: React.ReactNode;
children: React.ReactNode;
}
export function PopoverWithArrow({ trigger, children }: PopoverProps) {
const [open, setOpen] = useState(false);
return (
<div className="relative inline-block">
<button type="button" onClick={() => setOpen(!open)}>
{trigger}
</button>
{open && (
<div className="absolute left-1/2 top-full z-50 mt-3 w-64 -translate-x-1/2 rounded-lg border border-gray-200 bg-white p-4 shadow-lg">
<div className="absolute -top-2 left-1/2 h-4 w-4 -translate-x-1/2 rotate-45 border-l border-t border-gray-200 bg-white" />
{children}
</div>
)}
</div>
);
}The arrow is a rotated 45-degree square positioned at the top center of the panel. The border-l border-t draws only the two edges that face the trigger, creating a clean pointer effect. The panel uses left-1/2 -translate-x-1/2 to center below the trigger.
"use client";
import { useRef, useEffect } from "react";
interface PopoverProps {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export function Popover({ open, onOpenChange, trigger, children, className }: PopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onOpenChange(false);
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onOpenChange(false);
}
if (open) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [open, onOpenChange]);
return (
<div ref={popoverRef} className="relative inline-block">
<button type="button" onClick={() => onOpenChange(!open)}>
{trigger}
</button>
{open && (
<div
className={`absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-gray-200 bg-white p-4 shadow-lg ${className ?? ""}`}
>
{children}
</div>
)}
</div>
);
}A controlled variant where the parent owns the open state via open and onOpenChange. This allows coordinating multiple popovers (closing one when another opens) or triggering the popover from external events. Escape key support is included for accessibility.
"use client";
import { useState, useRef } from "react";
interface HoverCardProps {
trigger: React.ReactNode;
children: React.ReactNode;
delay?: number;
}
export function HoverCard({ trigger, children, delay = 300 }: HoverCardProps) {
const [open, setOpen] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
function handleMouseEnter() {
timeoutRef.current = setTimeout(() => setOpen(true), delay);
}
function handleMouseLeave() {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setOpen(false);
}
return (
<div
className="relative inline-block"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{trigger}
{open && (
<div className="absolute left-1/2 top-full z-50 mt-2 w-80 -translate-x-1/2 rounded-lg border border-gray-200 bg-white p-4 shadow-xl">
{children}
</div>
)}
</div>
);
}
// Usage
<HoverCard
trigger={<span className="cursor-pointer font-medium text-blue-600 underline">@johndoe</span>}
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gray-200" />
<div>
<p className="font-semibold">John Doe</p>
<p className="text-sm text-gray-500">Software Engineer</p>
</div>
</div>
</HoverCard>A hover-triggered card with a configurable delay to prevent flickering during casual mouse movement. The timeout is cleared on mouse leave to prevent the card from opening after the cursor has already moved away.
"use client";
import { useState } from "react";
type PopoverPosition = "top" | "bottom" | "left" | "right";
interface PopoverProps {
trigger: React.ReactNode;
children: React.ReactNode;
position?: PopoverPosition;
}
const positionClasses: Record<PopoverPosition, string> = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
left: "right-full top-1/2 -translate-y-1/2 mr-2",
right: "left-full top-1/2 -translate-y-1/2 ml-2",
};
export function Popover({ trigger, children, position = "bottom" }: PopoverProps) {
const [open, setOpen] = useState(false);
return (
<div className="relative inline-block">
<button type="button" onClick={() => setOpen(!open)}>
{trigger}
</button>
{open && (
<div
className={`absolute z-50 w-64 rounded-lg border border-gray-200 bg-white p-4 shadow-lg ${positionClasses[position]}`}
>
{children}
</div>
)}
</div>
);
}A static position map translates placement names into Tailwind utility combinations. Each position centers the panel along the perpendicular axis using translate transforms. The margin classes (mt-2, mb-2, ml-2, mr-2) create consistent spacing between the trigger and the panel.
"use client";
import {
forwardRef,
useState,
useRef,
useEffect,
useCallback,
useId,
createContext,
useContext,
type ReactNode,
} from "react";
type PopoverPosition = "top" | "bottom" | "left" | "right";
interface PopoverContextValue {
open: boolean;
setOpen: (v: boolean) => void;
triggerId: string;
contentId: string;
position: PopoverPosition;
triggerRef: React.RefObject<HTMLButtonElement | null>;
}
const PopoverContext = createContext<PopoverContextValue | null>(null);
function usePopoverContext() {
const ctx = useContext(PopoverContext);
if (!ctx) throw new Error("Popover compound components must be used within <Popover>");
return ctx;
}
interface PopoverProps {
children: ReactNode;
position?: PopoverPosition;
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function Popover({
children,
position = "bottom",
defaultOpen = false,
open: controlledOpen,
onOpenChange,
}: PopoverProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
const id = useId();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const setOpen = useCallback(
(value: boolean) => {
if (!isControlled) setUncontrolledOpen(value);
onOpenChange?.(value);
},
[isControlled, onOpenChange]
);
return (
<PopoverContext.Provider
value={{
open,
setOpen,
triggerId: `${id}-trigger`,
contentId: `${id}-content`,
position,
triggerRef,
}}
>
<div className="relative inline-block">{children}</div>
</PopoverContext.Provider>
);
}
export const PopoverTrigger = forwardRef<HTMLButtonElement, { children: ReactNode }>(
function PopoverTrigger({ children }, ref) {
const { open, setOpen, triggerId, contentId, triggerRef } = usePopoverContext();
return (
<button
ref={(node) => {
triggerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
id={triggerId}
type="button"
aria-expanded={open}
aria-haspopup="dialog"
aria-controls={open ? contentId : undefined}
onClick={() => setOpen(!open)}
>
{children}
</button>
);
}
);
const positionClasses: Record<PopoverPosition, string> = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
left: "right-full top-1/2 -translate-y-1/2 mr-2",
right: "left-full top-1/2 -translate-y-1/2 ml-2",
};
interface PopoverContentProps {
children: ReactNode;
className?: string;
}
export const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
function PopoverContent({ children, className }, ref) {
const { open, setOpen, contentId, triggerId, position, triggerRef } =
usePopoverContext();
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node;
if (
contentRef.current &&
!contentRef.current.contains(target) &&
triggerRef.current &&
!triggerRef.current.contains(target)
) {
setOpen(false);
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [open, setOpen, triggerRef]);
if (!open) return null;
return (
<div
ref={(node) => {
(contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
id={contentId}
role="dialog"
aria-labelledby={triggerId}
className={`absolute z-50 w-72 rounded-lg border border-gray-200 bg-white p-4 shadow-lg animate-in fade-in-0 zoom-in-95 ${positionClasses[position]} ${className ?? ""}`}
>
{children}
</div>
);
}
);
export function PopoverClose({ children }: { children: ReactNode }) {
const { setOpen, triggerRef } = usePopoverContext();
return (
<button
type="button"
onClick={() => {
setOpen(false);
triggerRef.current?.focus();
}}
>
{children}
</button>
);
}
// Usage
function ProfilePopover() {
return (
<Popover position="bottom">
<PopoverTrigger>
<span className="rounded-md bg-gray-100 px-3 py-1.5 text-sm hover:bg-gray-200">
Profile
</span>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-blue-100" />
<div>
<p className="font-semibold">Jane Doe</p>
<p className="text-sm text-gray-500">jane@example.com</p>
</div>
</div>
<div className="mt-3 flex justify-end">
<PopoverClose>
<span className="text-sm text-gray-500 hover:text-gray-700">Close</span>
</PopoverClose>
</div>
</PopoverContent>
</Popover>
);
}Key aspects:
Popover, PopoverTrigger, PopoverContent, and PopoverClose communicate via context, giving consumers full control over layout and composition without prop drilling.open is provided, it acts as controlled; otherwise, it manages its own state with defaultOpen.aria-expanded, aria-haspopup="dialog", and aria-controls to announce the popover relationship. The content panel has role="dialog" and aria-labelledby linking back to the trigger.PopoverClose returns focus to the trigger button, maintaining a predictable keyboard navigation flow.useId generates stable, SSR-safe IDs for ARIA attributes, avoiding hydration mismatches between server and client renders.Popover clipped by overflow: hidden ancestor -- if any parent element has overflow: hidden, the popover panel is cut off. Use a React portal (createPortal) to render the panel at the document body level.
Click-outside handler closes popover immediately on trigger click -- if the click-outside listener fires before the toggle logic, the popover opens and closes in the same frame. Exclude the trigger element from the outside-click check.
Z-index stacking conflicts -- a z-50 popover may render behind a modal at z-50. Establish a z-index scale in your project (e.g., dropdowns at 40, popovers at 50, modals at 60) and use it consistently.
Positioning near viewport edges -- a statically positioned popover can overflow off-screen when the trigger is near the edge. For production use, consider a positioning library like Floating UI to auto-flip and shift the panel.
Missing Escape key handling -- users expect Escape to close floating panels. Forgetting the keydown listener for Escape breaks keyboard accessibility and fails WCAG 2.1 SC 1.2.1.
Hover card inaccessible on touch devices -- hover-triggered popovers are unreachable on touch screens. Always provide a click/tap fallback or use onPointerDown instead of onMouseEnter.
Memory leak from unremoved event listeners -- attaching document.addEventListener without a cleanup function in useEffect causes listeners to accumulate on every open/close cycle. Always return a cleanup function.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥