🔥 Oxide UI v0.1.0 — Dark mode only, copy-paste ready. Get started →
Skip to content

Dropdown Menu

<div role="menu">

A floating panel of actions triggered by a button click. Closes on selection or outside click.

Overview

Preview

Basic dropdown

html
<!-- Trigger -->
<div class="relative inline-block">
  <button class="inline-flex items-center gap-2 rounded-md bg-zinc-800 px-3 py-2 text-sm font-medium text-zinc-100 hover:bg-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500">
    Options
    <svg class="h-4 w-4 text-zinc-400" ...>▾</svg>
  </button>

  <!-- Menu panel -->
  <div class="absolute left-0 z-10 mt-1.5 w-48 origin-top-left rounded-lg border border-zinc-700/60 bg-zinc-900 py-1 shadow-xl shadow-black/40">
    <button class="flex w-full items-center gap-2.5 px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 focus:outline-none">
      Profile
    </button>
    <button class="flex w-full items-center gap-2.5 px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 focus:outline-none">
      Settings
    </button>
    <div class="my-1 border-t border-zinc-800"/>
    <button class="flex w-full items-center gap-2.5 px-3 py-2 text-sm text-red-400 hover:bg-red-950/30 hover:text-red-300 focus:outline-none">
      Sign out
    </button>
  </div>
</div>

Section labels

html
<div class="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-600">Account</div>

With icons

html
<button class="flex w-full items-center gap-2.5 px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-800">
  <svg class="h-3.5 w-3.5 shrink-0 text-zinc-500" ...></svg>
  Profile
  <span class="ml-auto text-[10px] text-zinc-600">⌘P</span>
</button>

Destructive item

html
<button class="flex w-full items-center gap-2.5 px-3 py-2 text-sm text-red-400 hover:bg-red-950/30 hover:text-red-300 focus:outline-none">
  <svg class="h-3.5 w-3.5 text-red-500" ...></svg>
  Delete
</button>

With Vue reactivity

vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const open = ref(false)

function onClickOutside(e) {
  if (!e.target.closest('.dropdown-root')) open.value = false
}
onMounted(() => document.addEventListener('click', onClickOutside))
onUnmounted(() => document.removeEventListener('click', onClickOutside))
</script>

<template>
  <div class="dropdown-root relative inline-block">
    <button @click="open = !open" class="...">Options</button>
    <Transition enter-active-class="transition ease-out duration-100" enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100">
      <div v-if="open" class="absolute ...">
        <!-- items -->
      </div>
    </Transition>
  </div>
</template>
vue
<script setup>
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
</script>

<template>
  <Menu as="div" class="relative inline-block">
    <MenuButton class="...">Options ▾</MenuButton>
    <MenuItems class="absolute left-0 z-10 mt-1.5 w-48 rounded-lg border border-zinc-700/60 bg-zinc-900 py-1 shadow-xl focus:outline-none">
      <MenuItem v-slot="{ active }">
        <button :class="['flex w-full px-3 py-2 text-sm text-zinc-300', active && 'bg-zinc-800 text-zinc-100']">
          Profile
        </button>
      </MenuItem>
    </MenuItems>
  </Menu>
</template>

Accessibility notes

  • Use role="menu" on the panel, role="menuitem" on each button
  • Headless UI's Menu automatically manages focus, keyboard navigation (↑↓ arrows, Enter, Escape), and ARIA
  • Never use <a> tags for actions — only for actual navigation

Released under the MIT License.