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.