Popover Menu Chain
Popover panel dynamically positioned below its trigger — submenus chained on hover with overflow detection and automatic flip at the viewport edge.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Navigation has 36 effects, including 4 free. Explore the category →
3 usage examples



How it works
On trigger click, the script calls getBoundingClientRect() on both the button and its .popover-scene ancestor to compute relative coordinates. It then sets style.left and style.top directly on the panel — no position: sticky or CSS Anchor Positioning: the calculation runs on every open, ensuring correct alignment even after page scroll.
Items with data-has-sub open their sub-panel on mouseenter: the script first closes all existing .sub-popover.open panels (only one submenu open at a time), then adds the open class to the target via document.getElementById(id).
Overflow detection runs inside a requestAnimationFrame right after opening: if the submenu's getBoundingClientRect().right exceeds window.innerWidth − 10, the position flips to style.left = 'auto' and style.right = 'calc(100% + 6px)' — the submenu folds to the left of its parent.
Closing is handled by a single click listener on document: if the click target belongs neither to the panel nor to the trigger (contains + direct comparison), the closeAll function removes the open class from the panel and all its submenus. CSS transitions (opacity .2s, transform .2s) animate the show/hide.
Accessibility
- prefers-reduced-motion: no check in the code —
opacity .2s, transform .2stransitions remain active. Add@media (prefers-reduced-motion: reduce) { .popover-panel, .sub-popover { transition: none } }if needed. - Menu items are native
<button>elements — keyboard-focusable and announced by screen readers at the basic level. - Missing ARIA: no
aria-expandedon the trigger, noaria-haspopup, norole="menu"/role="menuitem"— add these for full WCAG compliance. - Escape key to close is not implemented — keyboard users must click outside the panel.
- Item contrast (#c7d2fe on #1e1b4b): approximate ratio 6.5:1 — WCAG AA compliant for normal-size text.
Browser compatibility
Uses getBoundingClientRect, classList, requestAnimationFrame, and querySelectorAll — universally supported without polyfills. The Popover API pattern is simulated in plain JS, without the native popover attribute.
Without JS, the panel stays hidden (<code>opacity:0, pointer-events:none</code>) — no fatal errors, the trigger remains visible and focusable.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="popover-scene" id="popoverScene">
<button class="popover-trigger-btn" id="popoverTrigger">Menu</button>
<div class="popover-panel" id="popoverPanel" style="position:absolute;">
<button class="pop-item" data-has-sub="sub1">Section <span class="arrow">▸</span></button>
<button class="pop-item">Direct action</button>
<div class="sub-popover" id="sub1">
<button class="pop-item">Sub-item 1</button>
<button class="pop-item">Sub-item 2</button>
</div>
</div>
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
| data-has-sub="subId" | absent | Add this data-attribute to a .pop-item to open the matching #subId on hover. Create as many submenus as needed. |
| .popover-panel, .sub-popover { background } | #1e1b4b | Panel and submenu background. Switch to #111827 for a neutral dark or #fff for a light theme. |
| .popover-panel, .sub-popover { border-color } | rgba(99,102,241,.25) | Panel border color. Use your brand's primary color to harmonize. |
| .pop-item:hover { background } | rgba(99,102,241,.15) | Item hover color. Adapt to your palette (e.g. rgba(255,255,255,.08) for a neutral hover). |
| transition: opacity .2s, transform .2s | .2s | Animation duration in the CSS of .popover-panel and .sub-popover. Drop to .12s for faster response or 0s for instant. |
| gap trigger → panel (JS) | 6 px | In the JS: t.style.top = s.bottom - c.top + 6 + 'px' — the 6 is the pixel gap between the button's bottom and the panel's top. |
| gap panel → submenu (CSS) | calc(100% + 6px) | left property of .sub-popover — controls the horizontal gap between the parent panel's right edge and the submenu. |
| .arrow (▸) | ▸ | Submenu indicator on items. Replace with an SVG icon or Unicode character (e.g. ›, →, ▶). |
FAQ
getBoundingClientRect and classList — compatible with Safari 14 and Firefox 87 without polyfills.<button class="pop-item" data-has-sub="mySubId">My item <span class="arrow">▸</span></button> inside #popoverPanel, then <div class="sub-popover" id="mySubId">…</div> right after. The script auto-detects all [data-has-sub] attributes via querySelectorAll.document.getElementById('popoverTrigger').click(). To close, call document.getElementById('popoverPanel').classList.remove('open') then strip .open from all submenus via querySelectorAll('.sub-popover.open').