The Popover API provides a standardized, built-in way to create popover overlays like modals, tooltips, and dropdown menus. This comprehensive guide covers everything you need to know about implementing native popovers.
What is the Popover API?
The Popover API enables the creation of UI elements that appear on top of other content. It’s designed to replace common patterns like custom modal implementations with a native, browser-optimized solution.
<!-- Basic popover with HTML only -->
<button popovertarget="my-popover">Open Popover</button>
<div id="my-popover" popover>
<p>This is a native popover!</p>
</div>
Key Features
- Declarative - Can work with just HTML attributes
- Accessible - Built-in focus management and keyboard support
- Light dismiss - Click outside to close
- Backdrop support - Optional dark overlay
- Nested popovers - Support for dropdown menus
Basic Usage
Simple Popover
<!-- Trigger -->
<button popovertarget="info-popover">
Show Information
</button>
<!-- Popover content -->
<div id="info-popover" popover>
<h3>Information</h3>
<p>This is a popover message.</p>
</div>
Popover with Manual Dismiss
<button popovertarget="menu" popovertargetaction="toggle">
Menu
</button>
<div id="menu" popover="manual">
<ul>
<li><a href="#">Home</a></li>
<li><a href="#">About</a></li>
<li><a href="#">Contact</a></li>
</ul>
</div>
const menu = document.getElementById('menu');
// Show
menu.showPopover();
// Hide
menu.hidePopover();
// Toggle
menu.togglePopover();
// Check state
console.log(menu.matches(':popover-open'));
Popover Types
Auto Popover (Default)
<!-- Auto: closes when clicking outside or pressing Escape -->
<div id="auto-popover" popover>
<p>Click outside or press Escape to close</p>
</div>
Manual Popover
<!-- Manual: only closes when explicitly dismissed -->
<div id="manual-popover" popover="manual">
<p>Only explicit close button works</p>
<button onclick="this.closest('[popover]').hidePopover()">Close</button>
</div>
// Manual popover requires explicit action
const popover = document.getElementById('manual-popover');
popover.showPopover();
// Won't auto-dismiss - requires:
popover.hidePopover();
Styling Popovers
Basic Styling
[popover] {
/* Default styles */
position: fixed;
inset: 0;
width: fit-content;
height: fit-content;
margin: auto;
padding: 1rem;
background: white;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Centered positioning */
[popover]:popover-center {
inset: 0;
margin: auto;
}
/* Top positioning */
[popover]:popover-top {
inset: 0 auto auto 50%;
transform: translateX(-50%);
}
Custom Popover Styles
.card-popover[popover] {
padding: 0;
border: none;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
max-width: 320px;
background: #fff;
}
.card-popover-header {
padding: 1rem;
border-bottom: 1px solid #eee;
}
.card-popover-body {
padding: 1rem;
}
.card-popover-footer {
padding: 1rem;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
Animated Popovers
[popover] {
transition: opacity 0.2s ease, transform 0.2s ease;
}
[popover]:not(:popover-open) {
opacity: 0;
pointer-events: none;
}
/* Opening animation */
[popover]:popover-open {
animation: popover-show 0.2s ease-out;
}
@keyframes popover-show {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
The Popover Backdrop
Adding a Backdrop
/* The backdrop pseudo-element */
::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
/* Different backdrop styles */
.modal-backdrop::backdrop {
background: rgba(0, 0, 0, 0.7);
}
.light-backdrop::backdrop {
background: rgba(255, 255, 255, 0.8);
}
.blur-backdrop::backdrop {
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
}
Animated Backdrop
::backdrop {
transition: opacity 0.3s ease;
opacity: 0;
}
[popover]:popover-open::backdrop {
opacity: 1;
}
Modal Dialogs
Modal Implementation
<button popovertarget="login-modal">
Open Login
</button>
<div id="login-modal" popover class="modal">
<div class="modal-content">
<header>
<h2>Sign In</h2>
<button popovertarget="login-modal" popovertargetaction="hide">
ร
</button>
</header>
<form method="dialog">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" required>
</div>
<button type="submit">Sign In</button>
</form>
</div>
</div>
.modal[popover] {
width: 100%;
max-width: 400px;
padding: 0;
border: none;
}
.modal-content {
padding: 1.5rem;
}
.modal header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.modal form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal button[type="submit"] {
margin-top: 0.5rem;
}
Using method=“dialog”
const modal = document.getElementById('login-modal');
modal.addEventListener('close', (e) => {
console.log('Modal closed');
});
modal.addEventListener('cancel', (e) => {
console.log('Modal cancelled (Escape pressed)');
});
// Submit handling
modal.querySelector('form').addEventListener('submit', (e) => {
e.preventDefault();
// Get form data
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
// Close with return value
modal.close(JSON.stringify(data));
});
Tooltips
Native Tooltip Pattern
<button
class="tooltip-trigger"
popovertarget="tooltip"
popovertargetaction="toggle"
aria-describedby="tooltip"
>
Hover me
</button>
<div id="tooltip" popover class="tooltip">
This is helpful information!
</div>
.tooltip[popover] {
position: anchor(--trigger); /* Future CSS - use JavaScript for now */
padding: 0.5rem 0.75rem;
background: #333;
color: white;
border-radius: 4px;
font-size: 0.875rem;
margin: 0;
}
/* Manual positioning */
.tooltip {
position: fixed;
bottom: calc(anchor(--trigger) + 10px);
left: 50%;
transform: translateX(-50%);
}
/* Arrow */
.tooltip::before {
content: '';
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #333;
}
JavaScript Positioning
function positionTooltip(trigger, tooltip) {
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
tooltip.style.position = 'fixed';
tooltip.style.left = `${triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2)}px`;
tooltip.style.top = `${triggerRect.bottom + 8}px`;
}
// Show with positioning
trigger.addEventListener('mouseenter', () => {
tooltip.showPopover();
positionTooltip(trigger, tooltip);
});
Dropdown Menus
Nested Dropdown
<div class="dropdown-container">
<button
popovertarget="dropdown-menu"
popovertargetaction="toggle"
>
Menu
</button>
<div id="dropdown-menu" popover class="dropdown">
<button popovertarget="submenu" popovertargetaction="toggle">
Submenu โ
</button>
<div id="submenu" popover class="dropdown submenu">
<a href="#">Item 1</a>
<a href="#">Item 2</a>
<a href="#">Item 3</a>
</div>
</div>
</div>
.dropdown[popover] {
margin: 0;
padding: 0.5rem;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dropdown a,
.dropdown button {
display: block;
width: 100%;
padding: 0.5rem 1rem;
text-align: left;
text-decoration: none;
color: #333;
border: none;
background: none;
cursor: pointer;
}
.dropdown a:hover,
.dropdown button:hover {
background: #f5f5f5;
}
/* Position submenu */
.submenu {
position: fixed;
left: 100%;
top: 0;
}
JavaScript API
Popover Methods
const popover = document.getElementById('my-popover');
// Show the popover
popover.showPopover();
// Hide the popover
popover.hidePopover();
// Toggle the popover
popover.togglePopover();
// Check if open
console.log(popover.matches(':popover-open'));
Event Handling
const popover = document.getElementById('my-popover');
// Fired when popover is shown
popover.addEventListener('popovershow', (e) => {
console.log('Popover shown');
// Focus first input
popover.querySelector('input')?.focus();
});
// Fired when popover is hidden
popover.addEventListener('popoverhide', (e) => {
console.log('Popover hidden');
});
// Fired when popover is about to hide (cancelable)
popover.addEventListener('beforepopoverhide', (e) => {
if (!confirm('Are you sure you want to close?')) {
e.preventDefault();
}
});
// Fired when popover is dismissed (Escape or click outside)
popover.addEventListener('popoverdismiss', (e) => {
console.log('Popover dismissed');
});
Programmatic Control
// Close all open popovers
document.querySelectorAll('[popover]').forEach(p => {
if (p.matches(':popover-open')) {
p.hidePopover();
}
});
// Close with return value
document.getElementById('my-popover').close('success');
// Get return value
document.getElementById('my-popover').addEventListener('close', (e) => {
console.log(e.target.returnValue); // 'success'
});
Accessibility
Required ARIA Attributes
<!-- Button controls popover -->
<button
popovertarget="my-popover"
aria-expanded="false"
id="trigger"
>
Open
</button>
<!-- Popover labelled by trigger -->
<div
id="my-popover"
popover
role="dialog"
aria-labelledby="popover-title"
>
<h3 id="popover-title">Title</h3>
<p>Content</p>
<!-- Focus trap -->
<button popovertarget="my-popover" popovertargetaction="hide">
Close
</button>
</div>
// Update aria-expanded
const button = document.getElementById('trigger');
const popover = document.getElementById('my-popover');
popover.addEventListener('popovershow', () => {
button.setAttribute('aria-expanded', 'true');
});
popover.addEventListener('popoverhide', () => {
button.setAttribute('aria-expanded', 'false');
});
Focus Management
// Remember focus
const trigger = document.activeElement;
popover.addEventListener('popoverhide', () => {
// Return focus to trigger
trigger?.focus();
});
// Focus trap
popover.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
const focusable = popover.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
Browser Support and Polyfills
// Feature detection
const supportsPopover = HTMLElement.prototype.hasOwnProperty('popover');
if (!supportsPopover) {
// Load polyfill
import('https://unpkg.com/@odoe/popover-polyfill');
}
Fallback for Older Browsers
// Manual polyfill-like behavior
function initPopoverFallback() {
document.querySelectorAll('[popover]').forEach(popover => {
const toggle = document.querySelector(`[popovertarget="${popover.id}"]`);
if (!toggle) return;
// Show
toggle.addEventListener('click', () => {
popover.style.display = 'block';
});
// Close button
popover.querySelector('[popovertargetaction="hide"]')?.addEventListener('click', () => {
popover.style.display = 'none';
});
// Click outside
document.addEventListener('click', (e) => {
if (!popover.contains(e.target) && e.target !== toggle) {
popover.style.display = 'none';
}
});
});
}
Best Practices
Do: Use for User-Initiated Actions
<!-- Good: User clicks to open -->
<button popovertarget="menu">Menu</button>
<div popover id="menu">...</div>
<!-- Avoid: Automatically showing popover on load -->
<div popover>...</div>
Don’t: Nest Too Deeply
/* Limit nesting */
.dropdown-menu popover {
/* Can get complex */
}
/* Better: Use submenus sparingly */
Handle Mobile
/* Full-screen on mobile */
@media (max-width: 480px) {
[popover] {
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
}
}
External Resources
Conclusion
The Popover API provides a native, accessible way to create overlays without external libraries. Key points:
- Use
popoverattribute for auto-dismiss,popover="manual"for controlled dismissal - Style with
[popover]selector and::backdropfor overlays - Use
popovertargetandpopovertargetactionfor declarative control - Events:
popovershow,popoverhide,popoverdismiss - Add ARIA attributes for accessibility
Browser support: Chrome 114+, Edge 114+, Safari 17+, Firefox (behind flag). Use polyfill for broader support.
Comments