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 portal
  • data-popover-portal="false" – never use portal

Callbacks

Pass callback functions to handle popover events:

  • on_show – called when popover is shown
  • on_hide – called when popover is hidden
  • on_toggle – called after toggle (show or hide)

Callbacks receive the popover instance as an argument.