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.jsonLive 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
| Prop | Type | Default | Description |
|---|---|---|---|
| content* | React.ReactNode | — | Popover body. |
| title | React.ReactNode | — | Optional heading shown above the body. |
| description | React.ReactNode | — | Optional muted subtext under the title. |
| children | React.ReactElement | — | Inline trigger element wrapped by the popover. |
| trigger | string | — | Matches `data-popup-id` on an external trigger element. |
| popupTrigger | string | — | Matches `data-popup` on an external trigger element. |
| manualTrigger | boolean | false | With `trigger`, skip auto click toggle and control open state manually. |
| open / onOpenChange | boolean / (open: boolean) => void | — | Controlled open state. |
| modal | boolean | false | When true, interaction outside is limited like a dialog. |
| placement | "top" | "right" | "bottom" | "left" | bottom | Preferred placement; flips to stay in viewport. |
| align | "start" | "center" | "end" | center | Alignment relative to the trigger. |
| sideOffset | number | 12 | Gap in pixels between trigger and panel. |
| alignOffset | number | — | Offset along the alignment axis. |
| showClose | boolean | false | Show a close button in the panel header. |
| defaultOpen | boolean | — | Initial open state when uncontrolled. |
| contentClassName | string | — | Merged onto the floating panel. |
| className | string | — | Merged onto the floating panel (alias for content styling). |
| onOpenAutoFocus / onCloseAutoFocus | (event: Event) => void | — | Focus 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
Inline triggerWrap any trigger element as a child of Popover.
With formCompound PopoverRoot example with inputs and PopoverClose.
PlacementOpen from top, right, bottom, or left.
Detached data triggerOpen a popover from a separate element using popupTrigger and data-popup.