Back

Creating Accessible Popovers with Modern CSS & JS

Creating Accessible Popovers with Modern CSS & JS

Popovers present contextual information without disrupting the user’s workflow—but implementing them accessibly remains a challenge for many developers. Whether you’re modernizing legacy code or building a component library, understanding the difference between popovers, tooltips, and modals is crucial for creating the right user experience.

This article covers how to build accessible popovers using modern CSS and JavaScript, from dynamic positioning to keyboard navigation, while exploring native browser APIs that reduce complexity.

Key Takeaways

  • Popovers display rich, interactive content that persists until dismissed, unlike tooltips which show brief hints on hover
  • The native Popover API eliminates JavaScript complexity while providing built-in accessibility features
  • Proper focus management and ARIA attributes are essential for keyboard navigation
  • Dynamic positioning ensures popovers remain visible within the viewport boundaries

Understanding Popovers vs. Tooltips vs. Modals

Tooltips provide brief hints on hover, typically containing a single line of text. They disappear when users move their cursor away and cannot contain interactive elements.

Popovers display richer content—headers, paragraphs, buttons, or forms. They remain visible until explicitly dismissed, allowing users to interact with both the popover content and the page beneath.

Modals create a focused experience by making the background inert. Users must complete the modal interaction before returning to the main content.

Core Implementation Requirements

Dynamic Positioning Within the Viewport

Modern popovers must adapt to available screen space. When a popover would extend beyond the viewport edge, it should automatically reposition:

const positionPopover = (trigger, popover) => {
  const triggerRect = trigger.getBoundingClientRect()
  const popoverRect = popover.getBoundingClientRect()
  
  let top = triggerRect.bottom + 8
  let left = triggerRect.left
  
  // Flip to top if insufficient space below
  if (top + popoverRect.height > window.innerHeight) {
    top = triggerRect.top - popoverRect.height - 8
    popover.classList.add('popover--top')
  }
  
  // Adjust horizontal position
  if (left + popoverRect.width > window.innerWidth) {
    left = window.innerWidth - popoverRect.width - 16
  }
  
  popover.style.top = `${top}px`
  popover.style.left = `${left}px`
}

Arrow Alignment

CSS handles the visual arrow that points to the trigger element:

.popover::after {
  content: "";
  position: absolute;
  width: 12px;
  height: 12px;
  background: inherit;
  border: inherit;
  transform: rotate(45deg);
  top: -7px;
  left: 20px;
  border-bottom: 0;
  border-right: 0;
}

.popover--top::after {
  top: auto;
  bottom: -7px;
  transform: rotate(225deg);
}

Dismissal Mechanisms

Accessible popovers require multiple dismissal methods:

// Click outside
document.addEventListener('click', (e) => {
  if (!popover.contains(e.target) && !trigger.contains(e.target)) {
    closePopover()
  }
})

// ESC key
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && isPopoverOpen) {
    closePopover()
    trigger.focus() // Return focus to trigger
  }
})

The Native Popover API

The Popover API eliminates much JavaScript complexity:

<button popovertarget="my-popover">Open Info</button>

<div id="my-popover" popover>
  <h3>Additional Information</h3>
  <p>This popover requires no JavaScript for basic functionality.</p>
  <button popovertarget="my-popover">Close</button>
</div>

This native approach handles positioning, dismissal, and focus management automatically. For improved accessibility, combine it with the <dialog> element:

<dialog id="enhanced-popover" popover>
  <h2>Accessible Popover</h2>
  <p>Combining dialog with popover provides semantic meaning.</p>
  <button popovertarget="enhanced-popover">Close</button>
</dialog>

Comparing Library vs. Native Solutions

Traditional libraries like Popper.js offer extensive positioning algorithms but add 15-30KB to your bundle. The native Popover API provides:

  • Zero JavaScript for basic functionality
  • Built-in accessibility features
  • Automatic focus management
  • Browser-optimized positioning

For complex positioning requirements, libraries remain valuable. For standard use cases, native solutions reduce complexity significantly.

Essential Accessibility Considerations

ARIA Attributes

When building custom popovers without the native API:

<button 
  aria-expanded="false"
  aria-controls="custom-popover"
  aria-haspopup="dialog">
  Open Popover
</button>

<div 
  id="custom-popover"
  role="dialog"
  aria-labelledby="popover-title"
  aria-modal="false">
  <h2 id="popover-title">Popover Title</h2>
  <!-- Content -->
</div>

Focus Management

Proper focus order ensures keyboard users can navigate effectively:

const focusableElements = popover.querySelectorAll(
  'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
)

// Trap focus within popover
popover.addEventListener('keydown', (e) => {
  if (e.key === 'Tab') {
    const firstElement = focusableElements[0]
    const lastElement = focusableElements[focusableElements.length - 1]
    
    if (e.shiftKey && document.activeElement === firstElement) {
      e.preventDefault()
      lastElement.focus()
    } else if (!e.shiftKey && document.activeElement === lastElement) {
      e.preventDefault()
      firstElement.focus()
    }
  }
})

Preventing Background Scroll

CSS alone can prevent background scrolling when using the native API:

body:has(dialog[popover]:popover-open) {
  overflow: hidden;
}

Conclusion

Building accessible popovers requires balancing user needs with technical implementation. The native Popover API simplifies development while maintaining accessibility standards, though custom solutions remain necessary for complex interactions.

Focus on keyboard navigation, proper ARIA implementation, and clear dismissal patterns. Whether using native APIs or building custom components, accessibility must drive your implementation decisions—ensuring your popovers work for all users, regardless of how they interact with your interface.

FAQs

Use the native Popover API for standard implementations as it provides built-in accessibility and requires no JavaScript. Choose libraries like Popper.js only when you need complex positioning logic or must support older browsers that lack native API support.

Tooltips show brief text on hover and disappear automatically, requiring only simple ARIA labels. Popovers contain interactive elements, need focus management, multiple dismissal methods, and proper ARIA attributes including role dialog and aria-modal to ensure screen readers announce them correctly.

The Popover API has support in Chrome 114+, Edge 114+, and Safari 17+. Firefox support is in development. Always check current browser compatibility and provide fallbacks for unsupported browsers using feature detection before implementing in production.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay