Skip to main content
โšก Calmops

Popover API: Native Modals, Tooltips, and Overlays

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;
}
<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);
});

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 popover attribute for auto-dismiss, popover="manual" for controlled dismissal
  • Style with [popover] selector and ::backdrop for overlays
  • Use popovertarget and popovertargetaction for 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