Beta, expect API change, and bugs. Hit one? Tell us.

Skip to content

Menu

A menu of actions.

<script setup lang="ts">
import { Menu } from '@shardsui/vue/menu'

const groups = [
  ['Rename', 'Duplicate'],
  ['Move to path', 'Archive']
]
</script>

<template>
  <Menu.Root>
    <Menu.Trigger
      class="flex h-8 items-center justify-center gap-1.5 rounded-md pr-2 pl-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
    >
      File
      <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path
          d="M5.75 9.5L12 15.75L18.25 9.5"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </Menu.Trigger>
    <Menu.Portal>
      <Menu.Positioner class="outline-hidden" :side-offset="8">
        <Menu.Popup
          class="origin-(--transform-origin) rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <template v-for="(group, groupIndex) in groups" :key="group[0]">
            <Menu.Separator v-if="groupIndex > 0" class="m-1 h-px bg-gray-200" />
            <Menu.Item
              v-for="item in group"
              :key="item"
              class="flex py-2 pr-8 pl-4 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
              >{{ item }}</Menu.Item
            >
          </template>
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>

Anatomy

<script setup>
import { Menu } from '@shardsui/vue/menu'
</script>

<template>
  <Menu.Root>
    <Menu.Trigger />
    <Menu.Portal>
      <Menu.Backdrop />
      <Menu.Positioner>
        <Menu.Popup>
          <Menu.Arrow />
          <Menu.Item />
          <Menu.LinkItem />
          <Menu.Separator />

          <Menu.SubmenuRoot>
            <Menu.SubmenuTrigger />
          </Menu.SubmenuRoot>

          <Menu.Group>
            <Menu.GroupLabel />
          </Menu.Group>

          <Menu.RadioGroup>
            <Menu.GroupLabel />
            <Menu.RadioItem>
              <Menu.RadioItemIndicator />
            </Menu.RadioItem>
          </Menu.RadioGroup>

          <Menu.CheckboxItem>
            <Menu.CheckboxItemIndicator />
          </Menu.CheckboxItem>

          <Menu.Viewport />
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>

Examples

Open on hover

Add the openOnHover prop to <Menu.Trigger> to open the menu on pointer hover. Tune the timing with delay (how long the pointer must rest before it opens) and closeDelay (how long it lingers after the pointer leaves), both in milliseconds.

<script setup lang="ts">
import { Menu } from '@shardsui/vue/menu'

const recent = ['Kerning & Tracking', 'Contrast Ratio', 'Flexbox & Grid']
</script>

<template>
  <Menu.Root>
    <Menu.Trigger
      open-on-hover
      :delay="200"
      class="flex h-8 items-center justify-center gap-1.5 rounded-md pr-2 pl-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
    >
      Recent
      <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path
          d="M5.75 9.5L12 15.75L18.25 9.5"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </Menu.Trigger>
    <Menu.Portal>
      <Menu.Positioner class="outline-hidden" :side-offset="8">
        <Menu.Popup
          class="origin-(--transform-origin) rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Menu.Item
            v-for="item in recent"
            :key="item"
            class="flex py-2 pr-8 pl-4 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
            >{{ item }}</Menu.Item
          >
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>

Checkbox items

<Menu.CheckboxItem> renders a menu item that toggles a setting on or off.

<script setup lang="ts">
import { ref } from 'vue'
import { Menu } from '@shardsui/vue/menu'

const columns = ref([
  { label: 'Status', checked: true },
  { label: 'Owner', checked: true },
  { label: 'Duration', checked: false }
])
</script>

<template>
  <Menu.Root>
    <Menu.Trigger
      class="flex h-8 items-center justify-center gap-1.5 rounded-md pr-2 pl-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
    >
      Columns
      <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path
          d="M5.75 9.5L12 15.75L18.25 9.5"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </Menu.Trigger>
    <Menu.Portal>
      <Menu.Positioner class="outline-hidden" :side-offset="8">
        <Menu.Popup
          class="origin-(--transform-origin) rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Menu.CheckboxItem
            v-for="column in columns"
            :key="column.label"
            v-model:checked="column.checked"
            class="grid grid-cols-[1rem_1fr] items-center gap-2 py-2 pr-8 pl-2.5 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
          >
            <Menu.CheckboxItemIndicator class="col-start-1">
              <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path
                  d="M6 14.15L10.0321 18L18 7"
                  stroke="currentColor"
                  stroke-width="1.5"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                />
              </svg>
            </Menu.CheckboxItemIndicator>
            <span class="col-start-2">{{ column.label }}</span>
          </Menu.CheckboxItem>
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>

Radio items

<Menu.RadioGroup> and <Menu.RadioItem> turn a set of items into mutually exclusive choices, like radio buttons.

<script setup lang="ts">
import { shallowRef } from 'vue'
import { Menu } from '@shardsui/vue/menu'

const speed = shallowRef('1')

const options = [
  { value: '0.75', label: '0.75×' },
  { value: '1', label: 'Normal' },
  { value: '1.5', label: '1.5×' }
]
</script>

<template>
  <Menu.Root>
    <Menu.Trigger
      class="flex h-8 items-center justify-center gap-1.5 rounded-md pr-2 pl-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
    >
      Speed
      <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path
          d="M5.75 9.5L12 15.75L18.25 9.5"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </Menu.Trigger>
    <Menu.Portal>
      <Menu.Positioner class="outline-hidden" :side-offset="8">
        <Menu.Popup
          class="origin-(--transform-origin) rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Menu.RadioGroup v-model:value="speed">
            <Menu.RadioItem
              v-for="option in options"
              :key="option.value"
              :value="option.value"
              class="grid grid-cols-[1rem_1fr] items-center gap-2 py-2 pr-8 pl-2.5 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
            >
              <Menu.RadioItemIndicator class="col-start-1">
                <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path
                    d="M6 14.15L10.0321 18L18 7"
                    stroke="currentColor"
                    stroke-width="1.5"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                  />
                </svg>
              </Menu.RadioItemIndicator>
              <span class="col-start-2">{{ option.label }}</span>
            </Menu.RadioItem>
          </Menu.RadioGroup>
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>

Close on click

Set closeOnClick to decide whether clicking an item dismisses the menu.

<template>
  <!-- Close the menu when a checkbox item is clicked -->
  <Menu.CheckboxItem close-on-click />

  <!-- Keep the menu open when an item is clicked -->
  <Menu.Item :close-on-click="false" />
</template>

Group labels

<Menu.GroupLabel> gives a <Menu.Group> or <Menu.RadioGroup> a heading.

<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import { Menu } from '@shardsui/vue/menu'

const sort = shallowRef('newest')

const sortOptions = [
  { value: 'newest', label: 'Newest' },
  { value: 'oldest', label: 'Oldest' }
]

const panels = ref([
  { label: 'Transcript', checked: true },
  { label: 'Notes', checked: false }
])
</script>

<template>
  <Menu.Root>
    <Menu.Trigger
      class="flex h-8 items-center justify-center gap-1.5 rounded-md pr-2 pl-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
    >
      View
      <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path
          d="M5.75 9.5L12 15.75L18.25 9.5"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </Menu.Trigger>
    <Menu.Portal>
      <Menu.Positioner class="outline-hidden" :side-offset="8">
        <Menu.Popup
          class="origin-(--transform-origin) rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Menu.RadioGroup v-model:value="sort">
            <Menu.GroupLabel class="py-2 pr-8 pl-8.5 text-sm/4 text-gray-600 select-none"
              >Sort by</Menu.GroupLabel
            >
            <Menu.RadioItem
              v-for="option in sortOptions"
              :key="option.value"
              :value="option.value"
              class="grid grid-cols-[1rem_1fr] items-center gap-2 py-2 pr-8 pl-2.5 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
            >
              <Menu.RadioItemIndicator class="col-start-1">
                <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path
                    d="M6 14.15L10.0321 18L18 7"
                    stroke="currentColor"
                    stroke-width="1.5"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                  />
                </svg>
              </Menu.RadioItemIndicator>
              <span class="col-start-2">{{ option.label }}</span>
            </Menu.RadioItem>
          </Menu.RadioGroup>

          <Menu.Separator class="m-1 h-px bg-gray-200" />

          <Menu.Group>
            <Menu.GroupLabel class="py-2 pr-8 pl-8.5 text-sm/4 text-gray-600 select-none"
              >Panels</Menu.GroupLabel
            >
            <Menu.CheckboxItem
              v-for="panel in panels"
              :key="panel.label"
              v-model:checked="panel.checked"
              class="grid grid-cols-[1rem_1fr] items-center gap-2 py-2 pr-8 pl-2.5 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
            >
              <Menu.CheckboxItemIndicator class="col-start-1">
                <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path
                    d="M6 14.15L10.0321 18L18 7"
                    stroke="currentColor"
                    stroke-width="1.5"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                  />
                </svg>
              </Menu.CheckboxItemIndicator>
              <span class="col-start-2">{{ panel.label }}</span>
            </Menu.CheckboxItem>
          </Menu.Group>
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>

Nested menu

Nest another menu inside the current one with <Menu.SubmenuRoot>, and mark the item that opens it with <Menu.SubmenuTrigger>.

<template>
  <Menu.Root>
    <Menu.Trigger />
    <Menu.Portal>
      <Menu.Positioner>
        <Menu.Popup>
          <Menu.Arrow />
          <Menu.Item />

          <!-- Submenu -->
          <Menu.SubmenuRoot>
            <Menu.SubmenuTrigger />
            <Menu.Positioner>
              <Menu.Popup>
                <!-- Submenu items -->
              </Menu.Popup>
            </Menu.Positioner>
          </Menu.SubmenuRoot>
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>
<script setup lang="ts">
import { Menu } from '@shardsui/vue/menu'

const accessLevels = ['Viewer', 'Editor', 'Admin']

function submenuOffset({ side }: { side: string }) {
  return side === 'top' || side === 'bottom' ? 4 : -4
}
</script>

<template>
  <Menu.Root>
    <Menu.Trigger
      class="flex h-8 items-center justify-center gap-1.5 rounded-md pr-2 pl-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
    >
      Share
      <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path
          d="M5.75 9.5L12 15.75L18.25 9.5"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </Menu.Trigger>
    <Menu.Portal>
      <Menu.Positioner class="outline-hidden" :side-offset="8">
        <Menu.Popup
          class="origin-(--transform-origin) rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Menu.Item
            class="flex py-2 pr-6 pl-4 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
            >Copy link</Menu.Item
          >

          <Menu.SubmenuRoot>
            <Menu.SubmenuTrigger
              class="flex items-center justify-between gap-4 py-2 pr-2 pl-4 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900 data-popup-open:relative data-popup-open:z-0 data-popup-open:before:absolute data-popup-open:before:inset-x-1 data-popup-open:before:inset-y-0 data-popup-open:before:z-[-1] data-popup-open:before:rounded-xs data-popup-open:before:bg-gray-100 data-highlighted:data-popup-open:before:bg-gray-900"
            >
              Invite as
              <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path
                  d="M9.5 18.25L15.75 12L9.5 5.75"
                  stroke="currentColor"
                  stroke-width="1.5"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                />
              </svg>
            </Menu.SubmenuTrigger>
            <Menu.Portal>
              <Menu.Positioner
                class="outline-hidden"
                :side-offset="submenuOffset"
                :align-offset="submenuOffset"
              >
                <Menu.Popup
                  class="origin-(--transform-origin) rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
                >
                  <Menu.Item
                    v-for="level in accessLevels"
                    :key="level"
                    class="flex py-2 pr-6 pl-4 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
                    >{{ level }}</Menu.Item
                  >
                </Menu.Popup>
              </Menu.Positioner>
            </Menu.Portal>
          </Menu.SubmenuRoot>

          <Menu.Separator class="m-1 h-px bg-gray-200" />

          <Menu.Item
            class="flex py-2 pr-6 pl-4 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
            >Manage access</Menu.Item
          >
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>

Navigate to another page

<Menu.LinkItem> renders a menu item as a link.

<template>
  <Menu.LinkItem href="/projects">Go to Projects</Menu.LinkItem>
</template>

Open a dialog

To open a dialog from a menu, hold the dialog's open state yourself and flip it from the item's @click handler.

<script setup>
import { Dialog } from '@shardsui/vue/dialog'
import { Menu } from '@shardsui/vue/menu'
import { shallowRef } from 'vue'

const dialogOpen = shallowRef(false)
</script>

<template>
  <Menu.Root>
    <Menu.Trigger>Open menu</Menu.Trigger>
    <Menu.Portal>
      <Menu.Positioner>
        <Menu.Popup>
          <!-- Open the dialog when the menu item is clicked -->
          <Menu.Item @click="dialogOpen = true">Open dialog</Menu.Item>
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>

  <!-- Control the dialog state -->
  <Dialog.Root v-model:open="dialogOpen">
    <Dialog.Portal>
      <Dialog.Backdrop />
      <Dialog.Popup>
        <!-- Rest of the dialog -->
      </Dialog.Popup>
    </Dialog.Portal>
  </Dialog.Root>
</template>

Detached triggers

A menu's trigger can sit inside <Menu.Root> (as in the hero demo above) or somewhere else entirely. When it lives outside, create a handle with Menu.createHandle() and pass it to both the trigger and the root.

Only top-level menus support detached triggers; a submenu's trigger always stays inside its SubmenuRoot.

<!-- [!code word::handle="demoMenu"] -->
<script setup>
import { Menu } from '@shardsui/vue/menu'

const demoMenu = Menu.createHandle()
</script>

<template>
  <Menu.Trigger :handle="demoMenu">Actions</Menu.Trigger>

  <Menu.Root :handle="demoMenu">
    <Menu.Portal>
      <Menu.Positioner>
        <Menu.Popup>
          <Menu.Item>Edit</Menu.Item>
          <Menu.Item>Share</Menu.Item>
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>
<script setup lang="ts">
import { Menu } from '@shardsui/vue/menu'

const demoMenu = Menu.createHandle()
</script>

<template>
  <Menu.Trigger
    :handle="demoMenu"
    aria-label="Actions"
    class="flex size-8 items-center justify-center rounded-md text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
  >
    <svg class="size-5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <rect x="10" y="3" width="4" height="4" rx="2" fill="currentColor" />
      <rect x="10" y="10" width="4" height="4" rx="2" fill="currentColor" />
      <rect x="10" y="17" width="4" height="4" rx="2" fill="currentColor" />
    </svg>
  </Menu.Trigger>

  <Menu.Root :handle="demoMenu">
    <Menu.Portal>
      <Menu.Positioner :side-offset="8" class="outline-hidden">
        <Menu.Popup
          class="origin-(--transform-origin) rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Menu.Item
            class="flex py-2 pr-8 pl-4 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
            >Rename</Menu.Item
          >
          <Menu.Item
            class="flex py-2 pr-8 pl-4 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
            >Duplicate</Menu.Item
          >
          <Menu.Separator class="m-1 h-px bg-gray-200" />
          <Menu.Item
            class="flex py-2 pr-8 pl-4 text-sm/4 text-red-800 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
            >Delete</Menu.Item
          >
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>

Multiple triggers

Several triggers can open the same menu. Render more than one <Menu.Trigger> inside a single <Menu.Root>, or point several detached triggers at the same handle.

<template>
  <Menu.Root>
    <Menu.Trigger>Row actions</Menu.Trigger>
    <Menu.Trigger>Quick actions</Menu.Trigger>
    <!-- Rest of the menu -->
  </Menu.Root>
</template>
<script setup>
const projectMenu = Menu.createHandle()
</script>

<template>
  <Menu.Trigger :handle="projectMenu">Row actions</Menu.Trigger>
  <Menu.Trigger :handle="projectMenu">Quick actions</Menu.Trigger>

  <Menu.Root :handle="projectMenu">
    <!-- Rest of the menu -->
  </Menu.Root>
</template>

A menu can show different content depending on which trigger opened it. Give each <Menu.Trigger> a payload prop and read it from the payload argument of <Menu.Root>'s default slot (typed by the handle's type argument).

<script setup lang="ts">
import { Menu } from '@shardsui/vue/menu'

const menus = {
  course: ['Rename', 'Duplicate', 'Archive'],
  lesson: ['Add note', 'Bookmark', 'Share']
}

const demoMenu = Menu.createHandle<keyof typeof menus>()
</script>

<template>
  <Menu.Trigger :handle="demoMenu" :payload="'course'">Course</Menu.Trigger>
  <Menu.Trigger :handle="demoMenu" :payload="'lesson'">Lesson</Menu.Trigger>

  <Menu.Root v-slot="{ payload }" :handle="demoMenu">
    <Menu.Portal>
      <Menu.Positioner>
        <Menu.Popup>
          <Menu.Viewport>
            <template v-if="payload">
              <Menu.Item v-for="item in menus[payload]" :key="item">{{ item }}</Menu.Item>
            </template>
          </Menu.Viewport>
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>

Controlled mode with multiple triggers

Drive the open state yourself with v-model:open on <Menu.Root>. With several triggers, track the active one through v-model:trigger-id on <Menu.Root> and a matching id on each <Menu.Trigger>: writing an id to the model selects that trigger.

<script setup lang="ts">
import { shallowRef } from 'vue'
import { Menu } from '@shardsui/vue/menu'

const MENUS = {
  file: ['Rename', 'Archive'],
  edit: ['Add note', 'Bookmark'],
  view: ['Grid', 'List']
}

type MenuKey = keyof typeof MENUS

const demoMenu = Menu.createHandle<MenuKey>()

const triggers: { id: string; payload: MenuKey; label: string }[] = [
  { id: 'menu-file', payload: 'file', label: 'File' },
  { id: 'menu-edit', payload: 'edit', label: 'Edit' },
  { id: 'menu-view', payload: 'view', label: 'View' }
]

const open = shallowRef(false)
const triggerId = shallowRef<string | null>(null)

function openViewMenu() {
  triggerId.value = 'menu-view'
  open.value = true
}
</script>

<template>
  <div class="flex flex-wrap items-center gap-2">
    <Menu.Trigger
      v-for="trigger in triggers"
      :key="trigger.id"
      :handle="demoMenu"
      :payload="trigger.payload"
      :id="trigger.id"
      class="flex h-8 items-center justify-center rounded-md px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
    >
      {{ trigger.label }}
    </Menu.Trigger>

    <button
      type="button"
      class="flex h-8 items-center justify-center rounded-md px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
      @click="openViewMenu"
    >
      Open View menu
    </button>
  </div>

  <Menu.Root
    v-slot="{ payload }"
    v-model:open="open"
    v-model:trigger-id="triggerId"
    :handle="demoMenu"
  >
    <Menu.Portal>
      <Menu.Positioner :side-offset="8" class="outline-hidden">
        <Menu.Popup
          class="origin-(--transform-origin) rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <template v-if="payload">
            <Menu.Item
              v-for="item in MENUS[payload]"
              :key="item"
              class="flex py-2 pr-8 pl-4 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
              >{{ item }}</Menu.Item
            >
          </template>
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>

Arrow

<Menu.Arrow> renders an arrow inside the popup that points at the trigger.

<script setup lang="ts">
import { Menu } from '@shardsui/vue/menu'
</script>

<template>
  <Menu.Root>
    <Menu.Trigger
      class="flex h-8 items-center justify-center gap-1.5 rounded-md pr-2 pl-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
    >
      Share
      <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path
          d="M5.75 9.5L12 15.75L18.25 9.5"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </Menu.Trigger>
    <Menu.Portal>
      <Menu.Positioner class="outline-hidden" :side-offset="8">
        <Menu.Popup
          class="origin-(--transform-origin) rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Menu.Arrow
            class="data-[side=bottom]:-top-2 data-[side=left]:-right-3.25 data-[side=left]:rotate-90 data-[side=right]:-left-3.25 data-[side=right]:-rotate-90 data-[side=top]:-bottom-2 data-[side=top]:rotate-180"
          >
            <svg width="20" height="10" viewBox="0 0 20 10" fill="none">
              <path
                d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
                class="fill-gray-50"
              />
              <path
                d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
                class="fill-gray-200"
              />
              <path
                d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
              />
            </svg>
          </Menu.Arrow>
          <Menu.Item
            v-for="item in ['Copy link', 'Manage access']"
            :key="item"
            class="flex py-2 pr-8 pl-4 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
            >{{ item }}</Menu.Item
          >
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</template>

Animating the Menu

When several detached triggers share one menu, its position, size, and content can animate as it travels between them.

Position and Size

For position, transition the left, right, top, and bottom properties of the Positioner part. For size, transition the width and height of the Popup part.

Content

When different triggers swap what the menu shows, wrap the content in a <Menu.Viewport> to animate the change. It renders a div carrying data-activation-direction — a space-separated horizontal and vertical pair such as right down — so your animation can lean toward the direction of travel. Match a single token with the ~= attribute selector, such as [data-activation-direction~='right'].

Within <Menu.Viewport>, each piece of content sits in a div tagged with a transition data attribute:

  • data-current: the content on screen when nothing is transitioning, or the incoming content during one.
  • data-previous: the outgoing content during a transition.
<script setup lang="ts">
import { Menu } from '@shardsui/vue/menu'

const MENUS = {
  file: {
    heading: 'File',
    groups: [
      ['Rename', 'Duplicate'],
      ['Move', 'Archive']
    ]
  },
  edit: {
    heading: 'Edit',
    groups: [
      ['Add note', 'Bookmark'],
      ['Download', 'Share']
    ]
  },
  view: {
    heading: 'View',
    groups: [['Grid', 'List'], ['Reset']]
  }
}

type MenuKey = keyof typeof MENUS

const demoMenu = Menu.createHandle<MenuKey>()

const triggers: { payload: MenuKey; label: string }[] = [
  { payload: 'file', label: 'File' },
  { payload: 'edit', label: 'Edit' },
  { payload: 'view', label: 'View' }
]
</script>

<template>
  <div class="flex flex-wrap items-center gap-2">
    <Menu.Trigger
      v-for="trigger in triggers"
      :key="trigger.payload"
      :handle="demoMenu"
      :payload="trigger.payload"
      class="flex h-8 items-center justify-center rounded-md px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
      >{{ trigger.label }}</Menu.Trigger
    >

    <Menu.Root v-slot="{ payload }" :handle="demoMenu" :modal="false">
      <Menu.Portal>
        <Menu.Positioner
          :side-offset="8"
          class="h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) outline-hidden transition-[top,left,right,bottom,transform] duration-350 ease-out-quint data-instant:transition-none"
        >
          <Menu.Popup
            class="relative h-(--popup-height,auto) w-(--popup-width,auto) origin-(--transform-origin) rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[width,height,opacity,scale] duration-350 ease-out-quint data-ending-style:scale-95 data-ending-style:opacity-0 data-instant:transition-none data-starting-style:scale-95 data-starting-style:opacity-0"
          >
            <Menu.Arrow
              class="flex transition-[left] duration-350 ease-out-quint data-[side=bottom]:-top-2 data-[side=left]:-right-3.25 data-[side=left]:rotate-90 data-[side=right]:-left-3.25 data-[side=right]:-rotate-90 data-[side=top]:-bottom-2 data-[side=top]:rotate-180"
            >
              <svg width="20" height="10" viewBox="0 0 20 10" fill="none">
                <path
                  d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
                  class="fill-gray-50"
                />
                <path
                  d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
                  class="fill-gray-200"
                />
                <path
                  d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
                />
              </svg>
            </Menu.Arrow>

            <Menu.Viewport
              class="relative box-border size-full overflow-clip p-0 **:data-current:w-(--popup-width) **:data-current:translate-x-0 **:data-current:opacity-100 **:data-current:transition-[translate,opacity] **:data-current:duration-[350ms,175ms] **:data-current:ease-out-quint **:data-previous:w-(--popup-width) **:data-previous:translate-x-0 **:data-previous:opacity-100 **:data-previous:transition-[translate,opacity] **:data-previous:duration-[350ms,175ms] **:data-previous:ease-out-quint data-[activation-direction~='left']:[&_[data-current][data-starting-style]]:-translate-x-1/2 data-[activation-direction~='left']:[&_[data-current][data-starting-style]]:opacity-0 data-[activation-direction~='right']:[&_[data-current][data-starting-style]]:translate-x-1/2 data-[activation-direction~='right']:[&_[data-current][data-starting-style]]:opacity-0 data-[activation-direction~='left']:[&_[data-previous][data-ending-style]]:translate-x-1/2 data-[activation-direction~='left']:[&_[data-previous][data-ending-style]]:opacity-0 data-[activation-direction~='right']:[&_[data-previous][data-ending-style]]:-translate-x-1/2 data-[activation-direction~='right']:[&_[data-previous][data-ending-style]]:opacity-0"
            >
              <template v-if="payload">
                <template v-for="(group, groupIndex) in MENUS[payload].groups" :key="group[0]">
                  <Menu.Group>
                    <Menu.GroupLabel
                      v-if="groupIndex === 0"
                      class="px-4 py-2 text-xs tracking-wider text-gray-500 uppercase"
                    >
                      {{ MENUS[payload].heading }}
                    </Menu.GroupLabel>
                    <Menu.Item
                      v-for="item in group"
                      :key="item"
                      class="flex py-2 pr-8 pl-4 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
                      >{{ item }}</Menu.Item
                    >
                  </Menu.Group>
                  <Menu.Separator
                    v-if="groupIndex < MENUS[payload].groups.length - 1"
                    class="m-1 h-px bg-gray-200"
                  />
                </template>
              </template>
            </Menu.Viewport>
          </Menu.Popup>
        </Menu.Positioner>
      </Menu.Portal>
    </Menu.Root>
  </div>
</template>

API reference

Root

Groups all parts of the menu. Doesn't render its own HTML element.

PropTypeDefault

Trigger

A button that opens the menu. Renders a <button> element.

PropTypeDefault
AttributeDescription
data-popup-openPresent when the menu is open and this trigger is active.
data-pressedPresent when the menu is open and this trigger is active.
data-disabledPresent when the trigger is disabled.

Backdrop

An overlay displayed beneath the popup. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the menu is open.
data-closedPresent when the menu is closed.
data-starting-stylePresent when the backdrop is animating in.
data-ending-stylePresent when the backdrop is animating out.

Portal

A portal that moves the popup out to <body>, clear of ancestor clipping and stacking. Renders a <div> element.

PropTypeDefault

Positioner

Positions the menu popup against the trigger. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-sideWhich side of the anchor the popup is on.
data-alignHow the popup is aligned relative to the side.
data-anchor-hiddenPresent when the anchor is hidden.
data-instantPresent when animations should be instant.
data-nestedPresent when the menu is a submenu.
CSS VariableDescription
--available-widthAvailable width between the anchor and the viewport edge.
--available-heightAvailable height between the anchor and the viewport edge.
--anchor-widthWidth of the anchor element.
--anchor-heightHeight of the anchor element.
--transform-originTransform origin for scale animations.
--positioner-widthThe positioner element's width. Set while a <Menu.Viewport> morphs content.
--positioner-heightThe positioner element's height. Set while a <Menu.Viewport> morphs content.

Popup

A container for the menu items. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the menu is open.
data-closedPresent when the menu is closed.
data-sideWhich side of the anchor the popup is on.
data-alignHow the popup is aligned relative to the side.
data-starting-stylePresent when the menu is animating in.
data-ending-stylePresent when the menu is animating out.
data-instantPresent when animations should be instant.
data-nestedPresent when the menu is a submenu.
CSS VariableDescription
--popup-widthThe popup element's width. Set while a <Menu.Viewport> morphs content.
--popup-heightThe popup element's height. Set while a <Menu.Viewport> morphs content.

Viewport

A viewport for displaying content transitions. Only needed when one popup has multiple triggers, its content changes with the trigger, and the switch is animated. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-activation-directionDirection the popup was activated from. A horizontal and a vertical token separated by a space, e.g. right down; either can be empty, so match one with [data-activation-direction~='right'].
data-transitioningPresent while a content transition is in progress.
data-currentApplied to the wrapper of the current content.
data-previousApplied to the wrapper of the outgoing content, present only during a transition.
data-starting-styleApplied to the data-current wrapper while the incoming content is animating in.
data-ending-styleApplied to the data-previous wrapper while the outgoing content is animating out.
data-instantPresent when animations should be instant.
CSS VariableDescription
--popup-widthWidth of the outgoing popup content, set on the data-previous container. Use it to freeze the popup size while the content morphs.
--popup-heightHeight of the outgoing popup content, set on the data-previous container.

Arrow

Displays an element positioned against the anchor. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-sideWhich side of the anchor the popup is on.
data-alignHow the popup is aligned relative to the side.
data-uncenteredPresent when the arrow cannot be centered.

Item

An individual interactive item in the menu. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-highlightedPresent when the item is highlighted.
data-disabledPresent when the item is disabled.

LinkItem

A link in the menu, for navigating to a different page or section. Renders an <a> element.

PropTypeDefault
AttributeDescription
data-highlightedPresent when the item is highlighted.

SubmenuRoot

Groups all parts of a submenu. Doesn't render its own HTML element.

PropTypeDefault

SubmenuTrigger

A menu item that opens a submenu. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-popup-openPresent when the submenu is open.
data-highlightedPresent when the trigger is highlighted.
data-disabledPresent when the trigger is disabled.

Group

Groups related menu items with the corresponding label. Renders a <div> element.

PropTypeDefault

GroupLabel

An accessible label that is automatically associated with its parent group. Renders a <div> element.

PropTypeDefault

RadioGroup

Groups related radio items, labelled by a nested <Menu.GroupLabel>. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-disabledPresent when the radio group is disabled.

RadioItem

A menu item that works like a radio button in a given group. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-checkedPresent when the item is selected.
data-uncheckedPresent when the item is not selected.
data-highlightedPresent when the item is highlighted.
data-disabledPresent when the item is disabled.

RadioItemIndicator

Indicates whether the radio item is selected. Renders a <span> element.

PropTypeDefault
AttributeDescription
data-checkedPresent when the parent item is selected.
data-uncheckedPresent when the parent item is not selected.
data-disabledPresent when the item is disabled.
data-highlightedPresent when the parent item is highlighted.
data-starting-stylePresent when the indicator is animating in.
data-ending-stylePresent when the indicator is animating out.

CheckboxItem

A menu item that toggles a setting on or off. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-checkedPresent when the item is checked.
data-uncheckedPresent when the item is not checked.
data-disabledPresent when the item is disabled.
data-highlightedPresent when the item is highlighted.

CheckboxItemIndicator

Indicates whether the checkbox item is ticked. Renders a <span> element.

PropTypeDefault
AttributeDescription
data-checkedPresent when the parent item is checked.
data-uncheckedPresent when the parent item is not checked.
data-disabledPresent when the item is disabled.
data-highlightedPresent when the parent item is highlighted.
data-starting-stylePresent when the indicator is animating in.
data-ending-stylePresent when the indicator is animating out.

Separator

A separator element accessible to screen readers. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-orientationIndicates the orientation of the separator.

Handle

Connects a <Menu.Root> with detached <Menu.Trigger> components, and controls the menu imperatively. Pass a type argument to type the payload.

const menu = Menu.createHandle<Payload>()
MemberType