Composite

Popover

Popover provides a prop-driven component for common trigger + panel usage, plus compound primitives for forms and custom layouts. Use for settings panels and contextual content — distinct from Tooltip (hover hints) and Modal (blocking dialogs).

Installation

npx shadcn add https://rck.vibeboxph.com//r/popover.json

Live preview & controls

Click to open
Controls
<Popover
  title="Popover panel"
  content="Adjust settings or show contextual actions here."
  popupTrigger="popover"
/>

<Button variant="outline" data-popup="popover">
  Open popover
</Button>

Anatomy

  • PopoverHigh-level component driven by `title`, `content`, and an inline or detached trigger.
  • PopoverRootCompound root that holds open state.
  • PopoverTriggerCompound trigger wrapper used internally for inline children.
  • PopoverContentThe floating panel, portaled to the document body.
  • PopoverAnchor / PopoverCloseOptional anchor element and programmatic close control.

Props

PropTypeDefaultDescription
content*React.ReactNodePopover body.
titleReact.ReactNodeOptional heading shown above the body.
descriptionReact.ReactNodeOptional muted subtext under the title.
childrenReact.ReactElementInline trigger element wrapped by the popover.
triggerstringMatches `data-popup-id` on an external trigger element.
popupTriggerstringMatches `data-popup` on an external trigger element.
manualTriggerbooleanfalseWith `trigger`, skip auto click toggle and control open state manually.
open / onOpenChangeboolean / (open: boolean) => voidControlled open state.
modalbooleanfalseWhen true, interaction outside is limited like a dialog.
placement"top" | "right" | "bottom" | "left"bottomPreferred placement; flips to stay in viewport.
align"start" | "center" | "end"centerAlignment relative to the trigger.
sideOffsetnumber12Gap in pixels between trigger and panel.
alignOffsetnumberOffset along the alignment axis.
showClosebooleanfalseShow a close button in the panel header.
defaultOpenbooleanInitial open state when uncontrolled.
contentClassNamestringMerged onto the floating panel.
classNamestringMerged onto the floating panel (alias for content styling).
onOpenAutoFocus / onCloseAutoFocus(event: Event) => voidFocus management callbacks forwarded to the content.

Accessibility

  • Opens on trigger click and closes on outside click or Escape.
  • Focus is moved into the popover content when opened; use for interactive panels, not hover-only hints.
  • Pair with visible trigger labels — the popover content is not a substitute for button text.

Source

registry/rck/ui/popover.tsx
"use client";

import * as React from "react";
import * as PopoverPrimitive from "@/registry/rck/primitives/popover";
import { X } from "@/lib/icons";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/registry/rck/ui/button";

/** Attribute placed on external trigger elements. Pair with the Popover `trigger` prop. */
export const POPUP_TRIGGER_ATTR = "data-popup-id";
export const POPUP_TRIGGER_ALIAS_ATTR = "data-popup";

/** @deprecated Use `Popover` for the common case, or import primitives directly. */
export const PopoverRoot = PopoverPrimitive.Root;
export const PopoverAnchor = PopoverPrimitive.Anchor;
export const PopoverClose = PopoverPrimitive.Close;

export const PopoverTrigger = PopoverPrimitive.Trigger;

export const PopoverContent = React.forwardRef<
  React.ComponentRef<typeof PopoverPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 12, ...props }, ref) => (
  <PopoverPrimitive.Portal>
    <PopoverPrimitive.Content
      ref={ref}
      data-slot="popover-content"
      align={align}
      sideOffset={sideOffset}
      className={cn(
        "group border-border bg-popover text-popover-foreground z-50 w-72 overflow-visible rounded-lg border p-4 shadow-md outline-none",
        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
        "data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
        "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
        "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        className,
      )}
      {...props}
    >
      <PopoverPrimitive.Arrow
        aria-hidden="true"
        className={cn(
          "fill-popover stroke-border absolute stroke-[1.5]",
          "group-data-[side=bottom]:-top-[6px] group-data-[side=bottom]:left-1/2 group-data-[side=bottom]:-translate-x-1/2 group-data-[side=bottom]:rotate-180",
          "group-data-[side=top]:-bottom-[6px] group-data-[side=top]:left-1/2 group-data-[side=top]:-translate-x-1/2",
          "group-data-[side=right]:top-1/2 group-data-[side=right]:-left-[9px] group-data-[side=right]:-translate-y-1/2 group-data-[side=right]:rotate-90",
          "group-data-[side=left]:top-1/2 group-data-[side=left]:-right-[9px] group-data-[side=left]:-translate-y-1/2 group-data-[side=left]:-rotate-90",
        )}
      />
      {props.children}
    </PopoverPrimitive.Content>
  </PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;

function usePopupTriggerElement(trigger: string | undefined, attribute: string) {
  const [element, setElement] = React.useState<HTMLElement | null>(null);

  React.useLayoutEffect(() => {
    if (!trigger) return;

    const selector = `[${attribute}="${CSS.escape(trigger)}"]`;

    function resolveTrigger() {
      setElement(document.querySelector<HTMLElement>(selector));
    }

    resolveTrigger();
    const observer = new MutationObserver(resolveTrigger);
    observer.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: [attribute],
    });
    return () => observer.disconnect();
  }, [trigger, attribute]);

  return trigger ? element : null;
}

export interface PopoverProps extends Omit<
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>,
  "content" | "children" | "title"
> {
  content: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** Inline trigger passed as a child element. */
  children?: React.ReactElement;
  /** Matches `${POPUP_TRIGGER_ATTR}` on an external trigger element. */
  trigger?: string;
  /** Matches `data-popup` on an external trigger element. Use when the trigger is outside this component. */
  popupTrigger?: string;
  /** With `trigger`, skip auto click toggle and control open state manually. */
  manualTrigger?: boolean;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  modal?: boolean;
  contentClassName?: string;
  showClose?: boolean;
  className?: string;
}

function PopoverBody({
  title,
  description,
  content,
  showClose,
}: Pick<PopoverProps, "title" | "description" | "content" | "showClose">) {
  return (
    <>
      {(title || description || showClose) && (
        <div className="mb-3 flex items-start justify-between gap-3">
          <div className="space-y-1">
            {title ? <p className="text-sm font-medium">{title}</p> : null}
            {description ? (
              <p className="text-muted-foreground text-xs">{description}</p>
            ) : null}
          </div>
          {showClose ? (
            <PopoverClose
              aria-label="Close"
              className={cn(
                "text-muted-foreground rounded-sm outline-none",
                "hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-2",
              )}
            >
              <X className="size-4" />
            </PopoverClose>
          ) : null}
        </div>
      )}
      <div>{content}</div>
    </>
  );
}

export function Popover({
  content,
  title,
  description,
  children,
  trigger,
  popupTrigger,
  manualTrigger = false,
  open,
  defaultOpen,
  onOpenChange,
  modal,
  className,
  contentClassName,
  showClose = false,
  placement,
  align,
  sideOffset,
  alignOffset,
  ...contentProps
}: PopoverProps) {
  if (trigger && popupTrigger) {
    throw new Error("Popover accepts either `trigger` or `popupTrigger`, not both.");
  }

  const externalTrigger = trigger ?? popupTrigger;
  const triggerElement = usePopupTriggerElement(
    externalTrigger,
    popupTrigger ? POPUP_TRIGGER_ALIAS_ATTR : POPUP_TRIGGER_ATTR,
  );
  const hasInlineTrigger = Boolean(children);

  if (!hasInlineTrigger && !externalTrigger) {
    throw new Error("Popover requires `children`, `trigger`, or `popupTrigger`.");
  }

  if (hasInlineTrigger && externalTrigger) {
    throw new Error(
      "Popover accepts either `children` or an external trigger, not both.",
    );
  }

  return (
    <PopoverRoot
      open={open}
      defaultOpen={defaultOpen}
      onOpenChange={onOpenChange}
      modal={modal}
      manualTrigger={manualTrigger}
      triggerElement={hasInlineTrigger ? undefined : triggerElement}
    >
      {children ? <PopoverTrigger asChild>{children}</PopoverTrigger> : null}
      <PopoverContent
        placement={placement}
        align={align}
        sideOffset={sideOffset}
        alignOffset={alignOffset}
        className={cn(contentClassName, className)}
        {...contentProps}
      >
        <PopoverBody
          title={title}
          description={description}
          content={content}
          showClose={showClose}
        />
      </PopoverContent>
    </PopoverRoot>
  );
}

/** Default styles for a standalone popover trigger button (optional helper). */
export const popoverTriggerButtonClassName = buttonVariants({
  variant: "outline",
  size: "md",
});

Related examples

Browse the full examples gallery