webdevpuneet.com

Tippy.js Dropdown Menu with Submenus — Free HTML CSS JS Snippet

Tippy.js Dropdown Menu with Submenus · Navigation · Plain HTML, CSS & JS · Live preview

CategoryNavigation

Copy & paste

Source Code

<div class="dm-wrap">
  <button class="dm-trigger" id="dmTrigger" type="button">Actions &#9662;</button>
  <div id="dmMenuTemplate" style="display:none">
    <div class="dm-menu">
      <button class="dm-item" data-action="rename">Rename</button>
      <button class="dm-item" data-action="duplicate">Duplicate</button>
      <button class="dm-item dm-has-sub" id="dmMoveTo">Move to &rsaquo;</button>
      <button class="dm-item dm-danger" data-action="delete">Delete</button>
    </div>
  </div>
  <div id="dmSubmenuTemplate" style="display:none">
    <div class="dm-menu">
      <button class="dm-item" data-action="move-projects">Projects</button>
      <button class="dm-item" data-action="move-archive">Archive</button>
      <button class="dm-item" data-action="move-trash">Trash</button>
    </div>
  </div>
  <div class="dm-log" id="dmLog">No action yet</div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Independent submenu lifecycle
A separate Tippy instance, not a nested toggled panel.
Auto-reset on reopen
onShow proactively closes any lingering open submenu.
OS-style submenu placement
right-start with a small overlap offset, like a real context menu.
Full-tree close on selection
Any item click closes both the submenu and the parent menu.
Event-delegated click handling
One listener per menu, not one per button.
Custom light menu theme
Plain CSS on Tippy's own theming attribute.

About this UI Snippet

Tippy.js Dropdown Menu with Submenus — Two Instances, Not One Nested Tree

Screenshot of the Tippy.js Dropdown Menu with Submenus snippet rendered live

A menu with a submenu (like a real OS context menu's "Move to ›") is tempting to build as one popover containing a hidden nested panel toggled with custom CSS. This snippet takes the structurally simpler route: the submenu is its own independent Tippy instance, anchored to the "Move to" menu item, with its own separate show and hide lifecycle.

Two Tippy instances, two independent lifecycles

mainInstance is anchored to the trigger button; subInstance is anchored to the "Move to" item *inside* the main menu. Because they're genuinely separate instances, hovering away from the submenu closes only the submenu (via its own mouseenter-based trigger), without needing to manually track "is the mouse over the parent, the child, or neither" the way a single hand-rolled nested-menu implementation would have to.

Every content element has to be captured before any tippy() call runs

This is easy to get backwards: passing a DOM node as content doesn't just reference it — Tippy moves that node out of the live page into its own popper structure the instant the instance is created, and that popper isn't attached back into the document until the tooltip first shows. Querying document.getElementById('dmMoveTo') *after* mainInstance has already been constructed would return null, because dmMoveTo lives inside dmMenuTemplate, which mainInstance already detached. The fix is grabbing every element tippy() will need — menuTemplate, subTemplate, and moveToItem — into variables *before* either tippy() call runs, and reusing those variables (never re-querying by id) everywhere afterward, including the click listeners.

The inline display:none has to be cleared, or the box "shows" at zero size

Both templates sit in the page markup with style="display:none" so they're invisible before Tippy ever touches them — but that inline style is part of the element itself, and it travels *with* the element into the tippy-box. Tippy's own show/hide logic only toggles the box wrapper's visibility, never an inner element's pre-existing inline style. Skip clearing it, and mainInstance.show() reports the tooltip as fully shown — data-state="visible", opacity: 1 — while the actual menu inside remains display: none, collapsing the whole box to a couple of pixels with nothing visibly rendered. menuTemplate.style.display = '' and subTemplate.style.display = '', run once right after capturing the references, is what fixes it.

onShow on the main menu proactively closes the submenu

If the main menu is dismissed and reopened, a previously-open submenu shouldn't still be showing underneath it. mainInstance's onShow hook calls subInstance.hide() every time the main menu opens — a small, explicit reset that prevents a stale-looking submenu from a previous interaction persisting into a new one.

placement: 'right-start' with a negative offset positions the submenu like a real OS menu

right-start opens the submenu to the right of its trigger item, top-aligned with it — the standard nested-menu placement. The offset: [-6, 0] nudges it slightly to overlap the parent menu's edge, which is a deliberate detail: a small gap between a menu and its submenu is exactly the gap a moving mouse can slip through and accidentally close the submenu by leaving both elements' hover areas simultaneously.

Selecting any item closes both instances explicitly

Both the main menu's and the submenu's click handlers call subInstance.hide() and mainInstance.hide() regardless of which one the click happened in — so choosing "Rename" from the top level or "Projects" from two levels deep both fully close the entire menu tree, rather than leaving a submenu open behind a now-hidden parent.

Event delegation, not one listener per button

Each menu's clicks are handled by a single listener on the menu container using e.target.closest('[data-action]'), rather than attaching an individual listener to every button — simpler to maintain as items are added or removed, and it works identically for the top-level menu and the submenu without duplicated code.

Reusing it

Add a second or third submenu the same way — a new Tippy instance anchored to its own trigger item — reusing the identical trigger/placement/offset pattern; each submenu instance is independent and doesn't need to know about the others.

Step by step

How to Use

  1. 1
    Add the Tippy.js CDNLoad tippy.css and tippy-bundle.umd.min.js before the snippet's JS runs.
  2. 2
    Paste HTML, CSS, and JSAn "Actions" button renders.
  3. 3
    Click the buttonA dropdown menu opens with four items.
  4. 4
    Hover "Move to"A submenu opens to the right with three destinations.
  5. 5
    Click a submenu itemBoth menus close and the action logs below.
  6. 6
    Reopen the menuThe submenu does not reappear on its own.

Real-world uses

Common Use Cases

File and document management
Offer rename, duplicate, move and delete actions, with a submenu implemented as its own independent Tippy instance rather than a nested panel.
Table row context actions
Provide compact per-row action menus where a Move to option opens a submenu to the right with a small overlap offset.
Settings and account menus
Group actions with a nested option, with onShow proactively closing any lingering open submenu when the parent reopens.
Admin toolbars
Pair with the Tippy interactive popover form so toolbars can hold both menus and small forms.
Content management moves
Build move-to-folder style nested navigation, where selecting any item closes both submenu and parent for a clean result.

Got questions?

Frequently Asked Questions

Passing a DOM node as Tippy's content option doesn't just reference it — Tippy moves that node out of its original place in the page and into its own popper structure the moment the instance is created, and that structure isn't reattached to the visible document until the tooltip first shows. Querying document.getElementById for that same id afterward would return null, since the element (though still valid and usable through the reference already saved in JavaScript) is no longer part of the live, attached document tree. Capturing every needed element into a variable before any tippy() call runs, and reusing those variables afterward, avoids this entirely.

The template markup starts with an inline style="display:none" so it doesn't flash visibly in the page before Tippy adopts it. But that inline style is a property of the element itself, and it moves along with the element when Tippy relocates it into the tippy-box — Tippy's show/hide mechanism only toggles the box wrapper's own visibility, it never inspects or clears an inner element's pre-existing inline styles. Without explicitly setting the template's display back to an empty string right after capturing it, the tooltip would report itself as fully shown while the actual content inside stayed invisible, collapsing the whole box to zero visible size.

Using two independent instances means each one manages its own show/hide state and hover detection natively — the submenu's own mouseenter trigger and Tippy's built-in interactive handling already solve "close when the mouse truly leaves this element," without needing custom logic to track whether the cursor is over the parent menu, the submenu, or the gap between them. A single hand-built nested menu would need to reimplement that hover-tracking logic manually.

Without it, a submenu left open from a previous interaction (opened, then the main menu was dismissed without closing the submenu explicitly) could still be showing the next time the main menu opens, creating a confusing, stale-looking state. Calling subInstance.hide() inside the main menu's onShow hook guarantees every fresh opening of the main menu starts with the submenu definitively closed.

A small gap between a menu item and its submenu is exactly the space a diagonally-moving mouse can pass through, leaving both the item's and the submenu's hover areas at the same moment and causing the submenu to close before the cursor actually reaches it. The offset: [-6, 0] value pulls the submenu slightly toward and overlapping its trigger item, closing that gap the same way real operating system context menus avoid it.

Selecting an action, whether from the top-level menu or two levels deep in the submenu, represents a completed choice — the entire menu tree should close, not just the specific panel the click happened in. Calling both subInstance.hide() and mainInstance.hide() from either handler ensures choosing "Rename" at the top level and choosing "Projects" inside the submenu both fully dismiss the whole menu, rather than leaving a parent or child panel open behind the other.

Create another Tippy instance anchored to that item's element (the same way subInstance is anchored to the "Move to" button), using the same interactive, trigger, placement, and offset configuration, with its own content element and its own click handler. Each submenu instance is fully independent, so adding more doesn't require changing the existing ones — just remember to also call its hide() method from the main menu's onShow and from every item's click handler that should close the full tree.

Yes. While the Tippy.js Dropdown Menu with Submenus snippet gives you one pattern to copy, the free Responsive Navbar Builder at webdevpuneet.com/navbar-builder/ designs a whole navigation bar: logo, links and dropdown menus, a mobile hamburger menu (dropdown panel, slide-in drawer or full screen) and a scroll effect (sticky, shrink, hide on scroll or transparent). It has six presets, a live preview that runs the real code, and exports HTML + CSS + vanilla JS, React, Tailwind, Vue 3 or Angular — or you can click Fork & Edit to keep editing it in My Code.