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';
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.
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; }
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.
// 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(); });
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">
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); }
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)); }
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 */
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.
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 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.
- 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;
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.
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 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.
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));
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
<dropdown-component><dropdown-trigger>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>role="group",
aria-hidden, aria-labelledby,
inert. Holds anything.
Attributes
visiblevisible property.
triggerbothboth = hover on fine pointers
plus click; hover = hover only where
(hover: hover) matches;
click = no hover path.
open-delay0pointerenter waits before opening. Leaving
first cancels it. Hover only.
close-delay0pointerleave waits before closing a
hover-opened panel. Re-entering cancels it.
widewidth: 100%, host goes
position: static — the panel spans the
nearest positioned ancestor.
opensdownright moves it to
top: 0; left: 100% and rotates the bridge
onto Y. The submenu case.
effectfade, slide,
scale, blur,
bloom or swing. No-op
without the effects stylesheet.
arrowflipflip mirrors the arrow on
open, static leaves it,
none hides it. Effects stylesheet only.
arrow-shapechevronchevron or
triangle, drawn only when the
[data-dropdown-arrow] hook is empty.
Effects stylesheet only.
CSS Custom Properties
--dp-effect-duration200ms--dp-effect-easingcubic-bezier(0.32, 0.72, 0, 1)--dp-arrow-size0.64em--dp-arrow-thickness1.5px
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
show()hide({ restoreFocus })toggle()visiblevisible attribute
both ways.
Events
dropdown-panel:before-showpreventDefault() aborts.
dropdown-panel:showdropdown-panel:before-hidepreventDefault() aborts.
dropdown-panel:hideAll 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.