Popover
An interactive, non-modal popup built on the native Popover API with focus management, ARIA wiring, and anchored positioning.
<pe-popover data-popover-offset="8">
<button type="button" class="Trigger" data-popover-trigger>Account</button>
<div class="Popup" data-popover-content hidden>
<span class="Arrow" data-popover-arrow></span>
<div class="Header">
<h2 class="Title" data-popover-title>Account</h2>
<button type="button" class="Close" data-popover-close>Close</button>
</div>
<p class="Description" data-popover-description>Manage your current session.</p>
</div>
</pe-popover>.Trigger {
min-height: 2rem;
padding: 0 0.75rem;
border: 1px solid oklch(14.5% 0 0);
border-radius: 0;
background: transparent;
color: inherit;
cursor: pointer;
@media (prefers-color-scheme: dark) {
border-color: white;
}
@media (hover: hover) {
&:hover {
background: oklch(92% 0 0);
@media (prefers-color-scheme: dark) {
background: oklch(26.9% 0 0);
}
}
}
}
pe-popover {
display: contents;
}
.Popup {
width: min(20rem, calc(100vw - 1rem));
padding: 0.75rem;
border: 1px solid oklch(14.5% 0 0);
border-radius: 0;
overflow: visible;
background: oklch(97% 0 0);
color: oklch(14.5% 0 0);
box-shadow: 0 0.75rem 2rem oklch(0% 0 0 / 18%);
@media (prefers-color-scheme: dark) {
border-color: white;
background: oklch(20% 0 0);
color: white;
box-shadow: 0 0.75rem 2rem oklch(0% 0 0 / 35%);
}
&.Nested {
width: min(18rem, calc(100vw - 1rem));
}
}
.Popup .Header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.Popup .Title {
margin: 0;
font-size: 1rem;
font-weight: 500;
line-height: 1.25rem;
}
.Popup .Description {
margin: 0.5rem 0 0.75rem;
line-height: 1.5;
}
.Popup .Close {
min-height: 2rem;
padding: 0 0.5rem;
border: 1px solid oklch(14.5% 0 0);
border-radius: 0;
background: transparent;
color: inherit;
cursor: pointer;
@media (prefers-color-scheme: dark) {
border-color: white;
}
}
.Popup .Arrow {
display: block;
width: 12px;
height: 6px;
overflow: clip;
pointer-events: none;
&::before {
position: absolute;
bottom: 0;
left: 50%;
box-sizing: border-box;
width: calc(6px * sqrt(2));
height: calc(6px * sqrt(2));
content: "";
transform: translate(-50%, 50%) rotate(45deg);
border: 1px solid oklch(14.5% 0 0);
background: oklch(97% 0 0);
@media (prefers-color-scheme: dark) {
border-color: white;
background: oklch(20% 0 0);
}
}
&[data-side="top"] {
bottom: -6px;
rotate: 180deg;
}
&[data-side="bottom"] {
top: -6px;
rotate: 0deg;
}
&[data-side="left"] {
right: -9px;
rotate: 90deg;
}
&[data-side="right"] {
left: -9px;
rotate: -90deg;
}
}
Use Popover for interactive, non-modal content. Use Tooltip for short hints and Dialog for modal tasks. Keep hidden on authored content and provide an accessible name.
Anatomy
Section titled “Anatomy”| Part | Selector | Role |
|---|---|---|
| Host | <pe-popover> |
Lifecycle host for one popup |
| Trigger | [data-popover-trigger] |
Toggles the popup |
| Content | [data-popover-content] |
Native manual popover with role="dialog" |
| Arrow | [data-popover-arrow] |
Optional decorative direct child of content; receives positioning state and coordinates |
| Title | [data-popover-title] |
Accessible name source |
| Description | [data-popover-description] |
Accessible description source |
| Close | [data-popover-close] |
Closes the popup |
External trigger
Section titled “External trigger”<button type="button" data-popover-trigger="filters-popover">Filters</button>
<pe-popover id="filters-popover"> <div data-popover-content hidden aria-label="Filters"> ... </div></pe-popover>Use data-popover-initial-focus to override the first focusable target.
Nested popover
Section titled “Nested popover”Keep the child host inside the parent content. Native popover nesting and the managed stack ensure that Escape or an outside press closes the child before its parent.
<pe-popover>
<button type="button" class="Trigger" data-popover-trigger>Open parent</button>
<div class="Popup" data-popover-content hidden>
<div class="Header">
<h2 class="Title" data-popover-title>Parent popover</h2>
<button type="button" class="Close" data-popover-close>Close parent</button>
</div>
<pe-popover data-popover-side="right" data-popover-offset="8">
<button type="button" class="Trigger" data-popover-trigger>Open child</button>
<div class="Popup Nested" data-popover-content hidden>
<div class="Header">
<h2 class="Title" data-popover-title>Child popover</h2>
<button type="button" class="Close" data-popover-close>Close child</button>
</div>
</div>
</pe-popover>
</div>
</pe-popover>.Trigger {
min-height: 2rem;
padding: 0 0.75rem;
border: 1px solid oklch(14.5% 0 0);
border-radius: 0;
background: transparent;
color: inherit;
cursor: pointer;
@media (prefers-color-scheme: dark) {
border-color: white;
}
@media (hover: hover) {
&:hover {
background: oklch(92% 0 0);
@media (prefers-color-scheme: dark) {
background: oklch(26.9% 0 0);
}
}
}
}
pe-popover {
display: contents;
}
.Popup {
width: min(20rem, calc(100vw - 1rem));
padding: 0.75rem;
border: 1px solid oklch(14.5% 0 0);
border-radius: 0;
overflow: visible;
background: oklch(97% 0 0);
color: oklch(14.5% 0 0);
box-shadow: 0 0.75rem 2rem oklch(0% 0 0 / 18%);
@media (prefers-color-scheme: dark) {
border-color: white;
background: oklch(20% 0 0);
color: white;
box-shadow: 0 0.75rem 2rem oklch(0% 0 0 / 35%);
}
&.Nested {
width: min(18rem, calc(100vw - 1rem));
}
}
.Popup .Header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.Popup .Title {
margin: 0;
font-size: 1rem;
font-weight: 500;
line-height: 1.25rem;
}
.Popup .Description {
margin: 0.5rem 0 0.75rem;
line-height: 1.5;
}
.Popup .Close {
min-height: 2rem;
padding: 0 0.5rem;
border: 1px solid oklch(14.5% 0 0);
border-radius: 0;
background: transparent;
color: inherit;
cursor: pointer;
@media (prefers-color-scheme: dark) {
border-color: white;
}
}
.Popup .Arrow {
display: block;
width: 12px;
height: 6px;
overflow: clip;
pointer-events: none;
&::before {
position: absolute;
bottom: 0;
left: 50%;
box-sizing: border-box;
width: calc(6px * sqrt(2));
height: calc(6px * sqrt(2));
content: "";
transform: translate(-50%, 50%) rotate(45deg);
border: 1px solid oklch(14.5% 0 0);
background: oklch(97% 0 0);
@media (prefers-color-scheme: dark) {
border-color: white;
background: oklch(20% 0 0);
}
}
&[data-side="top"] {
bottom: -6px;
rotate: 180deg;
}
&[data-side="bottom"] {
top: -6px;
rotate: 0deg;
}
&[data-side="left"] {
right: -9px;
rotate: 90deg;
}
&[data-side="right"] {
left: -9px;
rotate: -90deg;
}
}
API Reference
Section titled “API Reference”Structure attributes
Section titled “Structure attributes”| Name | Description |
|---|---|
data-popover-trigger | Marks a trigger. Empty value targets an internal popover. A host id string targets an external popover. |
data-popover-content | The popover surface. Receives `popover="manual"` and positioning styles. |
data-popover-arrow | Marks the optional decorative arrow. Must be a direct child of data-popover-content. |
data-popover-close | Inside content: closes the popover when activated. |
data-popover-title | Primary label source for content `aria-labelledby`. |
data-popover-description | Description source for content `aria-describedby`. |
data-popover-initial-focus | Inside content: preferred focus target on open. Defaults to the first focusable element. |
Host API
Section titled “Host API”| Name | Type | Description |
|---|---|---|
isOpen | boolean (readonly) | Reads whether the popover is currently open. |
open(trigger?) | void | Opens and positions the popover for a trigger. |
close() | void | Closes the popover programmatically. |
toggle(trigger?) | void | Opens, closes, or changes the active trigger. |
Positioning attributes
Section titled “Positioning attributes”| Name | Description |
|---|---|
data-popover-side | Preferred side: top | right | bottom | left. Default: bottom. |
data-popover-align | Alignment: start | center | end. Default: center. |
data-popover-offset | Distance from the trigger in pixels. Default: 0. |
data-popover-align-offset | Cross-axis offset in pixels. Default: 0. |
data-popover-collision-padding | Viewport padding for flip and clamp. Default: 5. |
data-popover-arrow-padding | Minimum distance between an authored arrow and the content edges. Default: 5. |
data-state | On host/content, arrow, and triggers: "open" or "closed". With shared triggers, only the active trigger is open. |
data-side / data-align | Resolved placement on content and arrow after collision handling. |
data-uncentered | Present on the arrow when edge padding prevents it from centering on the trigger. |
Events
Section titled “Events”| Name | Description |
|---|---|
pe-popover:open | Fired on open. detail includes content, trigger, and reason. |
pe-popover:close | Fired on close. detail includes content, trigger, and reason. |
Close reasons: trigger, close-control, dismiss, escape, programmatic, native.
Styling
Section titled “Styling”Content is placed in the browser top layer with fixed inline coordinates. All visual styling remains yours.
.Popup { width: min(20rem, calc(100vw - 1rem)); border: 1px solid oklch(14.5% 0 0); border-radius: 0; overflow: visible; background: oklch(97% 0 0);}An optional [data-popover-arrow] must be a decorative direct child of the content. Set its size, shape, and side styles in CSS; include its size in data-popover-offset.
While open, the content surface also receives positioning CSS variables:
| Name | Description |
|---|---|
--anchor-width / --anchor-height |
Active trigger dimensions |
--available-width / --available-height |
Remaining viewport space on the resolved side |
--positioner-width / --positioner-height |
Current content dimensions (updated on resize) |
--transform-origin |
Anchor-relative origin for enter/exit transitions |
Content also receives data-starting-style and data-ending-style for enter and exit animations.