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">&hellip;</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">&hellip;</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">&hellip;</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">&hellip;</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">&hellip;</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 });