Skip to main content

Popover

Displays rich content in a portal, triggered by a button.

When to use

A Popover is a non-modal dialog anchored to a trigger. Use it when the floating content must be interactive: forms, menus of actions, filters, settings.

  • Prefer Tooltip for a short, non-interactive label on a control (for example, the purpose of an icon-only button).
  • Prefer HoverCard for a sighted-only preview of content that already lives behind a link.

Popover is a non-modal dialog by default: focus moves into the content on open, Escape closes and returns focus to the trigger, clicking outside dismisses, and the page (body and any scroll containers) continues to scroll normally. Pass modal on Popover.Root to trap focus inside the content, block interaction with the rest of the page, and lock body scroll while the popover is open (the WAI-ARIA dialog pattern).

import { Button } from "@ngrok/mantle/button";import { Input } from "@ngrok/mantle/input";import { Popover } from "@ngrok/mantle/popover"; <Popover.Root>	<Popover.Trigger asChild>		<Button type="button" appearance="filled" intent="neutral">			Open popover		</Button>	</Popover.Trigger>	<Popover.Content preferredWidth="max-w-96">		<form			className="grid gap-4"			onSubmit={(event) => {				event.preventDefault();			}}		>			<div className="space-y-2">				<h4 className="font-medium leading-none">Dimensions</h4>				<p className="text-sm text-gray-700">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="popover-width">Width</label>					<Input id="popover-width" defaultValue="100%" className="col-span-2 h-8" />				</div>				<div className="grid grid-cols-3 items-center gap-4">					<label htmlFor="popover-maxWidth">Max width</label>					<Input id="popover-maxWidth" defaultValue="300px" className="col-span-2 h-8" />				</div>				<div className="grid grid-cols-3 items-center gap-4">					<label htmlFor="popover-height">Height</label>					<Input id="popover-height" defaultValue="25px" className="col-span-2 h-8" />				</div>				<div className="grid grid-cols-3 items-center gap-4">					<label htmlFor="popover-maxHeight">Max height</label>					<Input id="popover-maxHeight" defaultValue="none" className="col-span-2 h-8" />				</div>			</div>		</form>	</Popover.Content></Popover.Root>;

Arrow

Render Popover.Arrow as a child of Popover.Content when the popover explains one specific control and needs a tip pointing at it. Radix positions the arrow with floating-ui's arrow middleware, so it stays centered on the anchor when collision detection shifts or flips the content. A hand-rolled tip is a fixed offset and drifts off the anchor as soon as the content moves. The arrow's fill and border repaint Popover.Content's own edge across its base, so the two edges read as one line. The tip also casts its own shadow, because shadow-md on Popover.Content stops at that content's border and an unshadowed tip reads as pasted on. That shadow is clipped at the base, so it never darkens the content's interior.

Popover.Arrow takes no asChild. The shape is two layers (a filled polygon, plus a polyline that strokes the slanted edges only), and a swapped element loses the border. Restyle it with className instead: fill-* for the surface and a stroke-* on the arrow's polyline child for the edge, since Radix renders the arrow as an svg where bg-* paints nothing.

import { Button } from "@ngrok/mantle/button";import { Popover } from "@ngrok/mantle/popover"; <Popover.Root>	<Popover.Trigger asChild>		<Button type="button" appearance="filled" intent="neutral">			What moved?		</Button>	</Popover.Trigger>	<Popover.Content side="right" preferredWidth="max-w-64">		<Popover.Arrow />		<p className="text-sm">Products moved up here.</p>	</Popover.Content></Popover.Root>;

Layering and z-index

Popover.Content renders at Tailwind z-50, Mantle's float tier. When composed inside an open Dialog, AlertDialog, or Sheet, it portals into that overlay's positioner and paints above the overlay that owns it. Outside every overlay, it portals to document.body, below the overlay tier (z-60). When sibling floats share a container, the most recently mounted float paints on top.

Popover does not render a backdrop by default. Modal layers such as Dialog, Sheet, and AlertDialog do render backdrops, so stacking those layers compounds the dim and blur effect on the layers underneath.

Composition

Compose the parts of a Popover together to build your own:

Popover.Root├── Popover.Trigger├── Popover.Anchor└── Popover.Content    ├── Popover.Arrow    └── Popover.Close

API Reference

The Popover components are built on top of Radix Popover.

Popover.Root

The root, stateful component that manages the open/closed state of the popover.

Popover.Content renders at Tailwind z-50, Mantle's float tier. When composed inside an open Dialog, AlertDialog, or Sheet, it portals into that overlay's positioner and paints above the overlay that owns it. Outside every overlay, it portals to document.body, below the overlay tier (z-60). When sibling floats share a container, the most recently mounted float paints on top.

All props from Radix Popover.Root.

PropTypeDefaultDescription
defaultOpen?booleanfalseThe open state when initially rendered. Use when you do not need to control the state.
open?booleanControlled open state. Must be used with onOpenChange.
onOpenChange?(open: boolean) => voidEvent handler called when the open state changes.
modal?booleanfalseThe modality of the popover. When true, interaction with outside elements is disabled.

Popover.Trigger

The button that toggles the popover.

All props from Radix Popover.Trigger.

PropTypeDefaultDescription
asChild?booleanfalseCompose onto an alternative element type or your own React component.

Popover.Content

The content to render inside the popover. Appears in a portal with styling and animations.

Popover.Content renders at Tailwind z-50, Mantle's float tier. When composed inside an open Dialog, AlertDialog, or Sheet, it portals into that overlay's positioner and paints above the overlay that owns it. Outside every overlay, it portals to document.body, below the overlay tier (z-60). When sibling floats share a container, the most recently mounted float paints on top.

Popover.Content sets position: relative, so Popover.Arrow's absolutely-positioned wrapper keeps one containing block through the open animation. An absolutely-positioned child of the content therefore anchors to the content.

All props from Radix Popover.Content, plus:

PropTypeDefaultDescription
preferredWidth?`max-w-${string}`"max-w-72"The preferred width as a Tailwind max-w- class.
side?"top" | "right" | "bottom" | "left""bottom"The preferred side of the trigger to render against.
sideOffset?number4The distance in pixels from the trigger.
align?"start" | "center" | "end""center"The preferred alignment against the trigger.

Popover.Anchor

An optional element to position the Popover.Content against. If not used, the content will position alongside the Popover.Trigger.

All props from Radix Popover.Anchor.

PropTypeDefaultDescription
asChild?booleanfalseCompose onto an alternative element type or your own React component.

Popover.Close

A button that closes an open popover.

All props from Radix Popover.Close.

PropTypeDefaultDescription
asChild?booleanfalseCompose onto an alternative element type or your own React component.

Popover.Arrow

An optional tip that points from Popover.Content at its anchor. Render it as a child of Popover.Content. Radix renders it as an svg, so fill-* paints the surface and bg-* paints nothing.

It takes no asChild — the shape is a filled polygon plus a polyline that strokes the slanted edges, and a swapped element loses the border that continues Popover.Content's edge.

All props from Radix Popover.Arrow except asChild and children, plus:

PropTypeDefaultDescription
width?number14The width of the arrow in pixels.
height?number7The height of the arrow in pixels. Radix adds this to sideOffset, so the tip lands sideOffset pixels from the anchor.
classNamestringClasses for the svg. Reach for fill-* for the surface, and target the polyline child for the edge: [&>polyline]:…. The part already sets filter and clip-path for its shadow.