@magic-spells/dropdown-panel

A panel that drops.

Three custom elements, no dependencies, and one attribute that holds the state.

Install npm install @magic-spells/dropdown-panel
3.0 kB min + gzip, js + css
Anatomy

The markup.

The component wires the pair, the trigger is the button, the panel holds whatever you put in it. Ids and ARIA are written on connect. Everything you can see below is this page's own CSS.

Hover, tap, or press Enter on the trigger. All three set visible.

<dropdown-component>
  <dropdown-trigger>Account</dropdown-trigger>
  <dropdown-panel>
    <a href="/profile">Profile</a>
    <a href="/billing">Billing</a>
  </dropdown-panel>
</dropdown-component>

// ids, aria-controls, aria-expanded, aria-hidden, aria-labelledby
// and inert are all written for you on connect
import '@magic-spells/dropdown-panel';
import '@magic-spells/dropdown-panel/css';
State

One attribute holds the state.

visible on the host is the truth, and it reflects. aria-expanded, aria-hidden and inert are derived output — never write them yourself.

Deploy Staging Production Rollback
reading…
const menu = document.querySelector('#account-menu');

menu.visible = true;              // adds the attribute
menu.setAttribute('visible', '');  // identical
menu.toggle();

// hide() restores focus only if focus is inside the panel,
// so closing a menu you never entered never steals it back
menu.hide({ restoreFocus: false });

/* style the open state off the host, not off the ARIA */
dropdown-component[visible] > dropdown-trigger {
  color: #d8b043;
}
Pointer

Opening on hover.

Hover runs on pointerenter and ignores pointerType: touch — no more double-tap on iOS. Touch dismisses on an outside pointerdown instead, and a clicked-open panel latches rather than closing on leave.

Workspace Switch workspace Invite people Workspace settings Nothing yet. Hover it, tap it, or click somewhere else.
// hover, but never from a synthetic touch pointer
host.addEventListener('pointerenter', (e) => {
  if (e.pointerType !== 'touch') host.show();
});

// registered on open, removed on close — one listener, never two
document.addEventListener('pointerdown', (e) => {
  if (!host.contains(e.target)) host.hide();
});
Modes

Choosing the interaction.

both is the default: hover on a fine pointer, click everywhere. hover drops the click toggle only where (hover: hover) matches; click drops the hover path entirely. open-delay and close-delay add hover intent, in milliseconds — click, keys and the API never wait.

This device reports measuring…, so trigger="hover" is being measured. Only one stays open at a time, for free.

<dropdown-component>                     <!-- both, the default -->
<dropdown-component trigger="hover">    <!-- fine pointers only -->
<dropdown-component trigger="click">    <!-- press to toggle -->

<dropdown-component trigger="hover" open-delay="150" close-delay="300">
Bridge

The invisible bridge.

A 30px ::before on the trigger, skewed into a wedge, covers the diagonal trip from trigger to panel. It exists only while hovered, inside @media (hover: hover) — one left standing after an iOS tap would sit over the next nav item.

The gold fill is this page painting an invisible element — the package ships no colour. Hover Reports for the wedge below it, then Exports for the sideways one.

/* dropdown-panel.css — the whole of it */
@media (hover: hover) {
  dropdown-component:hover > dropdown-trigger::before {
    content: '';
    position: absolute;
    left: -20px;
    top: 50%;
    width: calc(100% + 40px);
    height: 30px;
    transform-origin: top center;
    transform: perspective(50px) rotateX(50deg);
    z-index: 10;
  }

  /* a right-opening panel gets the same wedge, rotated onto Y */
  dropdown-component:has(> dropdown-panel[opens='right']):hover
    > dropdown-trigger::before {
    left: calc(100% - 30px);
    top: -10px;
    width: 40px;
    height: calc(100% + 20px);
    transform: perspective(50px) rotateY(-50deg);
  }
}

/* a decorative ::after on the trigger replaces the bridge outright,
   so put the arrow in a real element and mirror it — scaleY(-1)
   flips through the centre line and reverses identically on close,
   where a rotation would unwind a spin */
.menu-arrow {
  transform-origin: center;
  transition: transform var(--dp-effect-duration, 200ms);
}

dropdown-component[visible] > dropdown-trigger > .menu-arrow {
  transform: scaleY(-1);
}
Wide

The full-width mega menu.

wide makes the panel width: 100% and the host position: static, so it measures against the nearest positioned ancestor. Give your nav bar position: relative and the mega menu spans it. Two CSS rules, no script.

A mega menu and a popover in one bar, no coordination.

<nav class="menubar">                /* position: relative */
  <dropdown-component>
    <dropdown-trigger>Products</dropdown-trigger>
    <dropdown-panel wide>…</dropdown-panel>
  </dropdown-component>
</nav>

/* dropdown-panel.css */
dropdown-component:has(> dropdown-panel[wide]) { position: static; }
dropdown-panel[wide] { width: 100%; }

/* your stylesheet does the layout */
dropdown-panel[wide] {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
Nesting

Menus inside menus.

A dropdown inside a panel is just another dropdown. opens="right" moves it to top: 0; left: 100% and rotates the bridge onto Y. Nothing in the chain may set overflow: hidden — that clips the submenu at the parent's edge.

Three levels. Esc closes one at a time. Under 640px they indent instead — no room to the right on a phone.

<dropdown-panel>
  <a href="/docs">Documentation</a>

  <dropdown-component>                    <!-- nested -->
    <dropdown-trigger>Support</dropdown-trigger>
    <dropdown-panel opens="right">…</dropdown-panel>
  </dropdown-component>
</dropdown-panel>

/* the child combinator is what keeps the levels apart */
dropdown-component:hover > dropdown-panel   /* ✓ */
dropdown-component:hover   dropdown-panel   /* ✗ opens every level */

/* and never do this to a panel or anything above one */
dropdown-panel { overflow: hidden; }             /* ✗ clips submenus */
Effects

Entrance effects.

The core stylesheet fades and nothing else. Import the opt-in file and effect="…" gives you six presets plus the trigger arrow, tuned by --dp-effect-duration and --dp-effect-easing, all collapsing to a fade under reduced motion.

effect="fade" · 200ms
Insert Image Table Code block Divider

Leave the hook empty and the sheet draws the glyph; put your own SVG in it and the sheet only flips it. Both settings sit on <dropdown-component>, so a submenu can differ from its parent. Drag the duration up to watch the chevron pass through flat.

import '@magic-spells/dropdown-panel/css';
import '@magic-spells/dropdown-panel/css/effects';  // opt in

<dropdown-panel effect="blur">…</dropdown-panel>

<!-- empty hook: the sheet draws and animates the glyph -->
<dropdown-component arrow-shape="triangle">
  <dropdown-trigger>Menu<span data-dropdown-arrow aria-hidden="true"></span></dropdown-trigger>

/* never a ::before on the trigger — that box is the hover bridge.
   on the hook element they are fine; that is what the sheet uses */

/* they inherit, so one rule reaches the panel and the arrow */
dropdown-component {
  --dp-effect-duration: 200ms;
  --dp-effect-easing: cubic-bezier(0.32, 0.72, 0, 1);
  --dp-arrow-size: 0.64em;
  --dp-arrow-thickness: 1.5px;
}
Keyboard

Keyboard and focus.

The panel carries inert while closed — correct on the first paint, not just after a toggle. ↓ and ↑ open on the first or last item and wrap; → and ← step through submenus. Tab out deliberately does not close: you have to be able to tab in.

File New document Open recent Duplicate Share Copy link Invite by email Tab to the trigger and press a key.
  • Tab reach the trigger
  • Enter Space toggle
  • ↓ open on the first item
  • ↑ open on the last item
  • Home End first, last
  • → into a submenu
  • ← back out of one
  • Esc close one level
// no role="menu", no role="menuitem" — this is site navigation,
// not an application menu, and the roles promise interactions
// screen-reader users would then be right to expect

dropdown-trigger   role="button" tabindex="0" aria-haspopup="true"
                   aria-expanded="false" aria-controls="…"
dropdown-panel     role="group" aria-hidden="true" inert
                   aria-labelledby="…"

// only the innermost open dropdown answers a key, which is what
// keeps a nested menu from taking its parents down with it
if (host.querySelector('dropdown-component[visible]')) return;
Context menu

Right-click, anywhere in here.

trigger="contextmenu" needs no <dropdown-trigger> at all — the component is the surface. The panel goes position: fixed at the pointer, flips and clamps to an 8 px margin, dismisses on scroll, and returns focus to wherever it came from. On touch, a 500 ms press does the same.

Right-click (or long-press) anywhere inside this box.
Try a corner.
No menu yet.

showAt(x, y) is public, so any element can be the surface — bind your own contextmenu listener and call it.

align · flip

Which way it opens.

align="start|end" on the panel is pure CSS — the right-hand menu edges to the right of its trigger. flip is opt-in JavaScript, measured once per open: near the bottom of the viewport the panel opens upward instead, and reverts as soon as there is room again. Scroll this section to the fold and reopen it.

Theme

Styling it yourself.

There is no theming API, because there is nothing to override. The package sets position, opacity and pointer-events, and stops. Every control below writes a property this page invented — only duration and easing belong to the component.

The last two rows set attributes on the element rather than properties. Reset restores all ten.

/* the component's entire stylesheet contribution */
dropdown-panel {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 11;
  opacity: 0;
  pointer-events: none;
  /* longhands, so overriding one does not reset the rest */
  transition-property: opacity;
  transition-duration: 200ms;
}

/* and yours does the rest */
dropdown-panel {
  background: #171b26;
  border-radius: 10px;
  box-shadow: 0 14px 34px rgba(0, 0, 0, 0.5);
  min-width: 200px;
  /* never overflow: hidden */
}
Lifecycle

Lifecycle events.

Four bubbling events on the host, each carrying { trigger, panel }. The two before- ones are cancelable — preventDefault() and nothing happens. Flip the switch to refuse.

Actions Rename Move to… Archive
No events yet.
const menu = document.querySelector('dropdown-component');

for (const name of ['before-show', 'show', 'before-hide', 'hide']) {
  menu.addEventListener(`dropdown-panel:${name}`, (event) => {
    console.log(name, event.detail.trigger, event.detail.panel);
  });
}

// cancelable — this menu refuses to open
menu.addEventListener('dropdown-panel:before-show', (e) => e.preventDefault());

// they bubble, so one listener on the nav hears every menu in it
nav.addEventListener('dropdown-panel:show', (e) => track(e.detail.trigger));
Reference

The full reference.

Three elements, seven attributes, three methods, four events.

import '@magic-spells/dropdown-panel';
import '@magic-spells/dropdown-panel/css';
import '@magic-spells/dropdown-panel/css/effects';  // optional

const menu = document.querySelector('dropdown-component');
menu.show();
menu.hide();
menu.toggle();
menu.visible;  // true | false, reflects the attribute

Elements

Element
Required
Role
<dropdown-component>
Yes
The host. State, listeners, public API. Both children must be direct children.
<dropdown-trigger>
Yes
The button. Gets role="button", tabindex, aria-haspopup, aria-controls, aria-expanded. Draws the bridge with its ::before — leave that one alone. A pseudo-element on the arrow hook inside it is a different box, and fine.
<dropdown-panel>
Yes
The content. Gets role="group", aria-hidden, aria-labelledby, inert. Holds anything.

Attributes

Attribute
Default
Description
visible
absent
On the host. Source of truth, observed and reflected. Mirrors the visible property.
trigger
both
On the host. both = hover on fine pointers plus click; hover = hover only where (hover: hover) matches; click = no hover path.
open-delay
0
On the host. Milliseconds pointerenter waits before opening. Leaving first cancels it. Hover only.
close-delay
0
On the host. Milliseconds pointerleave waits before closing a hover-opened panel. Re-entering cancels it.
wide
absent
On the panel. width: 100%, host goes position: static — the panel spans the nearest positioned ancestor.
opens
down
On the panel. right moves it to top: 0; left: 100% and rotates the bridge onto Y. The submenu case.
effect
none
On the panel. fade, slide, scale, blur, bloom or swing. No-op without the effects stylesheet.
arrow
flip
On the host. flip mirrors the arrow on open, static leaves it, none hides it. Effects stylesheet only.
arrow-shape
chevron
On the host. chevron or triangle, drawn only when the [data-dropdown-arrow] hook is empty. Effects stylesheet only.

CSS Custom Properties

Property
Default
Description
--dp-effect-duration
200ms
Effect duration. Effects stylesheet only.
--dp-effect-easing
cubic-bezier(0.32, 0.72, 0, 1)
Effect timing function. Decelerate-only. Also times the arrow.
--dp-arrow-size
0.64em
Box the drawn glyph fills. Empty hook only.
--dp-arrow-thickness
1.5px
Chevron bar weight. Empty hook only.

That is the whole list. Colours, shadows, radii and z-index are yours — dropdown-panel { … } matches like any other tag. Never overflow: hidden on a panel or above one.

Methods & Properties

Name
Type
Description
show()
method
Opens. Idempotent, and safe on an unconnected or incomplete element.
hide({ restoreFocus })
method
Closes. Restores focus only when focus was inside the panel.
toggle()
method
Opens if closed, closes if open.
visible
boolean
Get and set. Reflects the visible attribute both ways.

Events

Event
Cancelable
When it fires
dropdown-panel:before-show
Yes
Before opening. preventDefault() aborts.
dropdown-panel:show
No
After the state turns open.
dropdown-panel:before-hide
Yes
Before closing. preventDefault() aborts.
dropdown-panel:hide
No
After the state turns closed.

All four fire on the host and bubble — one listener on the nav hears every menu. You do not need them to keep a single menu open; the component already does that.

Accessibility

A disclosure pattern, not a menu one — no role="menu" or menuitem anywhere, because those promise a keyboard model site navigation rarely delivers. Write your items as real links or buttons and the rest follows.

Browser support

Chrome 105+, Edge 105+, Safari 15.4+, Firefox 121+ — custom elements, Pointer Events, inert, :has(). :has() is load-bearing: it switches the host between relative and static for wide.