Dropdown

Expandable menu of selectable options.

Hover

Trigger the dropdown on hover by adding data-dropdown-trigger="hover" to the button. The menu will appear after a short delay (configurable) and stay open while hovering over the menu itself.

Checkbox

Dropdowns can contain checkboxes for multi‑select options.

Usage

Include the JavaScript code in your project:

class Dropdown {
  #trigger;
  #menu;
  #options;
  #visible = false;
  #hover_timer = null;
  #original_parent = null;
  #using_portal = false;
  #bound_reposition = null;
  #outside_click_handler = null;

  constructor(trigger, menu, options = {}) {
    // Elements
    this.#trigger = trigger;
    this.#menu = menu;
    this.#original_parent = menu.parentNode;
    this.#bound_reposition = this.#set_position.bind(this);

    // Default + user options
    this.#options = {
      placement: "bottom", // 'top' | 'bottom' | 'left' | 'right'
      trigger_type: "click", // 'click' | 'hover'
      offset_skidding: 0, // X offset
      offset_distance: 10, // Y offset
      delay: 300, // hover delay
      ignore_click_outside_class: false, // ignore outside clicks for this class
      use_portal: undefined, // true | false | undefined (auto)
      on_show: () => {},
      on_hide: () => {},
      on_toggle: () => {},
      ...options,
    };

    this.#init();
  }

  /** Initialize dropdown */
  #init() {
    if (!this.#trigger || !this.#menu) return;

    this.#menu.classList.add("hidden");
    this.#menu.setAttribute("aria-hidden", "true");
    this.#setup_triggers();
  }

  /** Setup event listeners */
  #setup_triggers() {
    const type = this.#options.trigger_type;

    if (type === "click") {
      this.#trigger.addEventListener("click", () => this.toggle());
    }

    if (type === "hover") {
      const delay = this.#options.delay;

      this.#trigger.addEventListener("mouseenter", () => {
        clearTimeout(this.#hover_timer);
        this.#hover_timer = setTimeout(() => this.show(), delay);
      });

      this.#trigger.addEventListener("mouseleave", () => {
        this.#hover_timer = setTimeout(() => this.hide(), delay);
      });

      this.#menu.addEventListener("mouseenter", () => clearTimeout(this.#hover_timer));
      this.#menu.addEventListener("mouseleave", () => {
        this.#hover_timer = setTimeout(() => this.hide(), delay);
      });
    }
  }

  /** Check whether to use portal (<body> placement) */
  #should_use_portal() {
    const user_setting = this.#options.use_portal;
    if (typeof user_setting === "boolean") return user_setting;

    // Auto-detect layout context (overflow/transform)
    let el = this.#trigger.parentElement;
    while (el && el !== document.body) {
      const style = getComputedStyle(el);
      if (
        /(hidden|auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY) ||
        style.transform !== "none" ||
        style.perspective !== "none"
      ) {
        return true;
      }
      el = el.parentElement;
    }
    return false;
  }

  /** Attach global click listener for outside clicks */
  #listen_outside_clicks() {
    this.#outside_click_handler = (e) => {
      const clicked = e.target;
      const ignore_class = this.#options.ignore_click_outside_class;
      const ignored =
        ignore_class &&
        [...document.querySelectorAll(`.${ignore_class}`)].some((el) => el.contains(clicked));

      if (
        this.#menu.contains(clicked) &&
        clicked.dataset.dropdownHide === "true"
      ) {
        this.hide();
      } else if (
        !this.#trigger.contains(clicked) &&
        !this.#menu.contains(clicked) &&
        !ignored &&
        this.#visible
      ) {
        this.hide();
      }
    };

    document.addEventListener("click", this.#outside_click_handler, true);
  }

  /** Remove global click listener */
  #remove_outside_click_listener() {
    document.removeEventListener("click", this.#outside_click_handler, true);
  }

  /** Positioning logic with auto-flip */
  #set_position() {
    if (!this.#trigger || !this.#menu) return;

    const trigger_rect = this.#trigger.getBoundingClientRect();

    // Measure dropdown (temporarily visible)
    const was_hidden =
      this.#menu.classList.contains("hidden") || this.#menu.style.display === "none";

    if (was_hidden) {
      Object.assign(this.#menu.style, { visibility: "hidden", display: "block" });
    }

    const menu_rect = this.#menu.getBoundingClientRect();
    const height = menu_rect.height || 200;
    const width = menu_rect.width;
    const { innerHeight: inner_height } = window;

    if (was_hidden) {
      Object.assign(this.#menu.style, { visibility: "", display: "" });
    }

    const scroll_y = window.scrollY || document.documentElement.scrollTop;
    const scroll_x = window.scrollX || document.documentElement.scrollLeft;

    let { placement, offset_skidding: dx, offset_distance: dy } = this.#options;
    let x = trigger_rect.left + dx + scroll_x;
    let y = trigger_rect.bottom + dy + scroll_y;

    // Auto-flip
    if (placement === "bottom") {
      const space_below = inner_height - trigger_rect.bottom;
      if (space_below < height + dy) placement = "top";
    } else if (placement === "top") {
      const space_above = trigger_rect.top;
      if (space_above < height + dy) placement = "bottom";
    }

    switch (placement) {
      case "top":
        y = trigger_rect.top - height - dy + scroll_y;
        break;
      case "left":
        x = trigger_rect.left - width - dy + scroll_x;
        y = trigger_rect.top + scroll_y + dx;
        break;
      case "right":
        x = trigger_rect.right + dy + scroll_x;
        y = trigger_rect.top + scroll_y + dx;
        break;
      default:
        y = trigger_rect.bottom + dy + scroll_y;
    }

    Object.assign(this.#menu.style, {
      position: "absolute",
      left: `${x}px`,
      top: `${y}px`,
      zIndex: "1000",
    });
  }

  /** Toggle dropdown */
  toggle() {
    this.#visible ? this.hide() : this.show();
    this.#options.on_toggle(this);
  }

  /** Show dropdown */
  show() {
    if (this.#visible) return;
    this.#visible = true;

    const portal = this.#should_use_portal();
    this.#using_portal = portal;

    if (portal) {
      document.body.appendChild(this.#menu);
      this.#set_position();
      window.addEventListener("resize", this.#bound_reposition);
      window.addEventListener("scroll", this.#bound_reposition, true);
    } else {
      this.#set_position();
    }

    this.#menu.classList.remove("hidden");
    this.#menu.classList.add("block");
    this.#menu.removeAttribute("aria-hidden");
    this.#trigger.setAttribute("aria-expanded", "true");

    this.#listen_outside_clicks();
    this.#options.on_show(this);
  }

  /** Hide dropdown */
  hide() {
    if (!this.#visible) return;
    this.#visible = false;

    this.#menu.classList.remove("block");
    this.#menu.classList.add("hidden");
    this.#menu.setAttribute("aria-hidden", "true");
    this.#trigger.setAttribute("aria-expanded", "false");

    this.#remove_outside_click_listener();

    if (this.#using_portal) {
      window.removeEventListener("resize", this.#bound_reposition);
      window.removeEventListener("scroll", this.#bound_reposition, true);
      this.#original_parent.appendChild(this.#menu);
      this.#using_portal = false;
    }

    this.#options.on_hide(this);
  }

  /** Update callback functions dynamically */
  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 dropdowns on the page */
const init_dropdowns = (options = {}) => {
  document.querySelectorAll("[data-dropdown-toggle]").forEach((trigger) => {
    const id = trigger.getAttribute("data-dropdown-toggle");
    const menu = document.getElementById(id);

    trigger.setAttribute("aria-expanded", "false");

    if (!menu) {
      console.error(`Dropdown "${id}" not found.`);
      return;
    }

    const opt = {
      placement: trigger.dataset.dropdownPlacement || "bottom",
      trigger_type: trigger.dataset.dropdownTrigger || "click",
      offset_skidding: +trigger.dataset.dropdownOffsetSkidding || 0,
      offset_distance: +trigger.dataset.dropdownOffsetDistance || 10,
      delay: +trigger.dataset.dropdownDelay || 300,
      ignore_click_outside_class: trigger.dataset.dropdownIgnoreClickOutsideClass || false,
      use_portal:
        trigger.dataset.dropdownPortal === "true"
          ? true
          : trigger.dataset.dropdownPortal === "false"
          ? false
          : undefined,
      ...options,
    };

    new Dropdown(trigger, menu, opt);
  });
};

window.init_dropdowns = init_dropdowns;

Then initialise all dropdowns on the page:

init_dropdowns();

You can pass global options directly to the function – see the Params section.

Auto‑initialisation

Add this at the end of your dropdown.js file:

document.addEventListener("DOMContentLoaded", () => init_dropdowns());

Then link the script in your HTML:

<script src="dropdown.js"></script>

Note: For a hover‑triggered dropdown, add data-dropdown-trigger="hover" to the button element.

Params

The init_dropdowns() function accepts an optional configuration object:

init_dropdowns({
  // Defines where the dropdown will appear relative to the trigger element.
  placement: "bottom", // string: 'top' | 'bottom' | 'left' | 'right'
  // Determines how the dropdown is opened: on click or when hovering.
  trigger_type: "click", // string: 'click' | 'hover'
  // Moves dropdown slightly left/right from its trigger.
  offset_skidding: 0, // number: horizontal (X-axis) offset in pixels
  // Moves dropdown slightly above/below its trigger.
  offset_distance: 10, // number: vertical (Y-axis) offset in pixels            
  // Used to avoid flickering when moving between trigger and menu.
  delay: 300, // number: delay in milliseconds for hover-triggered dropdowns
  // CSS class name; clicking inside elements with this class won't close the dropdown.
  ignore_click_outside_class: false, // string | false
  // true → dropdown always appended to <body>.
  // false → dropdown stays inside its container.
  // undefined → automatically decides based on layout (hybrid mode).
  use_portal: undefined, // boolean | undefined: true | false | undefined
  // Called when the dropdown is shown.
  on_show: () => {}, // function(Dropdown instance)
  // Called when the dropdown is hidden.
  on_hide: () => {}, // function(Dropdown instance)
  // Called whenever dropdown visibility changes (show/hide).
  on_toggle: () => {}, // function(Dropdown instance)
});

Data attributes

You can override options per dropdown using data attributes on the trigger element:

  • data-dropdown-placement – string: 'top', 'bottom', 'left', 'right' (default: 'bottom')
  • data-dropdown-offset-skidding – number: horizontal offset in pixels
  • data-dropdown-offset-distance – number: vertical offset in pixels
  • data-dropdown-trigger – string: 'click' or 'hover'
  • data-dropdown-delay – number: delay in ms for hover trigger
  • data-dropdown-ignore-click-outside-class – string: CSS class name; clicking inside elements with this class won’t close the dropdown
  • data-dropdown-portal – string: 'true', 'false', or omit for auto detection

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