Popover
A non-modal panel on the native Popover API, anchored to its trigger with CSS anchor positioning. It flips to fit and unfolds from the side it opened on, in CSS.
Set the width and height of the layer.
Installation
Add the registry to components.json, once
{
"registries": {
"@rabtx": "http://localhost:3002/r/{name}.json"
}
}Add the component
npx shadcn@latest add @rabtx/popoverOr, without the namespace, by URL:
npx shadcn@latest add http://localhost:3002/r/popover.jsonThe files land in your components/ui and are styled by your own theme. Add --overwrite to replace a shadcn component with the same name.
Usage
Installed from the registry, import from @/components/ui/popover; inside this monorepo, from
@rabtx/ui/popover.
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@rabtx/ui/popover";
export function Dimensions() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="secondary">Dimensions</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="center" sideOffset={8}>
<PopoverHeader>
<PopoverTitle>Dimensions</PopoverTitle>
<PopoverDescription>Set the width and height of the layer.</PopoverDescription>
</PopoverHeader>
…
</PopoverContent>
</Popover>
);
}The parts are shadcn's, with the same data-slot names. PopoverContent is a [popover] element
and PopoverTrigger its native invoker (popovertarget), so the browser does the hard parts: the
panel sits in the top layer above everything (dialogs included), a press outside or Escape closes
it, focus goes back to the trigger, and it opens before the page hydrates. The panel is
role="dialog", named by PopoverTitle and described by PopoverDescription without ids passed
by hand; on open, focus moves to its first field.
Sides
side is top, right, bottom (default) or left. When the panel would not fit there,
position-try-fallbacks flips it to the other side, and the unfold still starts at the trigger.
Align
align lines the panel up with the trigger's start edge, its center (default) or its end edge.
A start- or end-aligned panel that would overflow flips to the other edge.
Controlled, with a close button
open and onOpenChange follow every change, whatever made it. PopoverClose is a native
popovertargetaction="hide": wrap a Cancel button in it with asChild.
Anyone with the link can view it.
Closed · Not shared
API
Popover holds the state and renders nothing itself:
Prop
Type
PopoverContent takes everything a div takes (on the [popover] element), plus:
Prop
Type
PopoverTrigger and PopoverClose render a button, or with asChild pass their props to the
one child, which must forward them to a <button> (our Button does). PopoverHeader and
PopoverTitle are divs and PopoverDescription a p.
Outside React, popoverParts from @rabtx/ui/popover/styles gives the class names; the Popover
API and anchor positioning need no script at all:
<button popovertarget="size" style="anchor-name: --size">Dimensions</button>
<div id="size" popover role="dialog" class="rx-popover" data-side="bottom" data-align="center"
style="position-anchor: --size">
<div class="rx-surface rx-popover-surface rx-popover-content">…</div>
</div>Theming
The Popover reads only the theme tokens.
| Part | Tokens |
|---|---|
| Panel | fill --popover, text --popover-foreground, Flat edge --border |
| Floating depth | --surface-ring, --surface-edge-top, --surface-edge-bottom, --surface-sheen, --surface-shade |
| Description | --muted-foreground |
| Corners | --radius-box |
| Motion | --spring-unfold, --motion-ease |
There is no cast shadow: the raised edge and its hairline ring separate the panel from the page.
How it works
| Effect | CSS |
|---|---|
| Placement | position-anchor names the trigger's anchor-name; position-area puts the panel on a side (bottom span-right for bottom and start, and so on) and the side's margin is the offset |
| Flip | position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline (inline first for left and right) |
| Unfold point | --_ax / --_ay name the corner or edge that touches the trigger; transform-origin and the clip start there. As it opens, the component reads where the panel landed and sets data-flip, which mirrors that point |
| Enter | @starting-style gives the first frame (opacity 0, scale 0.96); scale runs on the unfold spring, and a keyframe reveals the panel from a sliver at the unfold point (clip-path: inset()) |
| Exit | 150ms, eased, back to the sliver; display and overlay transition with allow-discrete, so it stays in the top layer until it has gone |
JavaScript only keeps the open state in step with the element and writes data-flip. Reduced
motion keeps a 120ms fade; data-motion="off" shows and hides it at once.
Browser support
- Chromium 133+: everything, including the invoker
sourcethat keeps a press on the trigger from counting as a press outside. - Safari 26 and Firefox with anchor positioning: the same. Without anchor positioning the
browser centers the panel in the viewport, which is still usable; without
overlaythe exit is instant.
Accessibility
- The trigger has
aria-haspopup="dialog",aria-expandedandaria-controls. - The panel is a non-modal
role="dialog", labelled by its title and described by its description when they are present. Focus moves to its first focusable element (or the panel) on open, and back to the trigger on Escape or close. - Tab moves from the trigger into the open panel and on out of it in document order, as the Popover API defines.
Tooltip
A short label on hover and keyboard focus, on the native Popover API and placed by CSS anchor positioning, in shadcn's parts. Every animation is CSS.
Dropdown Menu
WAI-ARIA's menu button on the native Popover API, with icons, shortcuts, checkbox and radio rows, submenus, typeahead and a highlight that glides between rows. Plus a right-click ContextMenu.