EmPopover
An anchored panel over the native popover API — the browser owns placement, stacking and dismissal; Emerald owns the surface.
Usage
Three parts. EmPopover owns the open state and provides the anchor id; EmPopoverActivator is the trigger — a native <button> by default; EmPopoverContent is the panel, rendered in the top layer through the native popover attribute and pinned to the trigger by CSS anchor positioning.
There is nothing to position in JavaScript and nothing to teleport. The panel escapes overflow: hidden ancestors and stacking contexts because the top layer is above all of them, and clicking outside or pressing Escape closes it because that is what light dismiss does. v-model is optional — the parts coordinate through context, so an uncontrolled popover is just the three tags.
Anatomy
<script setup lang="ts">
import {
EmPopover,
EmPopoverActivator,
EmPopoverContent,
} from '@paper/emerald'
</script>
<template>
<EmPopover>
<EmPopoverActivator />
<EmPopoverContent />
</EmPopover>
</template>Composed on v0
Each part maps one-to-one onto v0’s Popover compound — Popover.Root, Popover.Activator, Popover.Content. v0 supplies the state, the popovertarget and popover wiring, the ARIA attributes and the anchor plumbing; Emerald supplies the panel’s skin — the bordered, elevated .emerald-popover surface on its spacing and radius tokens — and an open transition (a fade and 4px drop into place) built on @starting-style and discrete transitions so the native display toggle animates.
The split is why v-model survives light dismiss. The browser closes the popover on outside click or Escape without asking anyone; v0 listens for the native toggle event and writes the new state back into the model, so your ref reads false after a dismissal it never initiated. State flows both ways — write true to open, and the browser’s own closes flow back.
One wiring detail Emerald handles for you: v0’s Popover.Content only applies position-area and position-try when it is given an explicit id, so EmPopoverContent reads the root’s id from context and passes it down. Placement props work out of the box while the anchor name stays matched to the activator.
Open, close and light dismiss work in every browser with the popover API; automatic placement additionally needs CSS Anchor Positioning. The v0 Popover page lists the supported versions.
Examples
Props
| Prop | Type | Default | Description |
|---|---|---|---|
v-model | boolean | false | Open state. Optional — the compound is fully functional uncontrolled |
id | string | generated | Shared anchor id for the trigger and panel. Falls back to useId() |
Parts
None of the parts take a namespace — they bind to the nearest EmPopover above them through context, so nesting one popover inside another’s panel resolves by proximity.
| Part | Renders | Props | Slot props |
|---|---|---|---|
EmPopoverActivator | The trigger — a native button unless as or renderless says otherwise | as: DOMElement | null (default 'button'), renderless: boolean (default false) | isOpen, attrs |
EmPopoverContent | The panel, via the native popover attribute | positionArea: string (default 'bottom'), positionTry: string (default 'most-width bottom') | — |
The activator’s attrs bundle carries popovertarget, aria-expanded, aria-controls, data-open (true while open, absent otherwise — a CSS hook), tabindex="0", the anchor-name style, and one host-specific extra: type="button" when the host is a native button, or role="button" plus an Enter/Space keydown polyfill when it is not. Bind it onto exactly one element, and only in renderless mode.
EmPopoverContent styles the panel through the .emerald-popover class; its default slot is plain content with no slot props.
Accessibility
The popover is native. EmPopoverContent carries the popover attribute and EmPopoverActivator points at it with popovertarget, so opening, closing, light dismiss and Escape are the browser’s behavior rather than script — the same machinery a plain HTML popover gets.
ARIA attributes
| Attribute | Value | Element |
|---|---|---|
popovertarget | Panel id | Activator |
aria-expanded | true / false | Activator |
aria-controls | Panel id | Activator |
popover | '' (auto) | Content |
The panel carries no role of its own, deliberately — a popover can hold a menu, a listbox or plain prose, and only you know which. Give EmPopoverContent the role that matches what you put in it, or none when it is ordinary content.
Keyboard
| Key | Behavior |
|---|---|
| Enter, Space | Toggle the panel — native button activation of popovertarget |
| Escape | Close — native light dismiss |
When as renders something other than a native button, v0 adds role="button" and an Enter/Space keydown handler to the attrs bundle so the trigger stays operable; on the default native button neither is added, because the platform already provides both.
Trigger naming
The trigger’s accessible name is its content. With the default activator that is your slot text; with renderless it is whatever element you bound attrs to — an icon-only EmButton trigger still needs its ariaLabel, exactly as it would anywhere else.
Focus
Nothing traps or moves focus — the panel is not modal, and no focus management is scripted. Focus stays on the trigger when the panel opens, and aria-expanded flipping to true is what tells a screen reader something appeared. Keep that in mind when deciding what belongs in a popover: a quick, glanceable panel is the fit; a task that deserves captured focus and an explicit dismissal is EmDialog’s job.