React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

popoverpopupfloatingpanelmenudropdowncomponenttailwind

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.

Use Cases

  • Show a mini profile card when clicking a user avatar
  • Display a color picker or date picker in a floating panel
  • Present a quick-edit form without navigating to a new page
  • Provide contextual help or additional information on demand
  • Build a share menu with social links and copy-to-clipboard
  • Show filter controls in a floating panel above a data table
  • Display notification details when clicking a notification icon

Simplest Implementation

"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.

Variations

Basic Click-to-Open

"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.

With Form Inside

"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.

With Arrow

"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.

Controlled Open/Close

"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.

Hover Card Style

"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.

Positioned (Top/Bottom/Left/Right)

"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.

Complex Implementation

"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:

  • Compound component pattern -- Popover, PopoverTrigger, PopoverContent, and PopoverClose communicate via context, giving consumers full control over layout and composition without prop drilling.
  • Controlled and uncontrolled modes -- the component supports both patterns. When open is provided, it acts as controlled; otherwise, it manages its own state with defaultOpen.
  • ARIA attributes -- the trigger uses 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.
  • Focus management on close -- pressing Escape or clicking PopoverClose returns focus to the trigger button, maintaining a predictable keyboard navigation flow.
  • Click-outside detection excludes trigger -- the click-outside handler checks that the click target is outside both the content and the trigger, preventing the popover from closing and immediately reopening when the trigger is clicked.
  • Unique IDs via useId -- React's useId generates stable, SSR-safe IDs for ARIA attributes, avoiding hydration mismatches between server and client renders.

Gotchas

  • 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.

  • Tooltip -- Tooltips show brief text on hover; popovers show rich content on click
  • Dropdown -- Dropdowns are a specialized popover for selecting from a list
  • Modal -- Modals block interaction with the page; popovers are non-modal
  • Toast -- Toasts show transient messages; popovers show persistent content until dismissed