Popover
Floating, temporary container that displays contextual information or quick actions.
Basic HTML structure:
- A wrapper element with
data-popover. - A trigger element (first child or with
data-popover-trigger). - A content element with
data-popover-content– hidden by default. - The popover content has a max-width of
400px.
Vertical
With form
Usage
Include the JavaScript code in your project:
/**
* Popover Component
*
* Vanilla JS popover with fade-in/out animation and portal support.
* Optimized with requestAnimationFrame and passive event listeners.
*
* Features:
* - Portal support with auto-detection
* - Position relative to trigger element
* - Fade-in/out animation
* - Closable via click outside or Escape key
* - Auto-focus first input on show
* - Scroll position update when portal is active
*
* @example
* // HTML
* <div data-popover data-popover-side="bottom" data-popover-align="center">
* <button>Trigger</button>
* <div data-popover-content>Content</div>
* </div>
*
* // JS
* init_popovers();
*/
class Popover {
// Wrapper element (data-popover container)
#wrapper;
// Trigger element (button or first child)
#trigger;
// Content element (data-popover-content)
#content;
// Visibility state
#visible = false;
// Outside click handler
#outside_handler = null;
// Keydown handler (Escape key)
#keydown_handler = null;
// Original parent for portal restoration
#original_parent;
// Portal rendering flag
#portal_active = false;
// Animation frame ID for debouncing
#raf_id = null;
// Component options
#options;
/**
* Create a Popover instance
* @param {HTMLElement} el - Wrapper element with data-popover
* @param {Object} options - Configuration options
*/
constructor(el, options = {}) {
if (!el) return;
this.#wrapper = el;
this.#trigger = el.querySelector("[data-popover-trigger]") || el.firstElementChild;
this.#content = el.querySelector("[data-popover-content]");
if (!this.#trigger || !this.#content) return;
this.#original_parent = this.#wrapper.parentNode;
// Merge data attributes with passed options (data attributes have priority)
this.#options = {
side: el.dataset.popoverSide || options.side || "bottom",
align: el.dataset.popoverAlign || options.align || "center",
use_portal: el.dataset.popoverPortal === "true" ? true : el.dataset.popoverPortal === "false" ? false : options.use_portal,
animation_duration: 200,
on_show: options.on_show || (() => {}),
on_hide: options.on_hide || (() => {}),
on_toggle: options.on_toggle || (() => {})
};
// Support callback function names as strings (look up in window)
if (typeof this.#options.on_show === "string") {
this.#options.on_show = window[this.#options.on_show] || (() => {});
}
if (typeof this.#options.on_hide === "string") {
this.#options.on_hide = window[this.#options.on_hide] || (() => {});
}
if (typeof this.#options.on_toggle === "string") {
this.#options.on_toggle = window[this.#options.on_toggle] || (() => {});
}
this.#initialize();
}
/**
* Auto-detect if portal rendering is needed
* Checks parent elements for overflow, transform, or perspective styles
* @returns {boolean}
*/
#check_portal() {
if (typeof this.#options.use_portal === "boolean") return this.#options.use_portal;
let parent = this.#wrapper.parentElement;
while (parent && parent !== document.body) {
const style = getComputedStyle(parent);
if (/(hidden|auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY) ||
style.transform !== "none" || style.perspective !== "none") {
return true;
}
parent = parent.parentElement;
}
return false;
}
/**
* Calculate popover position based on side and align
* Uses getBoundingClientRect for accurate positioning
* @returns {{top: number, left: number}} Position coordinates
*/
#calculate_position() {
const trigger_rect = this.#trigger.getBoundingClientRect();
const content_width = this.#content.offsetWidth;
const content_height = this.#content.offsetHeight;
const gap = 8; // Gap between trigger and popover
let top, left;
// Set base position based on side
if (this.#options.side === "top") {
top = trigger_rect.top - content_height - gap;
} else if (this.#options.side === "bottom") {
top = trigger_rect.bottom + gap;
} else if (this.#options.side === "left") {
top = trigger_rect.top;
left = trigger_rect.left - content_width - gap;
} else { // right
top = trigger_rect.top;
left = trigger_rect.right + gap;
}
// Apply horizontal/vertical alignment
if (this.#options.side === "top" || this.#options.side === "bottom") {
// Horizontal alignment for top/bottom sides
if (this.#options.align === "start") {
left = trigger_rect.left;
} else if (this.#options.align === "center") {
left = trigger_rect.left + trigger_rect.width / 2;
} else { // end
left = trigger_rect.right - content_width;
}
} else {
// Vertical alignment for left/right sides
if (this.#options.align === "start") {
top = trigger_rect.bottom - content_height;
} else if (this.#options.align === "center") {
top = trigger_rect.top + trigger_rect.height / 2;
} else { // end
top = trigger_rect.top;
}
}
// Convert viewport coordinates to relative for non-portal mode
if (!this.#portal_active) {
const wrapper_rect = this.#wrapper.getBoundingClientRect();
top = top - wrapper_rect.top;
left = left - wrapper_rect.left;
}
return { top, left };
}
/**
* Apply position to content element using requestAnimationFrame
* Debounces rapid position updates during scroll/resize
*/
#apply_position() {
if (this.#raf_id) return; // Skip if already scheduled
this.#raf_id = requestAnimationFrame(() => {
const pos = this.#calculate_position();
this.#content.style.top = pos.top + "px";
this.#content.style.left = pos.left + "px";
this.#raf_id = null;
});
}
/**
* Initialize event listeners for trigger and keyboard
*/
#initialize() {
// Bind trigger click to toggle visibility
this.#trigger.addEventListener("click", (e) => {
e.stopPropagation();
this.toggle();
});
// Bind Escape key to hide popover
this.#keydown_handler = (e) => {
if (e.key === "Escape") {
this.hide();
}
};
}
/**
* Toggle popover visibility
*/
toggle() {
this.#visible ? this.hide() : this.show();
this.#options.on_toggle(this);
}
/**
* Show popover with fade-in animation
* - Determines portal mode
* - Appends to body if portal active
* - Calculates and applies position
* - Binds outside click and keyboard listeners
* - Focuses first input if present
*/
show() {
if (this.#visible) return;
this.#visible = true;
// Determine if portal rendering is needed
this.#portal_active = this.#check_portal();
// Set positioning mode
if (this.#portal_active) {
document.body.appendChild(this.#content);
this.#content.classList.add("fixed");
this.#content.classList.remove("absolute");
} else {
this.#content.classList.add("absolute");
this.#content.classList.remove("fixed");
}
// Apply base classes and start animation
this.#content.classList.add("z-50");
this.#content.classList.remove("hidden");
this.#content.classList.remove("opacity-0");
this.#content.classList.add("opacity-100");
void this.#content.offsetWidth; // Force reflow for animation
// Calculate and apply initial position
this.#apply_position();
// Bind scroll/resize listeners only for portal mode
if (this.#portal_active) {
window.addEventListener("scroll", this.#apply_position.bind(this), { passive: true, capture: true });
window.addEventListener("resize", this.#apply_position.bind(this), { passive: true });
}
// Bind outside click and keyboard listeners
this.#bind_outside_listener();
this.#bind_keydown_listener();
// Focus first input for accessibility
this.#focus_first_input();
// Call on_show callback
this.#options.on_show(this);
}
/**
* Focus the first focusable input element inside popover
*/
#focus_first_input() {
const first_input = this.#content.querySelector("input, textarea, select");
if (first_input) {
setTimeout(() => first_input.focus(), this.#options.animation_duration);
}
}
/**
* Hide popover with fade-out animation
* - Cancels pending animation frames
* - Starts fade-out transition
* - Cleans up after animation completes
*/
hide() {
if (!this.#visible) return;
this.#visible = false;
// Cancel pending position updates
if (this.#raf_id) {
cancelAnimationFrame(this.#raf_id);
this.#raf_id = null;
}
// Start fade-out animation
this.#content.classList.remove("opacity-100");
this.#content.classList.add("opacity-0");
// Cleanup after animation duration
setTimeout(() => {
if (!this.#visible) {
this.#content.classList.add("hidden");
this.#content.classList.remove("fixed", "absolute", "z-50");
this.#content.style.top = "";
this.#content.style.left = "";
// Return content to original parent if portal was used
if (this.#portal_active) {
this.#original_parent.appendChild(this.#content);
this.#portal_active = false;
}
}
}, this.#options.animation_duration);
// Unbind event listeners
this.#unbind_outside_listener();
this.#unbind_keydown_listener();
// Call on_hide callback
this.#options.on_hide(this);
}
/**
* Bind outside click listener to close popover
*/
#bind_outside_listener() {
this.#outside_handler = (e) => {
if (!this.#wrapper.contains(e.target)) {
this.hide();
}
};
document.addEventListener("click", this.#outside_handler, true);
}
/**
* Unbind outside click listener
*/
#unbind_outside_listener() {
if (this.#outside_handler) {
document.removeEventListener("click", this.#outside_handler, true);
this.#outside_handler = null;
}
}
/**
* Bind Escape key listener to close popover
*/
#bind_keydown_listener() {
document.addEventListener("keydown", this.#keydown_handler);
}
/**
* Unbind Escape key listener
*/
#unbind_keydown_listener() {
if (this.#keydown_handler) {
document.removeEventListener("keydown", this.#keydown_handler);
}
}
/**
* Get current configuration options
* @returns {Object} Options copy
*/
get_options() {
return { ...this.#options };
}
/**
* Update on_show callback
* @param {Function} cb - New callback function
*/
update_on_show(cb) { this.#options.on_show = cb; }
/**
* Update on_hide callback
* @param {Function} cb - New callback function
*/
update_on_hide(cb) { this.#options.on_hide = cb; }
/**
* Update on_toggle callback
* @param {Function} cb - New callback function
*/
update_on_toggle(cb) { this.#options.on_toggle = cb; }
}
/**
* Initialize all popover elements on the page
* @param {Object} options - Global options (can be overridden per popover)
*/
const init_popovers = (options = {}) => {
window.__popover_instances = window.__popover_instances || new Map();
document.querySelectorAll("[data-popover]").forEach(el => {
const id = el.dataset.popoverId;
const opts = {
side: el.dataset.popoverSide || options.side || "bottom",
align: el.dataset.popoverAlign || options.align || "center",
use_portal: el.dataset.popoverPortal === "true" ? true : el.dataset.popoverPortal === "false" ? false : options.use_portal,
on_show: options.on_show,
on_hide: options.on_hide,
on_toggle: options.on_toggle
};
const popover = new Popover(el, opts);
if (id) {
window.__popover_instances.set(id, popover);
}
});
};
// Expose globally for external access
window.init_popovers = init_popovers;
Then initialise all popovers on the page:
init_popovers();
You can pass global options directly to the function – see the Params section.
Auto‑initialisation
Add this at the end of your popover.js file:
document.addEventListener("DOMContentLoaded", () => init_popovers());
Then link the script in your HTML:
<script src="popover.js"></script>
Params
The init_popovers() function accepts an optional configuration object:
init_popovers({
side: "bottom",
align: "start",
use_portal: undefined,
on_show: () => {},
on_hide: () => {},
on_toggle: () => {},
});
Data attributes
Override options per popover using data attributes on the wrapper element:
data-popover-side– one of:top,right,bottom,left(default:bottom)data-popover-align– one of:start,center,end(default:center)data-popover-portal– force portal rendering:"true","false", or omit for auto-detection
These attributes take precedence over the global options passed to init_popovers().
Portal support
The popover uses portal rendering by default when needed (e.g., when parent elements have overflow, transform, or perspective styles). This ensures the popover appears above all other content and follows the viewport during scrolling.
To manually control portal behavior:
data-popover-portal="true"– always use portaldata-popover-portal="false"– never use portal
Callbacks
Pass callback functions to handle popover events:
on_show– called when popover is shownon_hide– called when popover is hiddenon_toggle– called after toggle (show or hide)
Callbacks receive the popover instance as an argument.