Dropdown MenuATM
Context menu built on the popover API. Light-dismiss is automatic. Supports groups, shortcuts, separators, and destructive items. Animated with @starting-style.
On this page (10)
Full-featured menu with icons, keyboard shortcuts, groups, separators, a disabled item, and a destructive action.
§Checkbox & Radio
Use menuitemcheckbox for toggleable options and menuitemradio inside a role='group' for exclusive choices - a click or Enter / Space toggles them and the menu stays open.
§Live checkbox and radio state
Checkbox items toggle and radio items switch on click (or Enter / Space) - the menu stays open so several can be set at once. Every change fires dropdown:select; the line under the trigger reads the live aria-checked state.
Nest .dropdown-sub (a .dropdown-sub-trigger + its .dropdown-sub-content menu) - any depth; here three levels. Hover or click opens, → / Enter / Space opens by keyboard, ← / Esc closes one level. Submenus flip to the other side near the viewport edge.
§Disabled items
disabled (a <button>) or aria-disabled='true': dimmed, skipped by the arrow keys and typeahead, clicks ignored - a disabled submenu trigger never opens its submenu.
§Trigger Variants
Any button variant can serve as a dropdown trigger. Here a secondary button and a ghost icon-only button open simple menus; the icon menu carries data-align="end", so its end edge lines up with the button instead of hanging out past it.
§Sizes
Set data-size on the .dropdown-content menu; items scale with it. The trigger keeps its own .btn size.
§States
Named states via the shared State API, driven per menu through the bound api:
default- closed (the authored state; the trigger toggles it)open- shown via the nativeshowPopover(), first item highlighted
The first demo carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/dropdown-{state}.png.
Machine contract - verified against dropdown.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
open | boolean | true, false | false | Shown - driven through the component's own State API. |
§CSS view file
/* -- Dropdown component ---------------------------------------- */@layer components { .dropdown-content { background-color: var(--popover); color: var(--popover-foreground); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0.25rem; min-width: 8rem; box-shadow: 0 4px 16px oklch(0 0 0 / 0.12), 0 0 0 1px var(--border); /* -- Anchor positioning ------------------------------------ */ position: fixed; inset: auto; top: anchor(bottom); left: anchor(left); margin: 0; margin-top: 4px; /* the preferred edge first; when the menu would leave the viewport it flips to the trigger's OTHER edge (flip-inline swaps left: anchor(left) <-> right: anchor(right)), above the trigger (flip-block), or both */ position-try-fallbacks: flip-inline, flip-block, flip-block flip-inline; /* data-align="end": the menu's end edge lines up with the trigger's end (a split button's chevron, an icon trigger at a row's end) instead of hanging out past it - and falls back to start-aligned under the trigger when there is no room for that (the fallbacks above) */ &[data-align="end"] { left: auto; right: anchor(right); } /* -- Animation --------------------------------------------- */ opacity: 0; transform: scale(0.96) translateY(-0.25rem); /* overlay keeps a closing menu in the top layer until its exit animation ends - otherwise a closing SUBMENU drops back into its parent menu for 150ms, where the parent's transform makes it the containing block: the parent grew scrollbars and resized */ transition: opacity 150ms ease, transform 150ms ease, overlay 150ms allow-discrete, display 150ms allow-discrete; /* a menu never scrolls sideways; submenus live in the top layer */ overflow-x: hidden; &:popover-open { opacity: 1; transform: scale(1) translateY(0); } } @starting-style { .dropdown-content:popover-open { opacity: 0; transform: scale(0.96) translateY(-0.25rem); } } .dropdown-item { box-sizing: border-box; display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.375rem 0.5rem; border-radius: calc(var(--radius) * 0.6); font-size: 0.875rem; border: none; background: transparent; color: var(--foreground); cursor: pointer; outline: none; text-align: start; text-decoration: none; font: inherit; font-size: 0.875rem; &:hover, &[data-highlighted] { background-color: var(--accent); color: var(--accent-foreground); } &[data-variant="destructive"] { &:hover, &[data-highlighted] { background-color: var(--destructive); color: var(--destructive-foreground); } } &:disabled, &[aria-disabled="true"] { pointer-events: none; opacity: 0.5; } /* data-inset: lines a plain item (or a label) up with the text of checkbox / radio items */ &[data-inset] { padding-inline-start: 1.5rem; } & > svg { width: 1rem; height: 1rem; flex-shrink: 0; color: var(--muted-foreground); } &[data-variant="destructive"] > svg { color: currentColor; } } .dropdown-label[data-inset] { padding-inline-start: 1.5rem; } /* -- Submenus ---------------------------------------------------- <div class="dropdown-sub"> <button class="dropdown-item dropdown-sub-trigger" role="menuitem">…</button> <div class="dropdown-content dropdown-sub-content" role="menu" popover>…</div> </div> The submenu is a DOM descendant of its menu, so the Popover API keeps the parent open while it shows, closes open siblings and light-dismisses the whole tree - any depth. It opens beside its trigger (flipping to the other side, or up, when there is no room); dropdown.js anchors it. */ .dropdown-sub { display: contents; } .dropdown-sub-trigger { &::after { content: ""; width: 1rem; height: 1rem; margin-inline-start: auto; flex-shrink: 0; background-color: currentColor; opacity: 0.6; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E") center / contain no-repeat; } &:dir(rtl)::after { scale: -1 1; } /* the shortcut sits before the chevron */ & .dropdown-shortcut { margin-inline-start: auto; } &:has(.dropdown-shortcut)::after { margin-inline-start: 0.5rem; } &[aria-expanded="true"] { background-color: var(--accent); color: var(--accent-foreground); } } .dropdown-content.dropdown-sub-content { top: anchor(top); left: anchor(right); margin: 0; margin-block-start: calc(-0.25rem - 1px); margin-inline-start: 0.125rem; position-try-fallbacks: flip-inline, flip-block, flip-block flip-inline; transform: scale(0.96) translateX(-0.25rem); &:popover-open { transform: none; } &:dir(rtl) { left: auto; right: anchor(left); } } @starting-style { .dropdown-content.dropdown-sub-content:popover-open { opacity: 0; transform: scale(0.96) translateX(-0.25rem); } } /* -- Checkbox / Radio indicators ----------------------------- */ .dropdown-check, .dropdown-radio { padding-inline-start: 1.5rem; position: relative; &::before { content: ''; position: absolute; inset-inline-start: 0.375rem; top: 50%; transform: translateY(-50%); width: 0.875rem; height: 0.875rem; } } /* Mask (not background-image): a data: URI SVG resolves currentColor to BLACK when used as an image, making the checks invisible in dark mode. Masking paints them with background-color: currentColor instead. */ .dropdown-check[aria-checked="true"]::before, .dropdown-radio[aria-checked="true"]::before { background-color: currentColor; } .dropdown-check[aria-checked="true"]::before { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); } .dropdown-radio[aria-checked="true"]::before { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='black'%3E%3Ccircle cx='12' cy='12' r='4.5'/%3E%3C/svg%3E"); } .dropdown-check[aria-checked="true"]::before, .dropdown-radio[aria-checked="true"]::before { mask-size: contain; mask-repeat: no-repeat; } .dropdown-separator { height: 1px; background: var(--border); margin: 0.25rem -0.25rem; } .dropdown-label { padding: 0.375rem 0.5rem; font-size: 0.75rem; font-weight: 600; color: var(--muted-foreground); } .dropdown-shortcut { margin-inline-start: auto; font-size: 0.75rem; color: var(--muted-foreground); letter-spacing: 0.05em; } /* on a destructive highlight the shortcut follows the item's foreground (muted grey on red was unreadable) */ .dropdown-item[data-variant="destructive"]:is(:hover, [data-highlighted]) .dropdown-shortcut { color: inherit; opacity: 0.8; } /* -- Sizes ---------------------------------------------------- data-size goes on the .dropdown-content popover; items, labels, shortcuts and the check indicators scale together. md == the unsized default. The [data-size] compound outranks the single-class item rules; it only touches block padding/font so the check/radio inline padding (padding-inline-start) keeps working. */ .dropdown-content[data-size="xs"] { & .dropdown-item { padding-block: 0.1875rem; font-size: 0.75rem; } & .dropdown-label { padding-block: 0.1875rem; font-size: 0.6875rem; } & .dropdown-shortcut { font-size: 0.6875rem; } & :is(.dropdown-check, .dropdown-radio)::before { width: 0.75rem; height: 0.75rem; } } .dropdown-content[data-size="sm"] { & .dropdown-item { padding-block: 0.25rem; font-size: 0.8125rem; } & .dropdown-shortcut { font-size: 0.75rem; } } .dropdown-content[data-size="md"] { & .dropdown-item { padding-block: 0.375rem; font-size: 0.875rem; } } .dropdown-content[data-size="lg"] { & .dropdown-item { padding-block: 0.5rem; font-size: 1rem; } & .dropdown-label { font-size: 0.8125rem; } & .dropdown-shortcut { font-size: 0.875rem; } } .dropdown-content[data-size="xl"] { & .dropdown-item { padding-block: 0.625rem; font-size: 1.125rem; } & .dropdown-label { font-size: 0.875rem; } & .dropdown-shortcut { font-size: 1rem; } } /* -- Accessibility ------------------------------------------ */ @media (prefers-reduced-motion: reduce) { .dropdown-content { transition: none; } .dropdown-item { transition: none; } } @media (forced-colors: active) { .dropdown-content { border-color: ButtonText; } .dropdown-item { &:hover, &[data-highlighted] { forced-color-adjust: none; background: Highlight; color: HighlightText; } } }}§JavaScript view file
Wires triggers to popover menus. CSS anchor positioning handles placement. Keyboard navigation (Arrow keys, Home/End, Escape, Enter/Space), typeahead, and checkbox/radio state management.
// -- Dropdown Menu --------------------------------------------// Wires [data-dropdown-trigger] buttons to popover menus with full keyboard// navigation and ARIA support, nested submenus (.dropdown-sub, any depth),// checkbox / radio items that toggle by click and by keyboard, disabled// items, plus the named-state API so agents/tests can drive open/closed by// name (AGENTS.md "State API"). Menubar (menubar.js) reuses all of it.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, safeShowPopover } from '../../shared/state-api.js';const df$ = defussGlobals();const dropdownStates = ['default', 'open'];const ITEM = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';const isDisabled = (el) => el.disabled || el.getAttribute('aria-disabled') === 'true';/** The items that belong to THIS menu - not those of its nested submenus. */const itemsOf = (menu) => Array.from(menu.querySelectorAll(ITEM)).filter((i) => i.closest('[role="menu"]') === menu && !isDisabled(i));const subOf = (trigger) => trigger.closest('.dropdown-sub')?.querySelector(':scope > .dropdown-sub-content') ?? null;/** The outermost menu of a (sub)menu - the one its trigger opened. */const rootOf = (menu) => { let m = menu; while (m?.parentElement?.closest('.dropdown-content[popover]')) m = m.parentElement.closest('.dropdown-content[popover]'); return m;};const HOVER_OPEN = 120;const HOVER_CLOSE = 220;/** * UI side of setState: 'default' hides, 'open' shows. Open/close mechanics * stay native (Popover API); the toggle listener keeps aria-expanded and * highlight in sync either way. */function triggerStateChange(menu, stateName, _config) { switch (stateName) { case 'default': try { menu.hidePopover(); } catch { /* already closed */ } break; case 'open': // deferred show (safeShowPopover): showPopover() mid-exit (right after // light dismiss) crashes the headless renderer; exclusion stays native. safeShowPopover(menu); break; }}/** Registry-level API; pass the menu element explicitly. Unknown names throw. */export const dropdownApi = { setState(menu, stateName, config = {}) { if (!dropdownStates.includes(stateName)) { throw new Error(`dropdown: unknown state "${stateName}" (supported: ${dropdownStates.join(', ')})`); } triggerStateChange(menu, stateName, config); // state lives on the ELEMENT, not the module (multiple menus per page) menu.dataset.stateName = stateName; menu._stateConfig = config; }, getState(menu) { return { name: menu.dataset.stateName || 'default', config: menu._stateConfig ?? {} }; },};df$.dropdownApi = dropdownApi;df$.dropdownStates = dropdownStates;let anchorSeq = 0;function highlight(menu, item, focus = true) { itemsOf(menu).forEach((i) => { if (i !== item) i.removeAttribute('data-highlighted'); }); if (item) { item.setAttribute('data-highlighted', ''); if (focus) item.focus({ preventScroll: true }); }}/** Checkbox / radio / plain item activation (click or Enter / Space). */function activate(menu, item) { if (isDisabled(item)) return; const role = item.getAttribute('role'); if (item.classList.contains('dropdown-sub-trigger')) { openSub(item, true); return; } if (role === 'menuitemcheckbox') { const checked = item.getAttribute('aria-checked') !== 'true'; item.setAttribute('aria-checked', String(checked)); item.dispatchEvent(new CustomEvent('dropdown:select', { bubbles: true, detail: { item, value: item.dataset.value ?? item.textContent.trim(), checked } })); return; // stays open: toggling several options in one go } if (role === 'menuitemradio') { const group = item.closest('[role="group"]') ?? menu; group.querySelectorAll('[role="menuitemradio"]').forEach((r) => { if (r.closest('[role="menu"]') === menu) r.setAttribute('aria-checked', String(r === item)); }); item.dispatchEvent(new CustomEvent('dropdown:select', { bubbles: true, detail: { item, value: item.dataset.value ?? item.textContent.trim(), checked: true } })); return; } item.dispatchEvent(new CustomEvent('dropdown:select', { bubbles: true, detail: { item, value: item.dataset.value ?? item.textContent.trim() } })); // a plain item acts and closes the whole menu tree try { rootOf(menu).hidePopover(); } catch { /* closed */ }}function openSub(trigger, focusFirst) { const sub = subOf(trigger); if (!sub || isDisabled(trigger)) return; clearTimeout(sub._closeTimer); sub._focusFirst = focusFirst; if (!sub.matches(':popover-open')) { try { sub.showPopover(); } catch { /* detached */ } } else if (focusFirst) highlight(sub, itemsOf(sub)[0]);}function closeSub(sub) { if (sub?.matches(':popover-open')) { try { sub.hidePopover(); } catch { /* closed */ } }}/** Behavior shared by a root menu and every submenu: pointer highlight, * hover-intent submenus, keyboard navigation, click activation. */function wireMenu(menu) { if (menu._wired) return; menu._wired = true; const own = (e) => e.target instanceof Element && e.target.closest('[role="menu"]') === menu; const openSubs = () => Array.from(menu.querySelectorAll('.dropdown-sub-content')).filter((s) => s.parentElement.closest('[role="menu"]') === menu && s.matches(':popover-open')); menu.addEventListener('mousemove', (e) => { if (!own(e)) return; const item = e.target.closest(ITEM); if (!item || isDisabled(item)) return; if (!(item.hasAttribute('data-highlighted') && item === document.activeElement)) highlight(menu, item); // hover intent: open this item's submenu, close the others after a beat // (once per item entered - mousemove fires continuously) if (menu._hoverItem === item) return; menu._hoverItem = item; clearTimeout(menu._hoverTimer); const sub = item.classList.contains('dropdown-sub-trigger') ? subOf(item) : null; menu._hoverTimer = setTimeout(() => { openSubs().forEach((s) => { if (s !== sub) closeSub(s); }); if (sub) openSub(item, false); }, sub ? HOVER_OPEN : HOVER_CLOSE); }); menu.addEventListener('mouseleave', (e) => { clearTimeout(menu._hoverTimer); menu._hoverItem = null; // leaving toward an open submenu keeps its trigger highlighted const to = e.relatedTarget; if (to instanceof Element && to.closest('.dropdown-sub-content') && menu.contains(to)) return; itemsOf(menu).forEach((i) => { if (!(i.classList.contains('dropdown-sub-trigger') && subOf(i)?.matches(':popover-open'))) i.removeAttribute('data-highlighted'); }); }); // a press on a gap, a label or a disabled item keeps focus in the menu // (otherwise it falls to <body> and the arrow keys stop working) menu.addEventListener('mousedown', (e) => { if (!own(e)) return; const hit = e.target instanceof Element ? e.target.closest(`${ITEM}, input, textarea, select`) : null; if (!hit || isDisabled(hit)) e.preventDefault(); }); menu.addEventListener('click', (e) => { if (!own(e)) return; const item = e.target.closest(ITEM); if (!item) return; // no preventDefault: a link item (<a role="menuitem" href>) navigates activate(menu, item); }); menu.addEventListener('keydown', (e) => { if (!own(e)) return; const items = itemsOf(menu); const current = items.indexOf(document.activeElement); const isSub = menu.classList.contains('dropdown-sub-content'); const rtl = getComputedStyle(menu).direction === 'rtl'; const inward = rtl ? 'ArrowLeft' : 'ArrowRight'; const outward = rtl ? 'ArrowRight' : 'ArrowLeft'; switch (e.key) { case 'ArrowDown': e.preventDefault(); highlight(menu, items[(current + 1) % items.length]); break; case 'ArrowUp': e.preventDefault(); highlight(menu, items[(current - 1 + items.length) % items.length]); break; case 'Home': e.preventDefault(); highlight(menu, items[0]); break; case 'End': e.preventDefault(); highlight(menu, items[items.length - 1]); break; case inward: if (document.activeElement?.classList.contains('dropdown-sub-trigger')) { e.preventDefault(); openSub(document.activeElement, true); } break; case outward: if (isSub) { e.preventDefault(); closeSub(menu); } break; case 'Escape': e.preventDefault(); e.stopPropagation(); if (isSub) closeSub(menu); else menu.hidePopover(); break; case 'Tab': try { rootOf(menu).hidePopover(); } catch { /* closed */ } break; case 'Enter': case ' ': e.preventDefault(); // a real click, so the item's own click handlers run too (the menu's // click listener then activates it) if (document.activeElement?.matches(ITEM) && !isDisabled(document.activeElement)) (document.activeElement as HTMLElement).click(); break; default: if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { const k = e.key.toLowerCase(); const rest = items.slice(current + 1).concat(items.slice(0, current + 1)); const match = rest.find((item) => item.textContent.trim().toLowerCase().startsWith(k)); if (match) highlight(menu, match); } } });}/** A submenu: anchored to its trigger's side, aria-expanded in sync, * focus to the first item when opened by keyboard, back to the trigger when * closed from inside. */function wireSub(wrap) { const trigger = wrap.querySelector(':scope > .dropdown-sub-trigger'); const sub = wrap.querySelector(':scope > .dropdown-sub-content'); if (!trigger || !sub || sub._subWired) return; sub._subWired = true; sub.dataset.init = ''; if (!sub.hasAttribute('popover')) sub.setAttribute('popover', 'auto'); if (!sub.id) sub.id = `dropdown-sub-${++anchorSeq}`; const anchor = `--dropdown-sub-${anchorSeq}-${sub.id}`; trigger.style.anchorName = anchor; sub.style.positionAnchor = anchor; trigger.setAttribute('aria-haspopup', 'menu'); trigger.setAttribute('aria-expanded', 'false'); trigger.setAttribute('aria-controls', sub.id); wireMenu(sub); sub.addEventListener('toggle', (e) => { const open = e.newState === 'open'; trigger.setAttribute('aria-expanded', String(open)); if (open) { trigger.setAttribute('data-highlighted', ''); if (sub._focusFirst) highlight(sub, itemsOf(sub)[0]); } else { itemsOf(sub).forEach((i) => { i.removeAttribute('data-highlighted'); }); if (sub.contains(document.activeElement) || document.activeElement === document.body) trigger.focus({ preventScroll: true }); } }); // back inside the submenu: keep it open sub.addEventListener('mouseenter', () => { const parent = trigger.closest('[role="menu"]'); if (parent) clearTimeout(parent._hoverTimer); highlight(parent, trigger, false); });}function init() { document.querySelectorAll('[data-dropdown-trigger]:not([data-init])').forEach((trigger) => { trigger.dataset.init = ''; const menu = document.getElementById(trigger.dataset.dropdownTrigger); if (!menu) return; // CSS anchor positioning - unique name per trigger-menu pair const anchorId = `--dropdown-${menu.id}`; trigger.style.anchorName = anchorId; menu.style.positionAnchor = anchorId; if (!trigger.hasAttribute('aria-haspopup')) trigger.setAttribute('aria-haspopup', 'menu'); if (!trigger.hasAttribute('aria-controls')) trigger.setAttribute('aria-controls', menu.id); // Native declarative toggle. A JS `togglePopover()` click handler is // buggy for popover="auto": light dismiss closes the menu *before* the // click handler runs, so togglePopover re-opens it and the menu can // never be closed by clicking the trigger again. The popovertarget // command is dismiss-aware - the trigger button must be a <button> // (documented API) for the native command to apply. if (!trigger.hasAttribute('popovertarget')) trigger.setAttribute('popovertarget', menu.id); menu._trigger = trigger; menu.addEventListener('toggle', (e) => { if (e.target !== menu) return; const open = e.newState === 'open'; trigger.setAttribute('aria-expanded', String(open)); if (open) { const first = itemsOf(menu)[0]; if (first && !menu._noFocus) highlight(menu, first); menu._noFocus = false; } else { itemsOf(menu).forEach((i) => { i.removeAttribute('data-highlighted'); }); // focus back to the trigger - unless it moved on to another trigger // whose menu is open now (a menubar switching menus). The browser's // own popover focus restore may have put it on the trigger that // was focused when this menu opened; that one is taken back. const active = document.activeElement; const other = active instanceof Element && active !== trigger ? active.closest('[data-dropdown-trigger]') : null; const otherOpen = other ? document.getElementById(other.getAttribute('data-dropdown-trigger'))?.matches(':popover-open') : false; if (!otherOpen && (!active || active === document.body || menu.contains(active) || other)) trigger.focus({ preventScroll: true }); } }); wireMenu(menu); menu.querySelectorAll('.dropdown-sub').forEach(wireSub); }); // submenus added later (dynamic menus) document.querySelectorAll('.dropdown-sub').forEach(wireSub); // bind-scope the api per menu instance: `$('#menu').api.setState('open')` document.querySelectorAll('.dropdown-content[popover]:not(.dropdown-sub-content):not([data-init])').forEach((menu) => { menu.dataset.init = ''; menu.api = { setState: (stateName, config) => dropdownApi.setState(menu, stateName, config), getState: () => dropdownApi.getState(menu), }; });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub