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 hidden class).
  • 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-right
  • data-popup-backdrop="false" – set to "false" to disable backdrop
  • data-popup-closable="false" – set to "false" to disable closing via outside click/Escape
  • data-popup-portal="true" – force portal rendering ("true", "false", or omit for auto)
  • data-popup-animation="false" – disable animation
  • data-popup-animation-duration="500" – animation duration in ms

These attributes take precedence over the global options passed to init_popups().