Popover

An overlay element positioned relative to a trigger.

"use client"

import * as React from "react"
import { cn } from "cn"
import {
  DialogTrigger,
  Heading,
  Popover as PopoverPrimitive,
  type DialogTriggerProps,
  type PopoverProps as PopoverPrimitiveProps,
} from "@animate-ui/react/primitives/react-aria"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

function PopoverTrigger({ children, ...props }: DialogTriggerProps) {
  return (
    <DialogTrigger data-slot="popover-trigger" {...props}>
      {children}
    </DialogTrigger>
  )
}

function Popover({
  className,
  placement = "bottom",
  offset = 4,
  crossOffset = 0,
  ...props
}: Omit<PopoverPrimitiveProps, "className"> & {
  className?: string
}) {
  return (
    <PopoverPrimitive
      data-slot="popover-content"
      placement={placement}
      offset={offset}
      crossOffset={crossOffset}
      className={cn(
        "bg-popover text-popover-foreground ring-foreground/10 flex flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 z-50 w-72 origin-(--trigger-anchor-point) outline-hidden",
        className
      )}
      {...props}
    />
  )
}

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

function PopoverTitle({
  className,
  ...props
}: React.ComponentProps<typeof Heading>) {
  return (
    <Heading
      data-slot="popover-title"
      className={cn("font-medium", className)}
      {...props}
    />
  )
}

function PopoverDescription({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="popover-description"
      className={cn("text-muted-foreground", className)}
      {...props}
    />
  )
}

export {
  Popover,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
}

function toPlacement(
  side: "top" | "right" | "bottom" | "left",
  align: "start" | "center" | "end"
) {
  if (align === "center") return side
  if (side === "top" || side === "bottom") return `${side} ${align}` as const
  return `${side} ${align === "start" ? "top" : "bottom"}` as const
}

export default function PopoverDemo({
  side = "bottom",
  sideOffset = 4,
  align = "center",
  alignOffset = 0,
}: {
  side?: "top" | "right" | "bottom" | "left"
  sideOffset?: number
  align?: "start" | "center" | "end"
  alignOffset?: number
}) {
  return (
    <PopoverTrigger>
      <Button variant="outline">Open popover</Button>
      <Popover
        className="w-80"
        placement={toPlacement(side, align)}
        offset={sideOffset}
        crossOffset={alignOffset}
      >
        <div className="grid gap-4">
          <div className="space-y-2">
            <h4 className="leading-none font-medium">Dimensions</h4>
            <p className="text-sm text-muted-foreground">
              Set the dimensions for the layer.
            </p>
          </div>
          <div className="grid gap-2">
            <div className="grid grid-cols-3 items-center gap-4">
              <Label htmlFor="width">Width</Label>
              <Input
                id="width"
                defaultValue="100%"
                className="col-span-2 h-8"
              />
            </div>
            <div className="grid grid-cols-3 items-center gap-4">
              <Label htmlFor="maxWidth">Max. width</Label>
              <Input
                id="maxWidth"
                defaultValue="300px"
                className="col-span-2 h-8"
              />
            </div>
            <div className="grid grid-cols-3 items-center gap-4">
              <Label htmlFor="height">Height</Label>
              <Input
                id="height"
                defaultValue="25px"
                className="col-span-2 h-8"
              />
            </div>
            <div className="grid grid-cols-3 items-center gap-4">
              <Label htmlFor="maxHeight">Max. height</Label>
              <Input
                id="maxHeight"
                defaultValue="none"
                className="col-span-2 h-8"
              />
            </div>
          </div>
        </div>
      </Popover>
    </PopoverTrigger>
  )
}

Installation

Install the packages

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Add the shadcn/ui component

npx shadcn@latest add popover
pnpm dlx shadcn@latest add popover
yarn dlx shadcn@latest add popover
bun x shadcn@latest add popover

Animate it

The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate popover
pnpm dlx @animate-ui/cli@latest animate popover
yarn dlx @animate-ui/cli@latest animate popover
bun x @animate-ui/cli@latest animate popover

Usage

Import from Animate UI instead of react-aria-components: it re-exports all of React Aria, with an animated Popover. Everything else stays the same.

import {
  Button,
  Dialog,
  DialogTrigger,
  Heading,
  Popover,
} from '@animate-ui/react/primitives/react-aria';

export function SharePopover() {
  return (
    <DialogTrigger>
      <Button>Share</Button>
      <Popover placement="bottom" offset={4}>
        <Dialog>
          <Heading slot="title">Share this document</Heading>
          Anyone with the link can view it.
        </Dialog>
      </Popover>
    </DialogTrigger>
  );
}

Popover is the overlay under React Aria's Select, ComboBox, MenuTrigger, SubmenuTrigger, DatePicker and PreviewTrigger too: importing them from Animate UI animates every one of them.

Animation

React Aria has no Animate UI v1 counterpart: the Popover moves like the closest v1 overlay.

  • Under a DialogTrigger, a PreviewTrigger or a date picker, like the Radix UI and Base UI popovers: it grows from 0.8 and fades in, from the trigger (--trigger-anchor-point, or the side facing the trigger before React Aria measures it), on a spring (stiffness 400, damping 29) that overshoots a little; it shrinks back the same way when it closes, then unmounts.
  • Under a MenuTrigger, a SubmenuTrigger, a Select or a ComboBox, like the menus: a 220ms fade and scale from 0.9 (soft ease-out), both ways. Submenus move like their menu. Under a Select or a ComboBox with a single selection, choosing another item holds the popover still before it closes while the chosen item's check (a SelectionIndicator) changes: 250ms while it draws itself, 150ms while it pops or glides. Without a check, it doesn't wait. Under a MenuTrigger or a SubmenuTrigger, choosing an item whose check changes holds it the same way, along with the menus around a submenu.
  • React Aria only keeps a closing popover mounted if animations are running when it closes, before Motion starts any. Animate UI tracks the same open state React Aria reads (the trigger's, or isOpen/defaultOpen) and passes isExiting while the exit plays, so data-exiting and the isExiting render prop stay accurate.
  • Reopening while it closes picks up from where it is.
  • shouldSkipAnimation (set by PreviewTrigger when swapping previews during its warmup) skips both the entrance and the exit, as React Aria intends.
  • className and style functions keep working, and your own render function is made animatable rather than replaced.
  • Open from the start (defaultOpen on the trigger or the Popover), it plays its entrance on page load, like v1.
  • Under reduced motion, it only fades (no scale).

API Reference

Popover takes exactly the props of React Aria's Popover, plus:

PropTypeDefault
transition?
Transition{ type: 'spring', stiffness: 400, damping: 29 }; menus, selects and comboboxes: { duration: 0.22, ease: [0.215, 0.61, 0.355, 1] }
exitTransition?
Transitiontransition

DialogTrigger, Dialog, OverlayArrow and the other components take React Aria props only. They are not animated.

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/18/2026

On this page