Popup
Floating content triggered by user interaction.
Basic HTML structure:
- A trigger element with
data-popup-target="ID". - A popup element with the corresponding
id. - The popup is hidden by default (add the
hiddenclass). - Inside the popup, you can place any content – buttons with
data-popup-hide="ID"will close it.
Usage
Include the JavaScript code in your project:
/**
* Popup Component
*
* Vanilla JS popup/modal with portal, backdrop, animation, and flexible positioning.
* TailwindCSS v4 classes. Supports data attributes for configuration.
*
* Naming: snake_case
*
* Features:
* - Portal support
* - Animation with custom duration
* - Backdrop with configurable classes
* - Closable via click outside or Escape
* - Data attributes override options
*/
class Popup {
#el;
#options;
#visible = false;
#original_parent;
#portal_active = false;
#backdrop_el = null;
#outside_handler = null;
#keydown_handler = null;
constructor(el, options = {}) {
if (!el) return;
this.#el = el;
this.#original_parent = el.parentNode;
const default_duration = 300;
this.#options = {
placement: "center",
backdrop: true,
backdrop_classes: "fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 z-40 transition-opacity",
closable: true,
use_portal: undefined,
animation: true,
animation_duration: default_duration,
on_show: () => {},
on_hide: () => {},
on_toggle: () => {},
...options
};
const data_duration = el.dataset.popupAnimationDuration;
if (data_duration) this.#options.animation_duration = parseInt(data_duration, 10) || default_duration;
this.#initialize();
}
// Initialize element
#initialize() {
this.#el.classList.add(
"hidden",
"fixed",
"inset-0",
"z-50",
"flex",
"items-center",
"justify-center"
);
this.#el.setAttribute("aria-hidden", "true");
this.#apply_placement();
}
// Apply flex alignment based on placement
#apply_placement() {
const map = {
"top-left": ["justify-start", "items-start"],
"top-center": ["justify-center", "items-start"],
"top-right": ["justify-end", "items-start"],
"center-left": ["justify-start", "items-center"],
"center": ["justify-center", "items-center"],
"center-right": ["justify-end", "items-center"],
"bottom-left": ["justify-start", "items-end"],
"bottom-center": ["justify-center", "items-end"],
"bottom-right": ["justify-end", "items-end"]
};
const classes = map[this.#options.placement] || map.center;
this.#el.classList.add(...classes);
}
// Auto-detect if portal should be used
#check_portal() {
if (typeof this.#options.use_portal === "boolean") return this.#options.use_portal;
let parent = this.#el.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;
}
// Create backdrop element
#show_backdrop() {
if (!this.#options.backdrop) return;
const backdrop = document.createElement("div");
backdrop.className = this.#options.backdrop_classes + " opacity-0";
document.body.appendChild(backdrop);
requestAnimationFrame(() => backdrop.classList.add("opacity-100"));
this.#backdrop_el = backdrop;
}
// Remove backdrop element
#hide_backdrop() {
if (!this.#backdrop_el) return;
this.#backdrop_el.classList.remove("opacity-100");
this.#backdrop_el.classList.add("opacity-0");
setTimeout(() => {
this.#backdrop_el?.remove();
this.#backdrop_el = null;
}, this.#options.animation_duration);
}
// Setup outside click and Escape key
#bind_listeners() {
if (!this.#options.closable) return;
this.#outside_handler = (e) => {
if (e.target === this.#el || e.target === this.#backdrop_el) this.hide();
};
this.#keydown_handler = (e) => {
if (e.key === "Escape") this.hide();
};
document.addEventListener("click", this.#outside_handler, true);
document.addEventListener("keydown", this.#keydown_handler);
}
#unbind_listeners() {
if (this.#outside_handler) document.removeEventListener("click", this.#outside_handler, true);
if (this.#keydown_handler) document.removeEventListener("keydown", this.#keydown_handler);
this.#outside_handler = null;
this.#keydown_handler = null;
}
// Toggle popup
toggle() {
this.#visible ? this.hide() : this.show();
this.#options.on_toggle(this);
}
// Show popup
show() {
if (this.#visible) return;
this.#visible = true;
this.#portal_active = this.#check_portal();
if (this.#portal_active) {
setTimeout(() => document.body.appendChild(this.#el), 4);
}
this.#show_backdrop();
this.#bind_listeners();
this.#el.classList.remove("hidden");
this.#el.classList.add("flex");
this.#el.removeAttribute("aria-hidden");
this.#el.setAttribute("aria-modal", "true");
this.#el.setAttribute("role", "dialog");
document.body.classList.add("overflow-hidden");
if (this.#options.animation) this.#animate_show();
this.#options.on_show(this);
}
// Animate showing
#animate_show() {
const dur = this.#options.animation_duration;
this.#el.style.transitionDuration = dur + "ms";
this.#el.classList.add("opacity-0", "scale-95", "transition", "ease-out");
void this.#el.offsetWidth;
this.#el.classList.remove("opacity-0", "scale-95");
this.#el.classList.add("opacity-100", "scale-100");
}
// Hide popup
hide() {
if (!this.#visible) return;
this.#visible = false;
document.body.classList.remove("overflow-hidden");
this.#unbind_listeners();
this.#hide_backdrop();
if (this.#options.animation) {
this.#animate_hide();
} else {
this.#cleanup_hide();
}
this.#options.on_hide(this);
}
// Animate hiding
#animate_hide() {
const dur = this.#options.animation_duration;
this.#el.style.transitionDuration = dur + "ms";
this.#el.classList.remove("opacity-100", "scale-100");
this.#el.classList.add("opacity-0", "scale-95");
setTimeout(() => this.#cleanup_hide(), dur);
}
// Reset element after hide
#cleanup_hide() {
this.#el.classList.add("hidden");
this.#el.classList.remove("flex", "transition", "ease-out");
this.#el.setAttribute("aria-hidden", "true");
this.#el.removeAttribute("aria-modal");
this.#el.removeAttribute("role");
if (this.#portal_active) {
this.#original_parent.appendChild(this.#el);
this.#portal_active = false;
}
}
// Callback setters
update_on_show(cb) { this.#options.on_show = cb; }
update_on_hide(cb) { this.#options.on_hide = cb; }
update_on_toggle(cb) { this.#options.on_toggle = cb; }
}
/** Initialize all popups and bind triggers */
const init_popups = (options = {}) => {
window.__popup_instances = window.__popup_instances || new Map();
// Bind trigger elements
document.querySelectorAll("[data-popup-target]").forEach(trigger => {
const id = trigger.getAttribute("data-popup-target");
const el = document.getElementById(id);
if (!el) return;
if (!window.__popup_instances.has(id)) {
const opts = {
placement: trigger.dataset.popupPlacement || "center",
backdrop: trigger.dataset.popupBackdrop !== "false",
closable: trigger.dataset.popupClosable !== "false",
use_portal: trigger.dataset.popupPortal === "true" ? true : trigger.dataset.popupPortal === "false" ? false : undefined,
animation: trigger.dataset.popupAnimation !== "false",
animation_duration: parseInt(trigger.dataset.popupAnimationDuration, 10) || 300,
...options
};
const popup = new Popup(el, opts);
window.__popup_instances.set(id, popup);
}
const popup = window.__popup_instances.get(id);
trigger.addEventListener("click", () => popup.toggle());
});
// Bind show/hide buttons globally
document.addEventListener("click", e => {
const show_btn = e.target.closest("[data-popup-show]");
const hide_btn = e.target.closest("[data-popup-hide]");
if (show_btn) {
const id = show_btn.getAttribute("data-popup-show");
window.__popup_instances.get(id)?.show();
}
if (hide_btn) {
const id = hide_btn.getAttribute("data-popup-hide");
window.__popup_instances.get(id)?.hide();
}
});
};
// Expose globally
window.init_popups = init_popups;
Then initialise all popups on the page:
init_popups();
You can pass global options directly to the function – see the Params section.
Auto‑initialisation
Add this at the end of your popup.js file:
document.addEventListener("DOMContentLoaded", () => init_popups());
Then link the script in your HTML:
<script src="popup.js"></script>
Params
The init_popups() function accepts an optional configuration object:
init_popups({
// Alignment of the popup:
placement: "center", // 'top-left', 'top-center', 'top-right', 'center-left', 'center', 'center-right', 'bottom-left', 'bottom-center', 'bottom-right'
backdrop: true, // Whether to show a backdrop behind the popup
backdrop_classes: "fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 z-40 transition-opacity", // Tailwind classes for the backdrop element
closable: true, // Allow closing the popup by clicking outside or pressing Escape
use_portal: undefined, // Force portal: true | false | undefined (auto-detect based on parent overflow/transform)
animation: true, // Enable or disable show/hide animation
animation_duration: 300, // Duration of the animation in milliseconds
on_show: () => {}, // Callback executed when the popup is shown
on_hide: () => {}, // Callback executed when the popup is hidden
on_toggle: () => {}, // Callback executed when the popup is toggled (show/hide)
});
Data attributes
Override options per popup using data attributes on the trigger element:
data-popup-placement="top-left"– one of:top-left,top-center,top-right,center-left,center,center-right,bottom-left,bottom-center,bottom-rightdata-popup-backdrop="false"– set to"false"to disable backdropdata-popup-closable="false"– set to"false"to disable closing via outside click/Escapedata-popup-portal="true"– force portal rendering ("true","false", or omit for auto)data-popup-animation="false"– disable animationdata-popup-animation-duration="500"– animation duration in ms
These attributes take precedence over the global options passed to init_popups().