A primitive for building popovers. Used only within other Pluma components.


Importing

The components can be imported via:

import { 
	PopoverPrimitive, 
	PopoverPrimitiveTrigger, 
	PopoverPrimitiveContent,
	PopoverPrimitiveDragHandle
} from '@customerio/pluma-components/react';

Usage

A PopoverPrimitive is composed of four components:

  • PlumaPopoverPrimitive - the root component, which sets up the context, state and interactions
  • PlumaPopoverPrimitiveTrigger - the element that triggers the popover to show
  • PlumaPopoverPrimitiveContent - the content that will be shown when the trigger is clicked
  • PlumaPopoverPrimitiveDragHandle - an optional component that allows the popover to be dragged (only available when isDraggable is enabled)

Basic content

If the content of the popover is simple, content and heading props accept strings to be rendered inside the popover:

<PopoverPrimitive 
	header="Example popover" 
	content="Example content"
>
	<PopoverPrimitiveTrigger>
		<Text>Click me</Text>
	</PopoverPrimitiveTrigger>
</PopoverPrimitive>

Placement

The component accepts a placement argument, which accepts one of these values:

type Placement =
	| 'top'
	| 'top-start'
	| 'top-end'
	| 'right'
	| 'right-start'
	| 'right-end'
	| 'bottom'
	| 'bottom-start'
	| 'bottom-end'
	| 'left'
	| 'left-start'
	| 'left-end';

For example:

<Box display="flex" gap="500">
	<PopoverPrimitive content="I am the popover content" placement="top">
		<PopoverPrimitiveTrigger>
			<Text>Click me</Text>
		</PopoverPrimitiveTrigger>
	</PopoverPrimitive>

	<PopoverPrimitive content="I am the popover content" placement="top-start">
		<PopoverPrimitiveTrigger>
			<Text>Click me</Text>
		</PopoverPrimitiveTrigger>
	</PopoverPrimitive>

	<PopoverPrimitive content="I am the popover content" placement="right">
		<PopoverPrimitiveTrigger>
			<Text>Click me</Text>
		</PopoverPrimitiveTrigger>
	</PopoverPrimitive>
</Box>

Custom content

Usually, the contents of a popover will be more than a simple string. To render any custom content inside the popover, use the PopoverPrimitiveContent component, and do not use the content and header props:

<PopoverPrimitive>
	<PopoverPrimitiveTrigger>
		<Text>Click me</Text>
	</PopoverPrimitiveTrigger>

	<PopoverPrimitiveContent>
		<ButtonGroup>
			<Button>Button 1</Button>
			<Button>Button 2</Button>
			<Button>Button 3</Button>
		</ButtonGroup>
	</PopoverPrimitiveContent>
</PopoverPrimitive>

Focus trap

You'll notice that when the popover opens, the first focusable element inside it will receive focus. This is an accessibility feature.

In addition to moving focus into the popover, the popover can also "trap" focus, as recommended in the Dialog ARIA pattern. While popovers aren't usually as prominent as a Modal, they may have to be treated as such depending on use case.

Enabling the focus trap will ensure that focus stays within the popover: when tabbing through items in the popover, tabbing past the last item will move focus to the first item, and vice versa.

The focus trap can be enabled with the shouldTrapFocus prop:

<PopoverPrimitive shouldTrapFocus={true}>
	<PopoverPrimitiveTrigger>
		<Text>Click me</Text>
	</PopoverPrimitiveTrigger>

	<PopoverPrimitiveContent>
		<Text as="p" mb="200">Try tabbing through these buttons:</Text>

		<ButtonGroup>
			<Button>Button 1</Button>
			<Button>Button 2</Button>
			<Button>Button 3</Button>
		</ButtonGroup>
	</PopoverPrimitiveContent>
</PopoverPrimitive>

More focus trap options are available via the following props:

Custom trigger elements

By default, PopoverPrimitiveTrigger will render an unstyled button tag. It is important to render an element that is focusable, and that indicates it can be clicked.

If you'd like to use a different component as a trigger, PopoverPrimitiveTrigger is polymorphic, and accepts an as prop. When doing this, make sure the component or element you use is focusable and clickable.

<PopoverPrimitive 
	header="Example popover" 
	content="Example content"
>
	<PopoverPrimitiveTrigger as={Button} variant="primary">
		Click me
	</PopoverPrimitiveTrigger>
</PopoverPrimitive>

Alternatively, the PopoverPrimitive component also exposes Floating UI props, which can be used to attach trigger interactions to any element.

This can be used by passing in a render function into the content, passing setReference into the component's ref, and spreading getReferenceProps:

<PopoverPrimitive>
	{(context) => (
		<Label
			color="green"
			ref={context.reference.setReference}
			{...context.reference.getReferenceProps()}
			role="button"
		>
			Click me
		</Label>
	)}

	<PopoverPrimitiveContent>
		<ButtonGroup>
			<Button>Button 1</Button>
			<Button>Button 2</Button>
			<Button>Button 3</Button>
		</ButtonGroup>
	</PopoverPrimitiveContent>
</PopoverPrimitive>

Custom trigger interactions

By default, the popover will open when the trigger is clicked. PopoverPrimitives typically contain long form or interactive content, and as such require intent to open and close.

However, if you'd like to open the popover on hover, this can be enabled via shouldTriggerOnHover:

<PopoverPrimitive 
	header="Example popover" 
	content="Example content"
	shouldTriggerOnClick={false}
	shouldTriggerOnHover={true}
>
	<PopoverPrimitiveTrigger>
		<Text>Hover me</Text>
	</PopoverPrimitiveTrigger>
</PopoverPrimitive>

List Navigation

The PopoverPrimitive component also exposes a shouldNavigateList prop, which can be used to enable keyboard navigation within the popover content.

Use the PopoverPrimitiveItem for each item in the list.

<PopoverPrimitive shouldNavigateList={true}>
	<PopoverPrimitiveTrigger>
		<Text>Click me</Text>
	</PopoverPrimitiveTrigger>

	<PopoverPrimitiveContent>
		<PopoverPrimitiveItem>
			<Text>Item 1</Text>
		</PopoverPrimitiveItem>
		<PopoverPrimitiveItem>
			<Text>Item 2</Text>
		</PopoverPrimitiveItem>
		<PopoverPrimitiveItem>
			<Text>Item 3</Text>
		</PopoverPrimitiveItem>
	</PopoverPrimitiveContent>
</PopoverPrimitive>

Draggable Popovers

The PopoverPrimitive component supports drag functionality, allowing users to reposition the popover by dragging it. This is enabled by setting the isDraggable prop to true and including a PopoverPrimitiveDragHandle component within the content.

<PopoverPrimitive isDraggable={true}>
	<PopoverPrimitiveTrigger>
		<Text>Click me (draggable)</Text>
	</PopoverPrimitiveTrigger>

	<PopoverPrimitiveContent>
		<Text as="p" mb="200">This popover can be dragged around the page.</Text>
		
		<PopoverPrimitiveDragHandle>
			<Icon name="drag-handle" />
		</PopoverPrimitiveDragHandle>
		
		<ButtonGroup mt="200">
			<Button>Button 1</Button>
			<Button>Button 2</Button>
		</ButtonGroup>
	</PopoverPrimitiveContent>
</PopoverPrimitive>

Drag Handle

The PopoverPrimitiveDragHandle component provides the interactive element for dragging. It:

  • Only renders when isDraggable is true on the parent PopoverPrimitive
  • Extends PlainButton and accepts all button props like onClick and isDisabled
  • Should contain a visual indicator (like an icon) to show it's draggable
  • Can be positioned anywhere within the PopoverPrimitiveContent

The drag handle is polymorphic and can be customized:

<PopoverPrimitive isDraggable={true}>
	<PopoverPrimitiveTrigger>
		<Text>Custom drag handle</Text>
	</PopoverPrimitiveTrigger>

	<PopoverPrimitiveContent>
		<Box display="flex" justifyContent="space-between" alignItems="center" mb="200">
			<Text fontWeight="600">Drag me!</Text>
			<PopoverPrimitiveDragHandle>
				<Icon name="drag-handle" />
			</PopoverPrimitiveDragHandle>
		</Box>
		
		<Text>Content below the drag handle</Text>
	</PopoverPrimitiveContent>
</PopoverPrimitive>

Drag Behavior

When dragging is enabled:

  • The popover position resets each time it opens to avoid confusion
  • Dragging works by applying CSS transforms, allowing the popover to move freely around the viewport
  • The drag state is maintained while the popover remains open
  • Mouse interactions outside the drag handle work normally (buttons, inputs, etc.)

Accessibility

The drag functionality is fully accessible and supports both mouse and keyboard users:

Keyboard Navigation

  • Space: Enter/exit keyboard drag mode
  • Arrow keys: Move popover (10px increments) when in drag mode
  • Shift + Arrow keys: Fine movement (1px increments)
  • Ctrl + Arrow keys: Coarse movement (50px increments)
  • Escape: Exit drag mode

Screen Reader Support

  • aria-grabbed indicates when the popover is being dragged (mouse or keyboard)
  • Dynamic aria-label provides contextual instructions:
    • Default: "Drag handle. Press Space to enter drag mode, or click and drag with mouse."
    • Active: "Drag handle (drag mode active). Use arrow keys to move, Escape to exit."

Focus Management

  • Drag handle remains focusable and follows standard button behavior
  • Keyboard drag mode doesn't interfere with Tab navigation
  • Focus is maintained on the drag handle during keyboard dragging

API

Default:125

The duration (in ms) for the popover animation.

Whether the popover should be open initially

Default:['content']

The order in which focus should be moved when navigating through the popover. See Floating UI for more details.

Default:0

Which element to initially focus when the popover is opened. Can be a number (a tabbable index as specified by focusOrder) or an HTMLElement. See Floating UI for more details.

Default:false

Whether the popover should be disabled. Can be used to conditionally enable/disable a popover depending on state.

Default:false

Whether the popover should be draggable.

Default:false

Whether the focus manager (for the opened popover) should be disabled entirely. See Floating UI for more details.

Whether the popover should be open. Only use this along with onOpenChange if you want to control the state of the popover

middlewareOptions  { arrow?: Partial<{ padding?: Padding | undefined; element: Element; }> | ((state: { x: number; y: number; placement: Placement; strategy: Strategy; initialPlacement: Placement; middlewareData: MiddlewareData; rects: ElementRects; platform: Platform; elements: Elements; }) => Partial<{ padding?: Padding | undefined; element: Element; }>) | undefined; offset?: Partial<{ mainAxis: number; crossAxis: number; alignmentAxis: number | null; }> | ((state: { x: number; y: number; placement: Placement; strategy: Strategy; initialPlacement: Placement; middlewareData: MiddlewareData; rects: ElementRects; platform: Platform; elements: Elements; }) => Partial<{ mainAxis: number; crossAxis: number; alignmentAxis: number | null; }>) | undefined; flip?: { padding?: Padding | undefined; rootBoundary?: RootBoundary | undefined; elementContext?: ElementContext | undefined; altBoundary?: boolean | undefined; mainAxis?: boolean | undefined; crossAxis?: boolean | undefined; fallbackPlacements?: Placement[] | undefined; fallbackStrategy?: "initialPlacement" | "bestFit" | undefined; fallbackAxisSideDirection?: "none" | "end" | "start" | undefined; flipAlignment?: boolean | undefined; boundary?: Boundary | undefined; } | ((state: { x: number; y: number; placement: Placement; strategy: Strategy; initialPlacement: Placement; middlewareData: MiddlewareData; rects: ElementRects; platform: Platform; elements: Elements; }) => { padding?: Padding | undefined; rootBoundary?: RootBoundary | undefined; elementContext?: ElementContext | undefined; altBoundary?: boolean | undefined; mainAxis?: boolean | undefined; crossAxis?: boolean | undefined; fallbackPlacements?: Placement[] | undefined; fallbackStrategy?: "initialPlacement" | "bestFit" | undefined; fallbackAxisSideDirection?: "none" | "end" | "start" | undefined; flipAlignment?: boolean | undefined; boundary?: Boundary | undefined; }) | undefined; shift?: { padding?: Padding | undefined; rootBoundary?: RootBoundary | undefined; elementContext?: ElementContext | undefined; altBoundary?: boolean | undefined; mainAxis?: boolean | undefined; crossAxis?: boolean | undefined; limiter?: { fn: (state: MiddlewareState) => Coords; options?: any; } | undefined; boundary?: Boundary | undefined; } | ((state: { x: number; y: number; placement: Placement; strategy: Strategy; initialPlacement: Placement; middlewareData: MiddlewareData; rects: ElementRects; platform: Platform; elements: Elements; }) => { padding?: Padding | undefined; rootBoundary?: RootBoundary | undefined; elementContext?: ElementContext | undefined; altBoundary?: boolean | undefined; mainAxis?: boolean | undefined; crossAxis?: boolean | undefined; limiter?: { fn: (state: MiddlewareState) => Coords; options?: any; } | undefined; boundary?: Boundary | undefined; }) | undefined; hide?: { padding?: Padding | undefined; strategy?: "referenceHidden" | "escaped" | undefined; rootBoundary?: RootBoundary | undefined; elementContext?: ElementContext | undefined; altBoundary?: boolean | undefined; boundary?: Boundary | undefined; } | ((state: { x: number; y: number; placement: Placement; strategy: Strategy; initialPlacement: Placement; middlewareData: MiddlewareData; rects: ElementRects; platform: Platform; elements: Elements; }) => { padding?: Padding | undefined; strategy?: "referenceHidden" | "escaped" | undefined; rootBoundary?: RootBoundary | undefined; elementContext?: ElementContext | undefined; altBoundary?: boolean | undefined; boundary?: Boundary | undefined; }) | undefined; size?: { padding?: Padding | undefined; rootBoundary?: RootBoundary | undefined; elementContext?: ElementContext | undefined; altBoundary?: boolean | undefined; boundary?: Boundary | undefined; apply?: ((args: { x: number; y: number; placement: Placement; strategy: Strategy; initialPlacement: Placement; middlewareData: MiddlewareData; rects: ElementRects; platform: Platform; elements: Elements; } & { availableWidth: number; availableHeight: number; }) => Promisable<void>) | undefined; } | ((state: { x: number; y: number; placement: Placement; strategy: Strategy; initialPlacement: Placement; middlewareData: MiddlewareData; rects: ElementRects; platform: Platform; elements: Elements; }) => { padding?: Padding | undefined; rootBoundary?: RootBoundary | undefined; elementContext?: ElementContext | undefined; altBoundary?: boolean | undefined; boundary?: Boundary | undefined; apply?: ((args: { x: number; y: number; placement: Placement; strategy: Strategy; initialPlacement: Placement; middlewareData: MiddlewareData; rects: ElementRects; platform: Platform; elements: Elements; } & { availableWidth: number; availableHeight: number; }) => Promisable<void>) | undefined; }) | undefined; } | undefined

Additional options to pass into Floating UI middleware

Where the popover should be placed in relation to the trigger

Default:true

Whether focusout event listeners are attached to the trigger and popover, to close the popover when focus moves outside of it. See Floating UI for more details.

Default:false

Whether the popover should navigate through a list of items when the arrow keys are pressed. See Floating UI for more details.

Default:true

Whether the focus guards are rendered by the focus manager. See Floating UI for more details.

Default:true

Whether the popover content's max width and/or max height should be restricted using the Floating UI size middleware.

By default, this is enabled, to prevent popovers from growing larger than the available viewport size.

Default:true

Whether focus should be returned to the trigger when the popover is closed. See Floating UI for more details.

Default:false

Whether visually hidden close buttons are rendered before and after the popover. See Floating UI for more details.

Default:false

If the popover contains interactive elements, the dialog pattern should be applied. Enable shouldTrapFocus to ensure focus is kept within the popover while it's open. See Floating UI for more details.

Default:true

Whether the popover should open when the trigger is clicked. This is the recommended default.

Default:false

Whether the popover should open when the trigger is focused. Defaults to true when shouldTriggerOnHover is true.

Default:false

Whether the popover should open when the trigger is hovered. Only use if absolutely necessary, as Popovers are meant to be triggered by clicks.

The position CSS property to use on the floating element

Additional classes to apply on the popover component (the floating element, not the trigger). Should only be used as a last resort

Additional classes to apply on the popover content component. Should only be used as a last resort

Additional inline styles to apply on the popover component (the floating element, not the trigger). Should only be used as a last resort

An internal function to pass the Floating UI nodeId up to a parent if necessary.

Default:true

Whether the popover content should show an arrow.

Default:false

Whether "typeahead" functionality should be enabled, which focuses items while typing. See Floating UI for more details. Enabled by default when shouldNavigateList is true.

Whether the trigger is disabled

Additional classes to apply on the popover arrow

The URL for the link, if the item should render as a link instead of a button

Whether the item is disabled

Whether the link is external. When this flag is true, an a tag will be used, and target="_blank" rel="noopener noreferrer" will be added automatically

This is passed into the provider's linkComponent when used with href. It can be used by the link component implementation to handle replaceState instead of pushState

Default:true

Whether clicking the item should close the popover

Default:true

Whether to add safe external attributes (target="_blank" rel="noopener noreferrer") for external links. This can be turned off for special cases like mailto: links

If true, the button will automatically show the spinner when the onClick function is called and it returns a promise. If false, the spinner will only show if the isLoading prop is set to true. By default, this is true.

The URL passed into the link component (or a tag)

Disables the button. Use this instead of the native disabled prop, to make sure non-button elements (when used with as) get the correct styles

When this flag is true, an a tag will be used instead of the provider's linkComponent, even if it exists. Additionally, target="_blank" rel="noopener noreferrer" will be added automatically

Shows a spinner in the button overlaid on top of the button's content. The button will act as if disabled while in the loading state.

The function to call when the button is clicked

This is passed into the provider's linkComponent. It can be used by the link component implementation to handle replaceState instead of pushState

This allows turning off the automatic addition of target="_blank" rel="noopener noreferrer". This can be used for links to other protocols like mailto: