Popover

Anchored floating panel that grows out of its trigger — non-modal, positioned on any side, scaling from the exact point Base UI anchors it to.

Standalone

A trigger with a title and description inside the popup.

standalone

Placement

The same popover anchored to each side of its trigger.

placement

Install

Install the packages this component imports.

bun add motion @base-ui/react
Terminal

Also requires lib/utils.ts, lib/springs.ts (see Installation).

Source

Copy and paste the following code into your project.

"use client";

import type { ComponentProps } from "react";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { motion } from "motion/react";

import { cn } from "@/lib/utils";
import { spring } from "@/lib/springs";

function Popover({ ...props }: PopoverPrimitive.Root.Props) {
  return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}

function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}

/**
 * Anchored, non-modal — no backdrop, unlike Dialog. Scales from
 * `--transform-origin` (the point Base UI's Positioner anchors it to, which
 * moves with `side`/`align`) rather than a fixed origin, so it always reads
 * as growing out of the trigger no matter which edge it lands on.
 */
function PopoverContent({
  className,
  align = "center",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 8,
  ...props
}: PopoverPrimitive.Popup.Props &
  Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Positioner
        data-slot="popover-positioner"
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
        className="z-50"
      >
        <PopoverPrimitive.Popup
          data-slot="popover-content"
          render={(popupProps, state) => {
            const exiting = state.transitionStatus === "ending";
            return (
              <motion.div
                {...(popupProps as Record<string, unknown>)}
                {...(props as Record<string, unknown>)}
                className={cn(
                  "w-72 origin-(--transform-origin) rounded-lg bg-popover p-3 text-body text-popover-foreground shadow-popover outline-none",
                  className,
                )}
                initial={{ opacity: 0, scale: 0.96 }}
                animate={{ opacity: exiting ? 0 : 1, scale: exiting ? 0.96 : 1 }}
                transition={exiting ? spring.moderate.exit : spring.moderate.enter}
              />
            );
          }}
        />
      </PopoverPrimitive.Positioner>
    </PopoverPrimitive.Portal>
  );
}

function PopoverHeader({ className, ...props }: ComponentProps<"div">) {
  return (
    <div data-slot="popover-header" className={cn("flex flex-col gap-1", className)} {...props} />
  );
}

function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
  return (
    <PopoverPrimitive.Title
      data-slot="popover-title"
      className={cn("text-control font-medium text-foreground", className)}
      {...props}
    />
  );
}

function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
  return (
    <PopoverPrimitive.Description
      data-slot="popover-description"
      className={cn("text-caption text-muted-foreground", className)}
      {...props}
    />
  );
}

export { Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription };
popover.tsx

API Reference

Popover
defaultOpenfalse

boolean

Whether the popover is initially open, uncontrolled.

open

boolean

Controlled open state.

onOpenChange

(open: boolean) => void

Called when the open state changes.

modalfalse

boolean

Whether interaction with the rest of the page is blocked while open.

API Reference

PopoverTrigger
children

ReactNode

Trigger element — pass `render={<Button ... />}` to style it.

API Reference

PopoverContent
side"bottom"

"top" | "right" | "bottom" | "left"

Which side of the trigger to render on.

sideOffset8

number

Gap between the trigger and the popup, in pixels.

align"center"

"start" | "center" | "end"

Alignment along the side.

alignOffset0

number

Offset along the alignment axis, in pixels.

API Reference

PopoverTitlePopoverDescription
children

ReactNode

Title or description content.