Popover
A floating panel that appears next to a trigger element. Handles positioning via floating UI, collision detection, focus management, dismissal, and optional portal to <body>.
import { Popover } from "@areia/slots";
document.querySelector("#root")!.innerHTML = `
<div data-slot="popover">
<button data-slot="popover-trigger" class="win95-button">Open popover</button>
<div data-slot="popover-content" class="win95-menu">
<p>Popover content goes here.</p>
<button data-slot="popover-close" class="win95-button">Close</button>
<span data-slot="popover-arrow"></span>
</div>
</div>
`;
const root = document.querySelector<HTMLElement>(
'[data-slot="popover"]',
)!;
if (root) {
const controller = Popover.createPopover(root, {
side: "bottom",
align: "center",
});
}Import
import { Popover } from "@areia/slots";Usage
Call createPopover with the root element and optional configuration. The primitive reads slot attributes from the DOM and manages open/close state, floating positioning, focus, and dismiss behavior.
import { Popover } from "@areia/slots";
const root = document.querySelector<HTMLElement>(
'[data-slot="popover"]',
)!;
if (!root) throw new Error("No root element found");
const controller = Popover.createPopover(root, {
side: "bottom",
align: "center",
sideOffset: 4,
});
// Programmatic control
controller.open();
// controller.close();
// controller.toggle();
// controller.destroy();Auto-bind
Use the create helper to discover and bind every unattached root in a scope.
import { Popover } from "@areia/slots";
const controllers = Popover.create(document);
// controllers.forEach((c) => { ... });Expected Markup
The primitive expects the following slot structure. The popover-trigger and popover-content slots are required.
<div data-slot="popover">
<button data-slot="popover-trigger">Open popover</button>
<div data-slot="popover-content">
<p>Popover content goes here.</p>
<button data-slot="popover-close">Close</button>
<span data-slot="popover-arrow"></span>
</div>
</div>Content is portaled to <body> by default, wrapped in an auto-generated popover-positioner element for positioning. You can opt out of the portal by setting data-portal="false" on the root or content element.
An optional data-slot="popover-positioner" can be authored explicitly as a wrapper around the content to control the positioned container:
<div data-slot="popover">
<button data-slot="popover-trigger">Open</button>
<div data-slot="popover-portal">
<div data-slot="popover-positioner">
<div data-slot="popover-content">
<p>Content</p>
<div data-slot="popover-arrow"></div>
</div>
</div>
</div>
</div>Data Slots
| Slot | Required | Description |
|---|---|---|
popover |
Yes | Root element. Controller is bound here. |
popover-trigger |
Yes | Element that toggles the popover on click. |
popover-content |
Yes | The floating panel body. |
popover-close |
No | Any element inside content that closes the popover on click. |
popover-arrow |
No | Decorative arrow element. Position it via CSS relative to the popover-content or popover-positioner element. |
popover-positioner |
No | Explicit wrapper for positioning. When authored, the positioner (not the content) receives the transform. |
popover-portal |
No | Explicit portal wrapper. When authored, its contents are moved to <body> on open. |
Generated Attributes
| Attribute | Element(s) | Values | Description |
|---|---|---|---|
data-state |
Root, content, positioner | "open" / "closed" |
Current open state. |
data-open |
Root, content, positioner | (present) | Present when the popover is open. |
data-closed |
Root, content, positioner | (present) | Present when the popover is closed. |
data-side |
Content, positioner | "top" / "right" / "bottom" / "left" |
Resolved side after collision detection. |
data-align |
Content, positioner | "start" / "center" / "end" |
Resolved alignment after collision detection. |
data-position |
Content | Same as data-side |
Deprecated. Legacy alias for data-side. |
aria-expanded |
Trigger | "true" / "false" |
Reflects open state. |
aria-haspopup |
Trigger | "dialog" |
Indicates the trigger opens a dialog-like panel. |
aria-controls |
Trigger | ID of content | Links trigger to the content panel. |
--transform-origin |
Positioner | CSS custom property | Set on the positioner for CSS transform-origin animations. |
Events
All events are dispatched from the root element (data-slot="popover").
Outbound
| Event | Detail | Description |
|---|---|---|
popover:change |
{ open: boolean } |
Fires when the popover opens or closes. |
popover:change
interface PopoverChangeDetail {
open: boolean;
}| Field | Description |
|---|---|
open |
Whether the popover is now open. |
Inbound
| Event | Detail | Description |
|---|---|---|
popover:set |
{ open: boolean } |
Set the open state from outside the controller. |
Send popover:set on the root element to open or close the popover programmatically without a controller reference.
const root = document.querySelector<HTMLElement>(
'[data-slot="popover"]',
)!;
root?.dispatchEvent(
new CustomEvent("popover:set", {
bubbles: true,
detail: { open: true },
}),
);API Reference
Options
Popover.createPopover(root, options)
| Option | Type | Default | Description |
|---|---|---|---|
defaultOpen |
boolean |
false |
Initial open state. |
side |
"top" / "right" / "bottom" / "left" |
"bottom" |
Preferred side of the trigger to render against. |
align |
"start" / "center" / "end" |
"center" |
Preferred alignment against the trigger. |
sideOffset |
number |
4 |
Distance in pixels from the trigger. |
alignOffset |
number |
0 |
Offset in pixels from the alignment edge. |
avoidCollisions |
boolean |
true |
When true, flips and shifts content to avoid viewport collisions. |
collisionPadding |
number |
8 |
Viewport padding in pixels used when avoiding collisions. |
portal |
boolean |
true |
Portal content to <body> while open. |
closeOnClickOutside |
boolean |
true |
Close when clicking outside the popover. |
closeOnEscape |
boolean |
true |
Close when pressing Escape. |
onOpenChange |
(open: boolean) => void |
— | Called when open state changes. |
onPortalMounted |
(container: HTMLElement) => void |
— | After portaled content mounts on open. |
position |
PopoverSide |
— | Deprecated. Use side instead. |
Options can also be set via data-* attributes on the root element, using kebab-case names (e.g. data-side-offset="8"). JS options take precedence over data attributes.
Controller
| Member | Type | Description |
|---|---|---|
isOpen |
boolean (getter) |
Current open state. |
open() |
() => void |
Open the popover. |
close() |
() => void |
Close the popover. |
toggle() |
() => void |
Toggle the popover open state. |
destroy() |
() => void |
Remove all event listeners and clean up. |
Controller
The controller is the imperative handle returned by createPopover. Use it for programmatic control when you need to open or close the popover from application logic.
import { Popover } from "@areia/slots";
const root = document.querySelector<HTMLElement>(
'[data-slot="popover"]',
)!;
if (!root) throw new Error("No root element");
const ctrl = Popover.createPopover(root, {
side: "bottom",
align: "start",
});
// Read state
console.log(ctrl.isOpen); // boolean
// Open
ctrl.open();
// Close
ctrl.close();
// Toggle
ctrl.toggle();
// Tear down all listeners and clean up
ctrl.destroy();Behavior
- Floating positioning: The content is positioned relative to the trigger using collision-aware floating UI. The resolved side and alignment are mirrored in
data-sideanddata-alignattributes for CSS animation hooks. - Transform origin: The
--transform-originCSS custom property is set on the positioner to enable scale/fade animations that originate from the anchor edge. - Portal: Content is portaled to
<body>by default to escape stacking context and overflow clipping issues. Setportal: falseordata-portal="false"to keep content in place. - Focus: On open, focus moves to the first element with
[autofocus]inside the content. If none, the first focusable element receives focus. Falls back to the content element itself. On close, focus returns to the trigger. - Dismissal: Clicking outside the popover or pressing Escape closes it (configurable via
closeOnClickOutsideandcloseOnEscape). - Deduplication: Calling
Popover.createPopover()more than once on the same root returns the existing controller. Destroy it first if you need to rebind with new options. - Auto-bind: The
create()function discovers all[data-slot="popover"]elements in a scope and binds any that are not already bound.