Popover

A floating panel that is attached to a trigger element.

Dependencies

Source Code

"use client";
 
import { createContext, useCallback, use, useMemo, useState } from "react";
import {
  autoUpdate,
  flip,
  Placement,
  shift,
  useFloating,
  UseFloatingOptions,
  offset as offsetMiddleware,
  size,
  hide,
  useClick,
  useDismiss,
  useRole,
  useInteractions,
  useMergeRefs,
  useTransitionStatus,
  FloatingPortal,
  FloatingFocusManager,
  UseInteractionsReturn,
} from "@floating-ui/react";
import { Slot } from "@/components/slot";
import { MagnifyingGlass } from "@phosphor-icons/react";
 
import { cn } from "@/lib/utils";
 
interface UsePopoverFloatingOptions {
  open?: boolean;
  onOpenChange?: UseFloatingOptions["onOpenChange"];
  placement?: Placement;
  offset?: number;
}
 
const usePopoverFloating = ({
  open: propsOpen,
  onOpenChange: propsOnOpenChange,
  placement = "bottom",
  offset = 4,
}: UsePopoverFloatingOptions) => {
  const [internalOpen, setInternalOpen] = useState(false);
  const open = propsOpen ?? internalOpen;
 
  const setOpen = useCallback<NonNullable<UseFloatingOptions["onOpenChange"]>>(
    (open, event, reason) => {
      setInternalOpen(open);
      propsOnOpenChange?.(open, event, reason);
    },
    [propsOnOpenChange]
  );
 
  const floating = useFloating({
    placement,
    open,
    onOpenChange: setOpen,
    whileElementsMounted: (reference, floating, update) =>
      autoUpdate(reference, floating, update, {
        layoutShift: false,
      }),
    middleware: [
      flip({
        padding: 8,
      }),
      shift({
        padding: 8,
      }),
      offsetMiddleware(offset),
      size({
        apply({ elements, availableHeight }) {
          elements.floating.style.setProperty(
            "--max-height",
            `${availableHeight}px`
          );
        },
        padding: 4,
      }),
      hide(),
    ],
  });
 
  return useMemo(
    () => ({
      open,
      setOpen,
      ...floating,
    }),
    [open, setOpen, floating]
  );
};
 
// Context
 
interface ContextType
  extends ReturnType<typeof usePopoverFloating>,
    UseInteractionsReturn {
  modal: boolean;
}
 
const PopoverContext = createContext<ContextType | null>(null);
 
const usePopoverContext = () => {
  const context = use(PopoverContext);
 
  if (context == null) {
    throw new Error("Popover components must be wrapped in <Popover />");
  }
 
  return context;
};
 
// Components
 
interface PopoverProps extends UsePopoverFloatingOptions {
  modal?: boolean;
  children: React.ReactNode;
}
/**
 * Popover allows you to open a floating panel that is attached to a trigger element.
 *
 * Set `modal` to `true` to focus trap the popover.
 *
 * @example
 * ```
 * <Popover>
 *   <PopoverTrigger>
 *     <Button>Open Popover</Button>
 *   </PopoverTrigger>
 *   <PopoverContent>
 *     <p>Popover Content</p>
 *   </PopoverContent>
 * </Popover>
 * ```
 */
const Popover = ({ children, modal = false, ...props }: PopoverProps) => {
  const floating = usePopoverFloating(props);
 
  const click = useClick(floating.context);
  const dismiss = useDismiss(floating.context);
  const role = useRole(floating.context);
 
  const interactions = useInteractions([click, dismiss, role]);
 
  const popoverContextValue = useMemo(
    () => ({
      ...floating,
      ...interactions,
      modal,
    }),
    [floating, interactions, modal]
  );
 
  return (
    <PopoverContext value={popoverContextValue}>{children}</PopoverContext>
  );
};
 
interface PopoverTriggerProps extends React.ComponentPropsWithRef<"button"> {
  asChild?: boolean;
}
 
/**
 * Will open the popover when clicked.
 *
 * Use `asChild` to render as your child element.
 *
 * @example
 * ```
 * <PopoverTrigger>
 *   <Button>Open Popover</Button>
 * </PopoverTrigger>
 *
 * <PopoverTrigger asChild={false}>
 *   Open Popover
 * </PopoverTrigger>
 * ```
 */
const PopoverTrigger = ({
  ref: refProp,
  children,
  asChild = false,
  className,
  ...props
}: PopoverTriggerProps) => {
  const context = usePopoverContext();
  const Comp = asChild ? Slot : "button";
 
  const ref = useMergeRefs([context.refs.setReference, refProp]);
 
  return (
    <Comp
      ref={ref}
      type={asChild ? undefined : "button"}
      className={cn(!asChild && "disabled:opacity-40", className)}
      data-state={context.open ? "open" : "closed"}
      {...context.getReferenceProps(props)}
    >
      {children}
    </Comp>
  );
};
 
/**
 * Will render the popover content.
 *
 * @example
 * ```
 * <PopoverContent>
 *   <p>Popover Content</p>
 * </PopoverContent>
 * ```
 */
const PopoverContent = ({
  ref: refProp,
  style,
  className,
  children,
  ...props
}: React.ComponentPropsWithRef<"div">) => {
  const { context, refs, getFloatingProps, modal } = usePopoverContext();
 
  const ref = useMergeRefs([refs.setFloating, refProp]);
 
  const { isMounted, status } = useTransitionStatus(context, {
    duration: 150,
  });
 
  if (!isMounted) return null;
 
  return (
    <FloatingPortal>
      <FloatingFocusManager context={context} modal={modal}>
        <div
          data-state={["open", "initial"].includes(status) ? "open" : "closed"}
          data-side={context.placement.split("-")[0]}
          {...getFloatingProps({
            ref,
            className: cn(
              "z-50 w-72 overflow-auto rounded-xl border border-border bg-background p-3 text-foreground shadow-lg outline-none max-h-(--max-height)",
              "origin-(--popover-transform-origin) transition duration-300 ease-out-expo",
              "data-[state=closed]:data-[side=bottom]:-translate-y-2 data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2 data-[state=closed]:data-[side=top]:translate-y-2",
              "data-[state=closed]:scale-95 data-[state=closed]:opacity-0 data-[state=closed]:duration-150",
              "data-[state=open]:translate-x-0 data-[state=open]:translate-y-0 data-[state=open]:scale-100",
              className
            ),
            style: {
              position: context.strategy,
              top: context.y ?? 0,
              left: context.x ?? 0,
              "--popover-transform-origin": placementToTransformOrigin(
                context.placement
              ),
              visibility: context.middlewareData.hide?.referenceHidden
                ? "hidden"
                : "visible",
              ...style,
            },
            ...props,
          })}
        >
          {children}
        </div>
      </FloatingFocusManager>
    </FloatingPortal>
  );
};
 
// ugly and verbose but easy to reason about and maintain
const placementToTransformOrigin = (placement: Placement) => {
  switch (placement) {
    case "top":
      return "bottom";
    case "bottom":
      return "top";
    case "left":
      return "right";
    case "right":
      return "left";
    case "top-start":
      return "bottom left";
    case "top-end":
      return "bottom right";
    case "bottom-start":
      return "top left";
    case "bottom-end":
      return "top right";
    case "left-start":
      return "right top";
    case "left-end":
      return "right bottom";
    case "right-start":
      return "left top";
    case "right-end":
      return "left bottom";
  }
};
 
interface PopoverCloseProps extends React.ComponentPropsWithRef<"button"> {
  asChild?: boolean;
}
 
/**
 * Will close the popover when clicked.
 *
 * Useful to dismiss the popover from within (e.g.: popover with a form and a cancel button).
 *
 * Use `asChild` to render as your child element.
 *
 * @example
 * ```
 * <Popover>
 *   <PopoverTrigger>
 *     <Button>Open Popover</Button>
 *   </PopoverTrigger>
 *   <PopoverContent>
 *     <PopoverClose>
 *       <Button>Cancel</Button>
 *     </PopoverClose>
 *   </PopoverContent>
 * </Popover>
 * ```
 */
const PopoverClose = ({
  asChild = false,
  children,
  ...props
}: PopoverCloseProps) => {
  const { setOpen } = usePopoverContext();
  const Comp = asChild ? Slot : "button";
 
  return (
    <Comp
      {...props}
      onClick={(event: React.MouseEvent<HTMLElement>) => {
        props.onClick?.(event as React.MouseEvent<HTMLButtonElement>);
        setOpen(false);
      }}
    >
      {children}
    </Comp>
  );
};
 
const PopoverSearchInput = ({
  className,
  ...props
}: React.ComponentPropsWithRef<"input">) => {
  return (
    <div className="border-border relative mb-1 flex items-center rounded-t-lg border-b bg-transparent">
      <MagnifyingGlass
        weight="bold"
        className="text-foreground absolute left-4 size-4 shrink-0"
      />
      <input
        className={cn(
          "placeholder:text-foreground-secondary h-10 w-full border-0 bg-transparent p-4 pl-10 text-base font-medium transition-colors outline-none focus:ring-0",
          className
        )}
        {...props}
      />
    </div>
  );
};
 
const PopoverEmpty = ({
  children,
  className,
  ...props
}: React.ComponentPropsWithRef<"div">) => {
  return (
    <div
      className={cn(
        "text-foreground-secondary pt-4 pb-5 text-center text-base",
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
};
 
export {
  Popover,
  PopoverTrigger,
  PopoverContent,
  PopoverClose,
  PopoverSearchInput,
  PopoverEmpty,
  usePopoverFloating,
  PopoverContext,
  usePopoverContext,
};

Features

  • Smart Positioning: Automatically adjusts position to stay in view
  • Focus Management: Optional modal mode with focus trapping
  • Search Support: Built-in search input component
  • Empty States: Dedicated component for empty state messages
  • Flexible Triggers: Support for custom trigger elements

Anatomy

<Popover>
  <PopoverTrigger />
  <PopoverContent>
    <PopoverClose />
    <PopoverSearchInput />
    <PopoverEmpty />
  </PopoverContent>
</Popover>

API Reference

Popover

PropDefaultTypeDescription

open

-

boolean

Whether the popover is open.

onOpenChange

-

(open: boolean) => void

Callback fired when the open state changes.

placement

"bottom"

Placement

The placement of the popover relative to its trigger.

offset

4

number

The distance between the popover and its trigger.

modal

false

boolean

Whether to trap focus inside the popover.

PopoverTrigger

Extends the button element.

PropDefaultTypeDescription

asChild

-

boolean

Whether to render the trigger as its child element.

PopoverContent

Extends the div element.

The content will be rendered in a portal and will be positioned relative to the trigger.

PopoverClose

Extends the button element.

PropDefaultTypeDescription

asChild

-

boolean

Whether to render the close button as its child element.

PopoverSearchInput

Extends the input element.

A styled input with a search icon, useful for filtering content inside the popover.

PopoverEmpty

Extends the div element.

A styled container for empty state messages.

Examples

Simple

Basic usage of the popover component.

Placement

Controlling the position of the popover relative to its trigger.

A modal popover will trap focus inside, making it ideal for forms and other interactive content.

Custom Width

Example showing how to customize the popover width.

Search Input

Using the built-in search input for filtering content.

Empty State

Displaying a message when no content is available.

Best Practices

  1. Positioning:

    • Consider available screen space
    • Adjust offset based on content
    • Test on different screen sizes
  2. Focus Management:

    • Use modal mode for complex interactions
    • Ensure keyboard navigation works
    • Provide clear focus indicators
  3. Content:

    • Keep content concise
    • Use appropriate width for content
    • Consider mobile interactions