Skip to content

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

SlotRequiredDescription
popoverYesRoot element. Controller is bound here.
popover-triggerYesElement that toggles the popover on click.
popover-contentYesThe floating panel body.
popover-closeNoAny element inside content that closes the popover on click.
popover-arrowNoDecorative arrow element. Position it via CSS relative to the popover-content or popover-positioner element.
popover-positionerNoExplicit wrapper for positioning. When authored, the positioner (not the content) receives the transform.
popover-portalNoExplicit portal wrapper. When authored, its contents are moved to <body> on open.

Generated Attributes

AttributeElement(s)ValuesDescription
data-stateRoot, content, positioner"open" / "closed"Current open state.
data-openRoot, content, positioner(present)Present when the popover is open.
data-closedRoot, content, positioner(present)Present when the popover is closed.
data-sideContent, positioner"top" / "right" / "bottom" / "left"Resolved side after collision detection.
data-alignContent, positioner"start" / "center" / "end"Resolved alignment after collision detection.
data-positionContentSame as data-sideDeprecated. Legacy alias for data-side.
aria-expandedTrigger"true" / "false"Reflects open state.
aria-haspopupTrigger"dialog"Indicates the trigger opens a dialog-like panel.
aria-controlsTriggerID of contentLinks trigger to the content panel.
--transform-originPositionerCSS custom propertySet on the positioner for CSS transform-origin animations.

Events

All events are dispatched from the root element (data-slot="popover").

Outbound

EventDetailDescription
popover:change{ open: boolean }Fires when the popover opens or closes.

popover:change

interface PopoverChangeDetail {
  open: boolean;
}
FieldDescription
openWhether the popover is now open.

Inbound

EventDetailDescription
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)

OptionTypeDefaultDescription
defaultOpenbooleanfalseInitial 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.
sideOffsetnumber4Distance in pixels from the trigger.
alignOffsetnumber0Offset in pixels from the alignment edge.
avoidCollisionsbooleantrueWhen true, flips and shifts content to avoid viewport collisions.
collisionPaddingnumber8Viewport padding in pixels used when avoiding collisions.
portalbooleantruePortal content to <body> while open.
closeOnClickOutsidebooleantrueClose when clicking outside the popover.
closeOnEscapebooleantrueClose when pressing Escape.
onOpenChange(open: boolean) => voidCalled when open state changes.
onPortalMounted(container: HTMLElement) => voidAfter portaled content mounts on open.
positionPopoverSideDeprecated. 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

MemberTypeDescription
isOpenboolean (getter)Current open state.
open()() => voidOpen the popover.
close()() => voidClose the popover.
toggle()() => voidToggle the popover open state.
destroy()() => voidRemove 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.