Popover
A floating panel anchored to a trigger for contextual content and controls.
@data-slot/popoverSource ↗bun add @data-slot/popovernpm install @data-slot/popoverpnpm add @data-slot/popoverAnatomy
A trigger and its content. popover-close is optional — Escape and a click outside already close the panel.
<div data-slot="popover">
<button data-slot="popover-trigger">Trigger</button>
<div data-slot="popover-content">
Content
<button data-slot="popover-close">Close</button>
</div>
</div>
Examples
Anchored panel
Unlike tooltips, popovers stay open until dismissed. Click outside or press Escape to close.
Unlike tooltips, popovers stay open until dismissed. Click outside or press Escape to close.
Show code
<div data-slot="popover" class="popover-root">
<button data-slot="popover-trigger" class="popover-trigger-btn">Open Popover</button>
<div data-slot="popover-content" data-side="bottom" data-align="center" class="popover-content" hidden>
<div class="popover-title">Popover Panel</div>
<p class="popover-text">
Unlike tooltips, popovers stay open until dismissed.
Click outside or press Escape to close.
</p>
<button data-slot="popover-close" class="popover-close-btn">Got it</button>
</div>
</div>
<style>
.popover-root { display: inline-block; }
.popover-trigger-btn {
padding: 0.5rem 1rem;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
cursor: pointer;
}
.popover-content {
position: fixed;
background: var(--surface);
border: 1px solid var(--border);
padding: 1rem;
width: 20rem;
z-index: 50;
transform-origin: var(--transform-origin, center);
--popover-slide-x: 0px;
--popover-slide-y: -4px;
max-width: var(--available-width);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
.popover-content[data-side="top"] {
--popover-slide-y: 4px;
}
.popover-content[data-side="bottom"] {
--popover-slide-y: -4px;
}
.popover-content[data-side="left"] {
--popover-slide-x: 4px;
--popover-slide-y: 0px;
}
.popover-content[data-side="right"] {
--popover-slide-x: -4px;
--popover-slide-y: 0px;
}
.popover-content[data-open] {
animation: popover-in 160ms cubic-bezier(0.16, 1, 0.3, 1);
}
.popover-content[data-closed] {
pointer-events: none;
animation: popover-out 120ms ease-in forwards;
}
@keyframes popover-in {
from {
opacity: 0;
scale: 0.96;
translate: var(--popover-slide-x) var(--popover-slide-y);
}
to {
opacity: 1;
scale: 1;
translate: 0 0;
}
}
@keyframes popover-out {
from {
opacity: 1;
scale: 1;
translate: 0 0;
}
to {
opacity: 0;
scale: 0.96;
translate: var(--popover-slide-x) var(--popover-slide-y);
}
}
.popover-title { font-weight: 700; margin-bottom: 0.5rem; }
.popover-text { color: var(--muted); font-size: 0.875rem; margin-bottom: 0.75rem;
line-height: 1.25rem;
}
.popover-close-btn {
padding: 0.25rem 0.625rem;
background: none;
border: 1px solid var(--border);
cursor: pointer;
font-size: 0.875rem;
line-height: 1.25rem;
}
</style><div data-slot="popover" class="relative inline-block">
<button
data-slot="popover-trigger"
class="px-4 py-2 bg-[var(--surface)] text-text border border-[var(--border)] cursor-pointer
hover:opacity-90 transition-opacity"
>
Open Popover
</button>
<div
data-slot="popover-content"
data-side="bottom"
data-align="center"
class="fixed bg-[var(--surface)] border border-[var(--border)]
p-4 w-80 max-w-(--available-width) z-50 shadow-lg"
hidden
>
<div class="font-bold mb-2 mt-0">Popover Panel</div>
<p class="text-[var(--muted)] text-sm mb-3">
Unlike tooltips, popovers stay open until dismissed.
Click outside or press Escape to close.
</p>
<button
data-slot="popover-close"
class="px-2.5 py-1 bg-transparent border border-[var(--border)]
cursor-pointer hover:bg-code-bg transition-colors"
>
Got it
</button>
</div>
</div>
/* State-based animation (works with presence lifecycle) */
[data-slot="popover-content"] {
transform-origin: var(--transform-origin, center);
--popover-slide-x: 0px;
--popover-slide-y: -4px;
}
[data-slot="popover-content"][data-side="top"] {
--popover-slide-y: 4px;
}
[data-slot="popover-content"][data-side="bottom"] {
--popover-slide-y: -4px;
}
[data-slot="popover-content"][data-side="left"] {
--popover-slide-x: 4px;
--popover-slide-y: 0px;
}
[data-slot="popover-content"][data-side="right"] {
--popover-slide-x: -4px;
--popover-slide-y: 0px;
}
[data-slot="popover-content"][data-open] {
animation: popover-in 160ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-slot="popover-content"][data-closed] {
pointer-events: none;
animation: popover-out 120ms ease-in forwards;
}
@keyframes popover-in {
from {
opacity: 0;
scale: 0.96;
translate: var(--popover-slide-x) var(--popover-slide-y);
}
to {
opacity: 1;
scale: 1;
translate: 0 0;
}
}
@keyframes popover-out {
from {
opacity: 1;
scale: 1;
translate: 0 0;
}
to {
opacity: 0;
scale: 0.96;
translate: var(--popover-slide-x) var(--popover-slide-y);
}
}import { create } from "@data-slot/popover";
// Initialize after the markup is in the document.
const controllers = create();
// Clean up before removing the component.
// controllers.forEach((controller) => controller.destroy());Position above the trigger
data-side="top" opens the panel above the trigger when there is room.
Unlike tooltips, popovers stay open until dismissed. Click outside or press Escape to close.
Unlike tooltips, popovers stay open until dismissed. Click outside or press Escape to close.
Show code
<div data-slot="popover" class="popover-root">
<button data-slot="popover-trigger" class="popover-trigger-btn">Open Popover</button>
<div data-slot="popover-content" data-side="top" data-align="center" class="popover-content" hidden>
<div class="popover-title">Popover Panel</div>
<p class="popover-text">
Unlike tooltips, popovers stay open until dismissed.
Click outside or press Escape to close.
</p>
<button data-slot="popover-close" class="popover-close-btn">Got it</button>
</div>
</div>
<style>
.popover-root { display: inline-block; }
.popover-trigger-btn {
padding: 0.5rem 1rem;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
cursor: pointer;
}
.popover-content {
position: fixed;
background: var(--surface);
border: 1px solid var(--border);
padding: 1rem;
width: 20rem;
z-index: 50;
transform-origin: var(--transform-origin, center);
--popover-slide-x: 0px;
--popover-slide-y: -4px;
max-width: var(--available-width);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
.popover-content[data-side="top"] {
--popover-slide-y: 4px;
}
.popover-content[data-side="bottom"] {
--popover-slide-y: -4px;
}
.popover-content[data-side="left"] {
--popover-slide-x: 4px;
--popover-slide-y: 0px;
}
.popover-content[data-side="right"] {
--popover-slide-x: -4px;
--popover-slide-y: 0px;
}
.popover-content[data-open] {
animation: popover-in 160ms cubic-bezier(0.16, 1, 0.3, 1);
}
.popover-content[data-closed] {
pointer-events: none;
animation: popover-out 120ms ease-in forwards;
}
@keyframes popover-in {
from {
opacity: 0;
scale: 0.96;
translate: var(--popover-slide-x) var(--popover-slide-y);
}
to {
opacity: 1;
scale: 1;
translate: 0 0;
}
}
@keyframes popover-out {
from {
opacity: 1;
scale: 1;
translate: 0 0;
}
to {
opacity: 0;
scale: 0.96;
translate: var(--popover-slide-x) var(--popover-slide-y);
}
}
.popover-title { font-weight: 700; margin-bottom: 0.5rem; }
.popover-text { color: var(--muted); font-size: 0.875rem; margin-bottom: 0.75rem;
line-height: 1.25rem;
}
.popover-close-btn {
padding: 0.25rem 0.625rem;
background: none;
border: 1px solid var(--border);
cursor: pointer;
font-size: 0.875rem;
line-height: 1.25rem;
}
</style><div data-slot="popover" class="relative inline-block">
<button
data-slot="popover-trigger"
class="px-4 py-2 bg-[var(--surface)] text-text border border-[var(--border)] cursor-pointer
hover:opacity-90 transition-opacity"
>
Open Popover
</button>
<div
data-slot="popover-content" data-side="top"
data-align="center"
class="fixed bg-[var(--surface)] border border-[var(--border)]
p-4 w-80 max-w-(--available-width) z-50 shadow-lg"
hidden
>
<div class="font-bold mb-2 mt-0">Popover Panel</div>
<p class="text-[var(--muted)] text-sm mb-3">
Unlike tooltips, popovers stay open until dismissed.
Click outside or press Escape to close.
</p>
<button
data-slot="popover-close"
class="px-2.5 py-1 bg-transparent border border-[var(--border)]
cursor-pointer hover:bg-code-bg transition-colors"
>
Got it
</button>
</div>
</div>
/* State-based animation (works with presence lifecycle) */
[data-slot="popover-content"] {
transform-origin: var(--transform-origin, center);
--popover-slide-x: 0px;
--popover-slide-y: -4px;
}
[data-slot="popover-content"][data-side="top"] {
--popover-slide-y: 4px;
}
[data-slot="popover-content"][data-side="bottom"] {
--popover-slide-y: -4px;
}
[data-slot="popover-content"][data-side="left"] {
--popover-slide-x: 4px;
--popover-slide-y: 0px;
}
[data-slot="popover-content"][data-side="right"] {
--popover-slide-x: -4px;
--popover-slide-y: 0px;
}
[data-slot="popover-content"][data-open] {
animation: popover-in 160ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-slot="popover-content"][data-closed] {
pointer-events: none;
animation: popover-out 120ms ease-in forwards;
}
@keyframes popover-in {
from {
opacity: 0;
scale: 0.96;
translate: var(--popover-slide-x) var(--popover-slide-y);
}
to {
opacity: 1;
scale: 1;
translate: 0 0;
}
}
@keyframes popover-out {
from {
opacity: 1;
scale: 1;
translate: 0 0;
}
to {
opacity: 0;
scale: 0.96;
translate: var(--popover-slide-x) var(--popover-slide-y);
}
}import { create } from "@data-slot/popover";
// Initialize after the markup is in the document.
const controllers = create();
// Clean up before removing the component.
// controllers.forEach((controller) => controller.destroy());API reference
Initialization
create(scope?)
Auto-discover and bind all popover instances in a scope (defaults to document).
import { create } from "@data-slot/popover";
const controllers = create(); // Returns PopoverController[]
createPopover(root, options?)
Create a controller for a specific element.
import { createPopover } from "@data-slot/popover";
const popover = createPopover(element, {
defaultOpen: false,
side: "bottom",
align: "center",
sideOffset: 4,
alignOffset: 0,
avoidCollisions: true,
collisionPadding: 8,
portal: true,
closeOnClickOutside: true,
closeOnEscape: true,
onOpenChange: (open) => console.log(open),
});
Slots
Runtime Slots
popover- Root element that manages the open state.popover-trigger- Required button that toggles the popover and anchors its position.popover-content- Required floating panel containing the popover’s content.popover-close- Optional button inside the content that closes the popover.popover-positioner- Optional authored positioning wrapper around the content, reused instead of a generated wrapper.popover-portal- Optional authored portal wrapper that can contain the positioner and content.
Markup
<div data-slot="popover">
<button data-slot="popover-trigger">Trigger</button>
<div data-slot="popover-content">
Content
<button data-slot="popover-close">Close</button>
</div>
</div>
Composed Portal Markup (Optional)
<div data-slot="popover">
<button data-slot="popover-trigger">Trigger</button>
<div data-slot="popover-portal">
<div data-slot="popover-positioner">
<div data-slot="popover-content">Content</div>
</div>
</div>
</div>
Data Attributes
Options can also be set via data attributes. JS options take precedence over data attributes.
Placement attributes (data-side, data-align, data-side-offset, data-align-offset, data-avoid-collisions, data-collision-padding) resolve in this order:
- JavaScript option
popover-contentpopover-positionerpopoverroot (fallback)
| Attribute | Type | Default | Description |
|---|---|---|---|
data-default-open |
boolean | false |
Initial open state |
data-side |
string | "bottom" |
Preferred side |
data-align |
string | "center" |
Preferred alignment |
data-side-offset |
number | 4 |
Distance from trigger (px) |
data-align-offset |
number | 0 |
Offset from alignment edge (px) |
data-avoid-collisions |
boolean | true |
Flip/shift to stay in viewport |
data-collision-padding |
number | 8 |
Viewport edge padding (px) |
data-portal |
boolean | true |
Portal content to document.body while open |
data-close-on-click-outside |
boolean | true |
Close when clicking outside |
data-close-on-escape |
boolean | true |
Close when pressing Escape |
Boolean attributes: present or "true" = true, "false" = false, absent = default.
Placement can be set on root, content, or authored positioner (content takes precedence):
<div data-slot="popover-content" data-side="top" data-align="end">
data-position is still supported as a deprecated fallback alias for data-side.
<!-- Popover that stays open when clicking outside -->
<div data-slot="popover" data-close-on-click-outside="false">
...
</div>
Options
| Option | Type | Default | Description |
|---|---|---|---|
defaultOpen |
boolean |
false |
Initial open state |
side |
"top" | "right" | "bottom" | "left" |
"bottom" |
Preferred side relative to trigger |
align |
"start" | "center" | "end" |
"center" |
Preferred alignment on the side axis |
sideOffset |
number |
4 |
Distance from trigger in pixels |
alignOffset |
number |
0 |
Offset from alignment edge in pixels |
avoidCollisions |
boolean |
true |
Flip/shift to stay in viewport |
collisionPadding |
number |
8 |
Viewport edge padding in pixels |
portal |
boolean |
true |
Portal content to document.body while open |
position |
"top" | "bottom" | "left" | "right" |
- | Deprecated alias for side |
closeOnClickOutside |
boolean |
true |
Close when clicking outside |
closeOnEscape |
boolean |
true |
Close when pressing Escape |
onOpenChange |
(open: boolean) => void |
undefined |
Callback when open state changes |
Controller
| Method/Property | Description |
|---|---|
open() |
Open the popover |
close() |
Close the popover |
toggle() |
Toggle the popover |
isOpen |
Current open state (readonly boolean) |
destroy() |
Cleanup all event listeners |
Controller Destruction
destroy() permanently disposes the controller and hides any open surface without
emitting an additional change event. Repeated destruction is safe; methods on the
old controller become no-ops. Create a new controller on the same root to rebind it.
Focus restoration already queued by a close survives destruction.
Events
Outbound Events
Listen for changes via custom events:
element.addEventListener("popover:change", (e) => {
console.log("Popover open:", e.detail.open);
});
Inbound Events
Control the popover via events:
| Event | Detail | Description |
|---|---|---|
popover:set |
{ open: boolean } |
Set open state programmatically |
// Open the popover
element.dispatchEvent(
new CustomEvent("popover:set", { detail: { open: true } })
);
// Close the popover
element.dispatchEvent(
new CustomEvent("popover:set", { detail: { open: false } })
);
Deprecated Shapes
The following shapes are deprecated and will be removed in the next major release:
popover:setdetail{ value: boolean }(use{ open: boolean })positionoption (useside)data-positionattribute (usedata-side)
// Deprecated: { value: boolean }
element.dispatchEvent(
new CustomEvent("popover:set", { detail: { value: true } })
);
Use the replacements listed above.
Styling
Popover position is computed in JavaScript and applied as position: absolute + inline transform: translate3d(...).
By default, content is portaled to document.body while open (document coordinates). If you provide authored popover-positioner / popover-portal slots, those are reused. Otherwise a transient popover-positioner wrapper is generated.
If portal is disabled, positioning is applied directly to popover-content.
The positioned element (popover-positioner, or popover-content when portal is disabled) also receives --transform-origin so popup animations can originate from the trigger anchor.
Use data-open/data-closed and data-side for styling/animation.
This keeps popover-content free for transform animations.
Placement uses layout dimensions, so scale/zoom animations on popover-content do not require an extra inner wrapper for stable positioning.
[data-slot="popover-content"] {
transform-origin: var(--transform-origin, center);
--popover-slide-x: 0px;
--popover-slide-y: -4px;
}
[data-slot="popover-content"][data-side="top"] {
--popover-slide-y: 4px;
}
[data-slot="popover-content"][data-side="bottom"] {
--popover-slide-y: -4px;
}
[data-slot="popover-content"][data-side="left"] {
--popover-slide-x: 4px;
--popover-slide-y: 0px;
}
[data-slot="popover-content"][data-side="right"] {
--popover-slide-x: -4px;
--popover-slide-y: 0px;
}
[data-slot="popover-content"][data-open] {
animation: popover-in 160ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-slot="popover-content"][data-closed] {
pointer-events: none;
animation: popover-out 120ms ease-in forwards;
}
@keyframes popover-in {
from {
opacity: 0;
scale: 0.96;
translate: var(--popover-slide-x) var(--popover-slide-y);
}
to {
opacity: 1;
scale: 1;
translate: 0 0;
}
}
@keyframes popover-out {
from {
opacity: 1;
scale: 1;
translate: 0 0;
}
to {
opacity: 0;
scale: 0.96;
translate: var(--popover-slide-x) var(--popover-slide-y);
}
}
With Tailwind:
<div data-slot="popover">
<button data-slot="popover-trigger">Open</button>
<div
data-slot="popover-content"
data-side="bottom"
data-align="start"
class="absolute bg-white shadow-lg p-4"
>
Content
</div>
</div>
Use Tailwind for layout/colors and keep the state selectors from the CSS snippet above for fade/zoom animation.
Keyboard Navigation
| Key | Action |
|---|---|
Enter / Space |
Toggle popover (on trigger) |
Escape |
Close popover and return focus to trigger |
Accessibility
The component automatically handles:
aria-haspopup="dialog"on triggeraria-controlslinking trigger to contentaria-expandedstate on trigger- Unique ID generation for content