diff --git a/packages/docs/components/Popover/examples.md b/packages/docs/components/Popover/examples.md
new file mode 100644
index 00000000..964e4aa4
--- /dev/null
+++ b/packages/docs/components/Popover/examples.md
@@ -0,0 +1,47 @@
+---
+title: Popover examples
+---
+
+# Examples
+
+## Dropdown menu
+
+Click the button to toggle a dropdown wired entirely with [`Action`](/components/Action/). The panel is a [`Transition`](/components/Transition/) child the `Popover` orchestrates; it closes on Esc and when an item is chosen. Positioning is native CSS anchor positioning.
+
+
+
+
+
+
+:::code-group
+
+<<< ./stories/menu/app.twig
+<<< ./stories/menu/app.js
+
+:::
+
+
+
+## Tooltip
+
+The same primitive, opened on hover and focus instead of click. `open()`/`close()` are called from the trigger's pointer and focus events, with a short fade as the [`Transition`](/components/Transition/).
+
+
+
+
+
+
+:::code-group
+
+<<< ./stories/tooltip/app.twig
+<<< ./stories/tooltip/app.js
+
+:::
+
+
diff --git a/packages/docs/components/Popover/index.md b/packages/docs/components/Popover/index.md
new file mode 100644
index 00000000..bdf620f7
--- /dev/null
+++ b/packages/docs/components/Popover/index.md
@@ -0,0 +1,98 @@
+---
+badges: [JS]
+---
+
+# Popover
+
+The `Popover` component is a headless wrapper around the native [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API) — the non-modal sibling of [`Dialog`](/components/Dialog/). It offloads to the platform what a floating panel needs: top-layer stacking (it always paints above the page, no `z-index` wars), Esc to close and, with `popover="auto"`, light-dismiss. It owns only the orchestration of your transitions.
+
+It ships no markup and no positioning: you author the HTML, wire the triggers with [`Action`](/components/Action/), animate with [`Transition`](/components/Transition/) or [`ViewTransition`](/components/ViewTransition/) children, and place the panel with your own CSS. A dropdown menu, a tooltip and a rich hover-card are all the same `Popover` — only the trigger and the positioning change.
+
+## Usage
+
+Register the component, along with the [`Action`](/components/Action/) and [`Transition`](/components/Transition/) components used by the authored HTML:
+
+```js
+import { registerComponent } from '@studiometa/js-toolkit';
+import { Action, Popover, Transition } from '@studiometa/ui';
+
+registerComponent(Action);
+registerComponent(Popover);
+registerComponent(Transition);
+```
+
+The `Popover` lives on the `[popover]` element; inside it sits a transition child — the visible, animated panel. Triggers live anywhere on the page and call the component through `Action`:
+
+```html
+
+
+ Menu
+
+
+
+```
+
+## Triggers are delegated to `Action`
+
+The `Popover` class has no refs and adds no event listeners of its own — every interaction is wired declaratively with [`Action`](/components/Action/), calling the component's methods:
+
+- **Dropdown (click):** `data-on:click="Popover(#id)->target.toggle()"` on the trigger, and `data-on:click="Popover(#id)->target.close()"` on each item.
+- **Tooltip / hover-card (hover + focus):** `data-on:mouseenter` / `data-on:focus` call `open()`, `data-on:mouseleave` / `data-on:blur` call `close()`.
+- **Esc :** `data-on:keydown="event.key === 'Escape' && Popover.close()"` on the popover itself.
+
+## `auto` vs `manual`
+
+The `popover` attribute value is yours to choose, and it decides who owns dismissal:
+
+- **`popover="manual"` (used in the examples):** the component owns show/hide, so the **leave transition always plays**. You re-add dismissal with `Action` (Esc , items closing on click). Nothing light-dismisses it behind your back.
+- **`popover="auto"`:** the platform adds light-dismiss (outside click) and Esc for free, but a platform-driven close is **instant** — it skips the leave transition, exactly like the native Esc path of a ``. The component listens to the native `toggle` event and keeps its state (and the `close` event) in sync when this happens.
+
+## Positioning is yours
+
+`Popover` ships no positioning opinion. Neutralize the user-agent overlay styles on the host and place it however you like — `position: fixed`, or native [CSS anchor positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning) as the examples do:
+
+```css
+#menu-trigger { anchor-name: --menu; }
+#menu {
+ position: fixed;
+ inset: auto;
+ background: transparent;
+ position-anchor: --menu;
+ position-area: bottom span-right;
+ margin-top: 0.5rem;
+}
+```
+
+## Transitions are fanned out
+
+Like `Dialog`, the `Popover` awaits `Promise.all` over the `enter()`/`leave()` of every [`Transition`](/components/Transition/) and [`ViewTransition`](/components/ViewTransition/) child:
+
+- `open()` shows the popover **then** runs `enter()`, so it is already in the top layer when its child animates in.
+- `close()` runs `leave()` **before** hiding it, so the popover is still painted while its child animates out.
+
+A plain `Transition` (a CSS opacity/transform) is the right default for a small panel; reach for a `ViewTransition` only when a transform would otherwise judder — and if you add a backdrop, carry over the top-layer stacking guards from the [drawer example](/components/Dialog/#building-a-drawer).
+
+With no transition child, `Promise.all([])` resolves immediately and open/close are instant.
+
+See the [examples](./examples.md) for a live dropdown and tooltip, and the [JS API](./js-api.md) for the full list of methods and events.
diff --git a/packages/docs/components/Popover/js-api.md b/packages/docs/components/Popover/js-api.md
new file mode 100644
index 00000000..4d5c5458
--- /dev/null
+++ b/packages/docs/components/Popover/js-api.md
@@ -0,0 +1,50 @@
+---
+title: Popover JS API
+outline: deep
+---
+
+# JS API
+
+## Properties
+
+### `popover`
+
+- Type: `HTMLElement`
+
+A getter returning the native `[popover]` element (`this.$el`).
+
+### `transitions`
+
+- Type: `Array`
+
+A getter returning every [`Transition`](/components/Transition/) and [`ViewTransition`](/components/ViewTransition/) child the popover orchestrates.
+
+## Methods
+
+### `open`
+
+- Returns `Promise`
+
+Open the popover: call [`showPopover()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/showPopover), emit `open`, then run every child's `enter()`. A no-op if the popover is already open. Resolves once the enter transitions have finished.
+
+### `close`
+
+- Returns `Promise`
+
+Close the popover: emit `close`, run every child's `leave()`, **then** call [`hidePopover()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/hidePopover) — so the popover is still painted while its children animate out. A no-op if the popover is already closed. Resolves once hidden.
+
+### `toggle`
+
+- Returns `Promise`
+
+Call `close()` if the popover is open, `open()` otherwise.
+
+## Events
+
+### `open`
+
+Emitted when the popover starts opening, before the enter transitions run. Also emitted when a `popover="auto"` element is opened by the platform.
+
+### `close`
+
+Emitted when the popover starts closing, before the leave transitions run. Also emitted when a `popover="auto"` element is light-dismissed by the platform (outside click or Esc ).
diff --git a/packages/docs/components/Popover/stories/menu/app.js b/packages/docs/components/Popover/stories/menu/app.js
new file mode 100644
index 00000000..ab4e890d
--- /dev/null
+++ b/packages/docs/components/Popover/stories/menu/app.js
@@ -0,0 +1,6 @@
+import { registerComponent } from '@studiometa/js-toolkit';
+import { Action, Popover, Transition } from '@studiometa/ui';
+
+registerComponent(Action);
+registerComponent(Popover);
+registerComponent(Transition);
diff --git a/packages/docs/components/Popover/stories/menu/app.twig b/packages/docs/components/Popover/stories/menu/app.twig
new file mode 100644
index 00000000..4ad8d51d
--- /dev/null
+++ b/packages/docs/components/Popover/stories/menu/app.twig
@@ -0,0 +1,47 @@
+
+
+{# `popover="manual"` so the component owns show/hide and the enter/leave
+ transitions always play. Esc and the items close it, wired with Action. #}
+
+
+
diff --git a/packages/docs/components/Popover/stories/tooltip/app.js b/packages/docs/components/Popover/stories/tooltip/app.js
new file mode 100644
index 00000000..ab4e890d
--- /dev/null
+++ b/packages/docs/components/Popover/stories/tooltip/app.js
@@ -0,0 +1,6 @@
+import { registerComponent } from '@studiometa/js-toolkit';
+import { Action, Popover, Transition } from '@studiometa/ui';
+
+registerComponent(Action);
+registerComponent(Popover);
+registerComponent(Transition);
diff --git a/packages/docs/components/Popover/stories/tooltip/app.twig b/packages/docs/components/Popover/stories/tooltip/app.twig
new file mode 100644
index 00000000..feafed1c
--- /dev/null
+++ b/packages/docs/components/Popover/stories/tooltip/app.twig
@@ -0,0 +1,40 @@
+
+ Hover / focus me
+
+
+{# A tooltip is the same primitive, opened on hover/focus instead of click. #}
+
+
+ A headless tooltip, positioned with your own CSS.
+
+
+
+
diff --git a/packages/tests/Popover/Popover.spec.ts b/packages/tests/Popover/Popover.spec.ts
new file mode 100644
index 00000000..ebf7adb8
--- /dev/null
+++ b/packages/tests/Popover/Popover.spec.ts
@@ -0,0 +1,157 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { Popover } from '@studiometa/ui';
+import { h, mount } from '#test-utils';
+
+/**
+ * happy-dom does not implement `HTMLElement.showPopover()`/`hidePopover()`, so
+ * we stub them as spies. The component tracks its own open state, so the stubs
+ * need no behavior of their own.
+ */
+function mockPopover(el: HTMLElement) {
+ el.showPopover = vi.fn();
+ el.hidePopover = vi.fn();
+ return el;
+}
+
+function transitionChild() {
+ return h('div', {
+ dataComponent: 'Transition',
+ dataOptionEnterFrom: 'opacity-0',
+ dataOptionLeaveTo: 'opacity-0',
+ dataOptionLeaveKeep: '',
+ class: 'opacity-0',
+ });
+}
+
+async function createPopover({ children = 0 } = {}) {
+ const kids = Array.from({ length: children }, transitionChild);
+ const el = h('div', { dataComponent: 'Popover', popover: 'auto' }, kids);
+ mockPopover(el);
+ document.body.append(el);
+ const popover = new Popover(el);
+ await mount(popover);
+ return { popover, el };
+}
+
+/**
+ * Dispatch the native `toggle` event the platform fires on light-dismiss.
+ */
+function nativeToggle(el: HTMLElement, newState: 'open' | 'closed') {
+ const event = new Event('toggle');
+ Object.assign(event, { newState });
+ el.dispatchEvent(event);
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('The Popover component', () => {
+ it('should be closed on instantiation', async () => {
+ const { popover, el } = await createPopover();
+ expect(popover.__isOpen).toBe(false);
+ expect(el.showPopover).not.toHaveBeenCalled();
+ });
+
+ it('should show the popover on open', async () => {
+ const { popover, el } = await createPopover();
+ await popover.open();
+ expect(el.showPopover).toHaveBeenCalledTimes(1);
+ expect(popover.__isOpen).toBe(true);
+ });
+
+ it('should hide the popover on close', async () => {
+ const { popover, el } = await createPopover();
+ await popover.open();
+ await popover.close();
+ expect(el.hidePopover).toHaveBeenCalledTimes(1);
+ expect(popover.__isOpen).toBe(false);
+ });
+
+ it('should be a no-op to open an already open popover', async () => {
+ const { popover, el } = await createPopover();
+ await popover.open();
+ await popover.open();
+ expect(el.showPopover).toHaveBeenCalledTimes(1);
+ });
+
+ it('should be a no-op to close an already closed popover', async () => {
+ const { popover, el } = await createPopover();
+ await popover.close();
+ expect(el.hidePopover).not.toHaveBeenCalled();
+ });
+
+ it('should toggle between open and close', async () => {
+ const { popover, el } = await createPopover();
+ await popover.toggle();
+ expect(el.showPopover).toHaveBeenCalledTimes(1);
+ expect(popover.__isOpen).toBe(true);
+ await popover.toggle();
+ expect(el.hidePopover).toHaveBeenCalledTimes(1);
+ expect(popover.__isOpen).toBe(false);
+ });
+
+ it('should emit `open` and `close` events', async () => {
+ const { popover } = await createPopover();
+ const openFn = vi.fn();
+ const closeFn = vi.fn();
+ popover.$on('open', openFn);
+ popover.$on('close', closeFn);
+
+ await popover.open();
+ expect(openFn).toHaveBeenCalledTimes(1);
+ await popover.close();
+ expect(closeFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('should fan `enter()`/`leave()` out to every transition child', async () => {
+ const { popover } = await createPopover({ children: 2 });
+ expect(popover.transitions).toHaveLength(2);
+
+ const enters = popover.transitions.map((transition) =>
+ vi.spyOn(transition, 'enter').mockResolvedValue(),
+ );
+ const leaves = popover.transitions.map((transition) =>
+ vi.spyOn(transition, 'leave').mockResolvedValue(),
+ );
+
+ await popover.open();
+ for (const enter of enters) expect(enter).toHaveBeenCalledTimes(1);
+ for (const leave of leaves) expect(leave).not.toHaveBeenCalled();
+
+ await popover.close();
+ for (const leave of leaves) expect(leave).toHaveBeenCalledTimes(1);
+ });
+
+ it('should leave transitions BEFORE hiding the popover', async () => {
+ const { popover, el } = await createPopover({ children: 1 });
+ const [transition] = popover.transitions;
+ const order: string[] = [];
+ vi.spyOn(transition, 'leave').mockImplementation(async () => {
+ order.push('leave');
+ });
+ (el.hidePopover as ReturnType).mockImplementation(() => {
+ order.push('hide');
+ });
+
+ await popover.open();
+ await popover.close();
+ expect(order).toEqual(['leave', 'hide']);
+ });
+
+ it('should sync state and emit `close` on a platform light-dismiss', async () => {
+ const { popover, el } = await createPopover();
+ const closeFn = vi.fn();
+ popover.$on('close', closeFn);
+
+ await popover.open();
+ // The platform closes it (Esc / outside click) without calling `close()`.
+ nativeToggle(el, 'closed');
+ expect(popover.__isOpen).toBe(false);
+ expect(closeFn).toHaveBeenCalledTimes(1);
+
+ // It can be reopened afterwards.
+ await popover.open();
+ expect(el.showPopover).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts
index 0f44e701..d565cb1b 100644
--- a/packages/tests/index.spec.ts
+++ b/packages/tests/index.spec.ts
@@ -49,6 +49,7 @@ test('components exports', () => {
"Modal",
"ModalWithTransition",
"Panel",
+ "Popover",
"PrefetchWhenOver",
"PrefetchWhenVisible",
"ScrollAnimation",
diff --git a/packages/ui/Popover/Popover.ts b/packages/ui/Popover/Popover.ts
new file mode 100644
index 00000000..388f2b12
--- /dev/null
+++ b/packages/ui/Popover/Popover.ts
@@ -0,0 +1,144 @@
+import { Base } from '@studiometa/js-toolkit';
+import type { BaseProps, BaseConfig } from '@studiometa/js-toolkit';
+import { Transition } from '../Transition/index.js';
+import { ViewTransition } from '../ViewTransition/index.js';
+
+/**
+ * A `toggle` event as dispatched by the native Popover API. Declared locally so
+ * the component does not depend on the DOM lib shipping `ToggleEvent` yet.
+ */
+type PopoverToggleEvent = Event & { newState: 'open' | 'closed' };
+
+export interface PopoverProps extends BaseProps {
+ $el: HTMLElement;
+ $children: {
+ Transition: Transition[];
+ ViewTransition: ViewTransition[];
+ };
+}
+
+/**
+ * Popover class.
+ *
+ * A headless wrapper around the native [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API):
+ * the non-modal sibling of [`Dialog`](https://ui.studiometa.dev/components/Dialog/).
+ * The `[popover]` element gives top-layer stacking, Esc and — with
+ * `popover="auto"` — light-dismiss for free; this class only orchestrates the
+ * transitions, fanning `enter()`/`leave()` out to every `Transition` /
+ * `ViewTransition` child (a dropdown panel, a tooltip, …).
+ *
+ * It ships no markup and owns no triggers: wire opening and closing from your
+ * HTML with [`Action`](https://ui.studiometa.dev/components/Action/) — e.g.
+ * `data-on:click="Popover(#menu)->target.toggle()"` for a dropdown, or
+ * `data-on:mouseenter`/`data-on:mouseleave` for a tooltip. Positioning is
+ * entirely author-controlled (native CSS anchor positioning, `position: fixed`,
+ * …); the library ships no positioning opinion.
+ *
+ * @link https://ui.studiometa.dev/components/Popover/
+ */
+export class Popover extends Base {
+ /**
+ * Config.
+ */
+ static config: BaseConfig = {
+ name: 'Popover',
+ components: { Transition, ViewTransition },
+ emits: ['open', 'close'],
+ };
+
+ /**
+ * Whether the popover is open, mirroring the platform state.
+ * @private
+ */
+ __isOpen = false;
+
+ /**
+ * Get the native `[popover]` element.
+ */
+ get popover(): HTMLElement {
+ return this.$el;
+ }
+
+ /**
+ * Get every transition child to orchestrate, `Transition` and
+ * `ViewTransition` alike. With none, open/close are instant.
+ */
+ get transitions(): Array {
+ const { Transition: transitions = [], ViewTransition: viewTransitions = [] } = this.$children;
+ return [...transitions, ...viewTransitions] as Array;
+ }
+
+ /**
+ * Keep the internal state in sync when the platform toggles the popover on
+ * its own — a light-dismiss (outside click) or Esc on a
+ * `popover="auto"` element closes it natively, without going through
+ * `close()`, so the leave transition is skipped (an instant close, like the
+ * native Esc path of `Dialog`). Our own `showPopover()` /
+ * `hidePopover()` calls are ignored here because they never disagree with the
+ * flag we just set.
+ * @private
+ */
+ __onToggle = (event: Event) => {
+ const { newState } = event as PopoverToggleEvent;
+ if (newState === 'closed' && this.__isOpen) {
+ this.__isOpen = false;
+ this.$emit('close');
+ } else if (newState === 'open' && !this.__isOpen) {
+ this.__isOpen = true;
+ this.$emit('open');
+ }
+ };
+
+ /**
+ * Listen for platform-driven toggles.
+ */
+ mounted() {
+ this.$el.addEventListener('toggle', this.__onToggle);
+ }
+
+ /**
+ * Stop listening for platform-driven toggles.
+ */
+ destroyed() {
+ this.$el.removeEventListener('toggle', this.__onToggle);
+ }
+
+ /**
+ * Open the popover: show it in the top layer, emit `open`, then run every
+ * child's `enter()`. A no-op if already open. Resolves once the enter
+ * transitions have finished.
+ */
+ async open(): Promise {
+ if (this.__isOpen) {
+ return;
+ }
+
+ this.__isOpen = true;
+ this.popover.showPopover();
+ this.$emit('open');
+ await Promise.all(this.transitions.map((transition) => transition.enter()));
+ }
+
+ /**
+ * Close the popover: emit `close`, run every child's `leave()`, **then** hide
+ * it — so the popover is still painted while its children animate out. A
+ * no-op if already closed. Resolves once hidden.
+ */
+ async close(): Promise {
+ if (!this.__isOpen) {
+ return;
+ }
+
+ this.__isOpen = false;
+ this.$emit('close');
+ await Promise.all(this.transitions.map((transition) => transition.leave()));
+ this.popover.hidePopover();
+ }
+
+ /**
+ * Toggle the popover open or closed.
+ */
+ toggle(): Promise {
+ return this.__isOpen ? this.close() : this.open();
+ }
+}
diff --git a/packages/ui/Popover/index.ts b/packages/ui/Popover/index.ts
new file mode 100644
index 00000000..29e4efc1
--- /dev/null
+++ b/packages/ui/Popover/index.ts
@@ -0,0 +1 @@
+export * from './Popover.js';
diff --git a/packages/ui/index.ts b/packages/ui/index.ts
index 5f471d02..037417d9 100644
--- a/packages/ui/index.ts
+++ b/packages/ui/index.ts
@@ -20,6 +20,7 @@ export * from './LazyInclude/index.js';
export * from './Menu/index.js';
export * from './Modal/index.js';
export * from './Panel/index.js';
+export * from './Popover/index.js';
export * from './Prefetch/index.js';
export * from './ScrollAnimation/index.js';
export * from './ScrollReveal/index.js';