Dropdown Menu
Context menu built on the popover API. Light-dismiss is automatic.
Supports groups, shortcuts, separators, and destructive items. Animated with @starting-style.
Standard Menu
Full-featured menu with icons, keyboard shortcuts, groups, separators, a disabled item, and a destructive action.
<!-- Trigger -->
<button class="btn" data-variant="default"
aria-haspopup="menu" aria-expanded="false"
aria-controls="demo-dropdown"
data-dropdown-trigger="demo-dropdown">
Options
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
<!-- Menu -->
<div id="demo-dropdown" role="menu" popover
class="dropdown-content" aria-label="Options menu"
style="min-width:12rem;">
<div class="dropdown-label">My Account</div>
<div role="group" aria-label="Navigation">
<button role="menuitem" class="dropdown-item" tabindex="-1">
<svg aria-hidden="true" width="14" height="14">…</svg>
Profile
<span class="dropdown-shortcut">⇧⌘P</span>
</button>
<button role="menuitem" class="dropdown-item" tabindex="-1">
<svg aria-hidden="true" width="14" height="14">…</svg>
Settings
<span class="dropdown-shortcut">⌘S</span>
</button>
<button role="menuitem" class="dropdown-item"
tabindex="-1" disabled>
<svg aria-hidden="true" width="14" height="14">…</svg>
Upload
<span class="dropdown-shortcut">⌘U</span>
</button>
</div>
<div class="dropdown-separator" role="separator"></div>
<div role="group" aria-label="Actions">
<button role="menuitem" class="dropdown-item" tabindex="-1">
<svg aria-hidden="true" width="14" height="14">…</svg>
Export
<span class="dropdown-shortcut">⌘E</span>
</button>
<button role="menuitem" class="dropdown-item"
data-variant="destructive" tabindex="-1">
<svg aria-hidden="true" width="14" height="14">…</svg>
Delete
<span class="dropdown-shortcut">⌘⌫</span>
</button>
</div>
</div>
Checkbox & Radio
Use menuitemcheckbox for toggleable options and menuitemradio inside a role="group" for exclusive choices.
<!-- Trigger -->
<button class="btn" data-variant="outline"
aria-haspopup="menu" aria-expanded="false"
aria-controls="demo-dropdown-checks"
data-dropdown-trigger="demo-dropdown-checks">
View
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
<!-- Checkbox / Radio Menu -->
<div id="demo-dropdown-checks" role="menu" popover
class="dropdown-content" aria-label="View options"
style="min-width:11rem;">
<div class="dropdown-label">Appearance</div>
<div role="group" aria-label="Toggle columns">
<button role="menuitemcheckbox"
class="dropdown-item dropdown-check"
aria-checked="true" tabindex="-1">Status Bar</button>
<button role="menuitemcheckbox"
class="dropdown-item dropdown-check"
aria-checked="false" tabindex="-1">Activity Bar</button>
<button role="menuitemcheckbox"
class="dropdown-item dropdown-check"
aria-checked="true" tabindex="-1">Panel</button>
</div>
<div class="dropdown-separator" role="separator"></div>
<div class="dropdown-label">Layout</div>
<div role="group" aria-label="Layout style">
<button role="menuitemradio"
class="dropdown-item dropdown-radio"
aria-checked="true" tabindex="-1">Sidebar Left</button>
<button role="menuitemradio"
class="dropdown-item dropdown-radio"
aria-checked="false" tabindex="-1">Sidebar Right</button>
</div>
</div>
Trigger Variants
Any button variant can serve as a dropdown trigger. Here a secondary button and a ghost icon-only button open simple menus.
<!-- Secondary trigger -->
<button class="btn" data-variant="secondary"
aria-haspopup="menu" aria-expanded="false"
aria-controls="demo-dropdown-3"
data-dropdown-trigger="demo-dropdown-3">
More
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
<div id="demo-dropdown-3" role="menu" popover
class="dropdown-content" aria-label="More options"
style="min-width:10rem;">
<button role="menuitem" class="dropdown-item" tabindex="-1">Copy</button>
<button role="menuitem" class="dropdown-item" tabindex="-1">Paste</button>
<div class="dropdown-separator" role="separator"></div>
<button role="menuitem" class="dropdown-item" tabindex="-1">Select All</button>
</div>
<!-- Ghost icon trigger -->
<button class="btn" data-variant="ghost" data-size="icon"
aria-haspopup="menu" aria-expanded="false"
aria-controls="demo-dropdown-4"
data-dropdown-trigger="demo-dropdown-4"
aria-label="More options">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round">
<circle cx="12" cy="12" r="1"/>
<circle cx="12" cy="5" r="1"/>
<circle cx="12" cy="19" r="1"/>
</svg>
</button>
<div id="demo-dropdown-4" role="menu" popover
class="dropdown-content" aria-label="Actions"
style="min-width:10rem;">
<button role="menuitem" class="dropdown-item" tabindex="-1">Edit</button>
<button role="menuitem" class="dropdown-item" tabindex="-1">Duplicate</button>
<div class="dropdown-separator" role="separator"></div>
<button role="menuitem" class="dropdown-item"
data-variant="destructive" tabindex="-1">Delete</button>
</div>
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;
position-try-fallbacks: flip-block;
/* -- Animation --------------------------------------------- */
opacity: 0;
transform: scale(0.96) translateY(-0.25rem);
transition: opacity 150ms ease, transform 150ms ease,
display 150ms allow-discrete;
&: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 {
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: left;
&: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 {
pointer-events: none;
opacity: 0.5;
}
}
/* -- 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;
}
}
.dropdown-check[aria-checked="true"]::before {
content: '';
background-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='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
.dropdown-radio[aria-checked="true"]::before {
content: '';
background-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='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='5'/%3E%3C/svg%3E");
background-size: contain;
background-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;
}
/* -- 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.
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;
const getItems = () => {
return Array.from(menu.querySelectorAll('[role="menuitem"]:not(:disabled), [role="menuitemcheckbox"]:not(:disabled), [role="menuitemradio"]:not(:disabled)'));
};
const highlight = (item) => {
getItems().forEach((i) => { i.removeAttribute('data-highlighted'); });
if (item) { item.setAttribute('data-highlighted', ''); item.focus(); }
};
trigger.addEventListener('click', () => { menu.togglePopover(); });
menu.addEventListener('toggle', (e) => {
const open = e.newState === 'open';
trigger.setAttribute('aria-expanded', open);
if (open) { const first = getItems()[0]; if (first) highlight(first); }
else { getItems().forEach((i) => { i.removeAttribute('data-highlighted'); }); trigger.focus(); }
});
menu.addEventListener('mousemove', (e) => {
const item = e.target.closest('[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]');
if (item && !item.disabled) highlight(item);
});
menu.addEventListener('mouseleave', () => {
getItems().forEach((i) => { i.removeAttribute('data-highlighted'); });
});
menu.addEventListener('keydown', (e) => {
const items = getItems();
const current = items.indexOf(document.activeElement);
switch (e.key) {
case 'ArrowDown': e.preventDefault(); highlight(items[(current + 1) % items.length]); break;
case 'ArrowUp': e.preventDefault(); highlight(items[(current - 1 + items.length) % items.length]); break;
case 'Home': e.preventDefault(); highlight(items[0]); break;
case 'End': e.preventDefault(); highlight(items[items.length - 1]); break;
case 'Escape': menu.hidePopover(); break;
case 'Enter': case ' ':
e.preventDefault();
if (document.activeElement) {
const role = document.activeElement.getAttribute('role');
if (role === 'menuitemcheckbox') {
const checked = document.activeElement.getAttribute('aria-checked') === 'true';
document.activeElement.setAttribute('aria-checked', !checked);
} else if (role === 'menuitemradio') {
const group = document.activeElement.closest('[role="group"]');
if (group) group.querySelectorAll('[role="menuitemradio"]').forEach((r) => { r.setAttribute('aria-checked', 'false'); });
document.activeElement.setAttribute('aria-checked', 'true');
} else { document.activeElement.click(); menu.hidePopover(); }
}
break;
default:
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const match = items.find((item) => item.textContent.trim().toLowerCase().startsWith(e.key.toLowerCase()));
if (match) highlight(match);
}
}
});
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });