Skip to content

Popover

A floating panel anchored to a trigger element with collision-aware positioning.

Updated View as Markdown

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-side and data-align attributes for CSS animation hooks.
  • Transform origin: The --transform-origin CSS 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. Set portal: false or data-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 closeOnClickOutside and closeOnEscape).
  • 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.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close