---
title: Popover
description: A floating panel anchored to a trigger element with collision-aware positioning.
order: 13
tags: [primitives]
---

# 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>`.

```ts
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

```ts
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.

```ts
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.

```ts
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.

```html
<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:

```html
<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`

```ts
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.

```ts
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.

```ts
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.
