Overlays

Popover

Radix popover with origin-aware scale from the trigger side, 150ms enter / 100ms exit, overlay shadow, and an optional arrow.

Install

npx shadcn@latest add @paragon/popover

popover.tsx

"use client";

import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";

/*
 * Origin-aware popover physics: the content scales 0.97 -> 1 from the
 * trigger side (transform-origin comes from Radix's positioning var),
 * 150ms in with ease-out, 100ms out with ease-exit settling at 0.99 —
 * the exit never retraces the enter. Reduced motion keeps only the fade.
 */
const popoverStyles = `
@keyframes pg-popover-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-popover-out { to { opacity: 0; scale: 0.99; } }
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-popover-in { from { opacity: 0; } }
  @keyframes pg-popover-out { to { opacity: 0; } }
}
`;

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

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

function PopoverAnchor(
  props: React.ComponentProps<typeof PopoverPrimitive.Anchor>,
) {
  return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}

export interface PopoverContentProps
  extends React.ComponentProps<typeof PopoverPrimitive.Content> {
  /** Render a small arrow pointing at the trigger. */
  showArrow?: boolean;
}

function PopoverContent({
  className,
  align = "center",
  sideOffset = 6,
  showArrow = false,
  children,
  ...props
}: PopoverContentProps) {
  return (
    <>
      {/* Hoisted outside the Portal: React 19 does not reliably render a
          bare <style> inside a Portal subtree, dropping the keyframes. */}
      <style href="paragon-popover" precedence="paragon">{popoverStyles}</style>
      <PopoverPrimitive.Portal>
        <PopoverPrimitive.Content
          data-slot="popover-content"
          align={align}
          sideOffset={sideOffset}
          className={cn(
            "z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg bg-popover p-4 text-popover-foreground shadow-overlay outline-none",
            "data-[state=open]:animate-[pg-popover-in_150ms_var(--ease-out)]",
            "data-[state=closed]:animate-[pg-popover-out_100ms_var(--ease-exit)_forwards]",
            className,
          )}
          {...props}
        >
          {children}
          {showArrow && (
            <PopoverPrimitive.Arrow
              width={12}
              height={6}
              className="fill-popover"
            />
          )}
        </PopoverPrimitive.Content>
      </PopoverPrimitive.Portal>
    </>
  );
}

export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent };