ShardsUI is in beta. APIs may change before 1.0.

Menu

A menu of actions.

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

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

<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
    {@render chevronDownIcon()}
  </Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner class="outline-hidden" sideOffset={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"
      >
        {#each groups as group, groupIndex (group[0])}
          {#if groupIndex > 0}
            <Menu.Separator class="m-1 h-px bg-gray-200" />
          {/if}
          {#each group as item (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"
              >{item}</Menu.Item
            >
          {/each}
        {/each}
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

{#snippet chevronDownIcon()}
  <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>
{/snippet}

Anatomy

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

<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>

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 lang="ts">
  import { Menu } from '@shardsui/svelte/menu'

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

<Menu.Root>
  <Menu.Trigger
    openOnHover
    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
    {@render chevronDownIcon()}
  </Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner class="outline-hidden" sideOffset={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"
      >
        {#each recent as item (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"
            >{item}</Menu.Item
          >
        {/each}
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

{#snippet chevronDownIcon()}
  <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>
{/snippet}

Checkbox items

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

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

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

<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
    {@render chevronDownIcon()}
  </Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner class="outline-hidden" sideOffset={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"
      >
        {#each columns as column (column.label)}
          <Menu.CheckboxItem
            bind: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">
              {@render checkIcon()}
            </Menu.CheckboxItemIndicator>
            <span class="col-start-2">{column.label}</span>
          </Menu.CheckboxItem>
        {/each}
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

{#snippet chevronDownIcon()}
  <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>
{/snippet}

{#snippet checkIcon()}
  <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>
{/snippet}

Radio items

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

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

  let speed = $state('1')

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

<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
    {@render chevronDownIcon()}
  </Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner class="outline-hidden" sideOffset={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 bind:value={speed}>
          {#each options as option (option.value)}
            <Menu.RadioItem
              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">
                {@render checkIcon()}
              </Menu.RadioItemIndicator>
              <span class="col-start-2">{option.label}</span>
            </Menu.RadioItem>
          {/each}
        </Menu.RadioGroup>
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

{#snippet chevronDownIcon()}
  <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>
{/snippet}

{#snippet checkIcon()}
  <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>
{/snippet}

Close on click

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

<!-- Close the menu when a checkbox item is clicked -->
<Menu.CheckboxItem closeOnClick />

<!-- Keep the menu open when an item is clicked -->
<Menu.Item closeOnClick={false} />

Group labels

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

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

  let sort = $state('newest')

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

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

<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
    {@render chevronDownIcon()}
  </Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner class="outline-hidden" sideOffset={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 bind:value={sort}>
          <Menu.GroupLabel class="py-2 pr-8 pl-8.5 text-sm/4 text-gray-600 select-none"
            >Sort by</Menu.GroupLabel
          >
          {#each sortOptions as option (option.value)}
            <Menu.RadioItem
              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">
                {@render checkIcon()}
              </Menu.RadioItemIndicator>
              <span class="col-start-2">{option.label}</span>
            </Menu.RadioItem>
          {/each}
        </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
          >
          {#each panels as panel (panel.label)}
            <Menu.CheckboxItem
              bind: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">
                {@render checkIcon()}
              </Menu.CheckboxItemIndicator>
              <span class="col-start-2">{panel.label}</span>
            </Menu.CheckboxItem>
          {/each}
        </Menu.Group>
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

{#snippet chevronDownIcon()}
  <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>
{/snippet}

{#snippet checkIcon()}
  <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>
{/snippet}

Nested menu

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

<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>
<script lang="ts">
  import { Menu } from '@shardsui/svelte/menu'

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

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

<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
    {@render chevronDownIcon()}
  </Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner class="outline-hidden" sideOffset={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
            {@render chevronRightIcon()}
          </Menu.SubmenuTrigger>
          <Menu.Portal>
            <Menu.Positioner
              class="outline-hidden"
              sideOffset={submenuOffset}
              alignOffset={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"
              >
                {#each accessLevels as level (level)}
                  <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"
                    >{level}</Menu.Item
                  >
                {/each}
              </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>

{#snippet chevronDownIcon()}
  <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>
{/snippet}

{#snippet chevronRightIcon()}
  <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>
{/snippet}

Navigate to another page

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

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

Open a dialog

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

<script>
  import { Dialog } from '@shardsui/svelte/dialog'
  import { Menu } from '@shardsui/svelte/menu'

  let dialogOpen = $state(false)
</script>

<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 onclick={() => (dialogOpen = true)}>Open dialog</Menu.Item>
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

<!-- Control the dialog state -->
<Dialog.Root bind:open={dialogOpen}>
  <Dialog.Portal>
    <Dialog.Backdrop />
    <Dialog.Popup>
      <!-- Rest of the dialog -->
    </Dialog.Popup>
  </Dialog.Portal>
</Dialog.Root>

Detached triggers

A menu's trigger can sit inside <Menu.Root> (as in the hero demo above) or somewhere else entirely. When the trigger and the menu content belong in different parts of the tree, create a handle with new Menu.Handle() 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.

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

  const demoMenu = new Menu.Handle()
</script>

<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>
<script lang="ts">
  import { Menu } from '@shardsui/svelte/menu'

  const demoMenu = new Menu.Handle()
</script>

<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"
>
  {@render dotsIcon()}
</Menu.Trigger>

<Menu.Root handle={demoMenu}>
  <Menu.Portal>
    <Menu.Positioner sideOffset={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>

{#snippet dotsIcon()}
  <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>
{/snippet}

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.

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

<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>

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 children snippet (typed by the handle's type argument).

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

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

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

<Menu.Trigger handle={demoMenu} payload={'course'}>Course</Menu.Trigger>
<Menu.Trigger handle={demoMenu} payload={'lesson'}>Lesson</Menu.Trigger>

<Menu.Root handle={demoMenu}>
  {#snippet children({ payload })}
    <Menu.Portal>
      <Menu.Positioner>
        <Menu.Popup>
          <Menu.Viewport>
            {#if payload}
              {#each menus[payload] as item}
                <Menu.Item>{item}</Menu.Item>
              {/each}
            {/if}
          </Menu.Viewport>
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  {/snippet}
</Menu.Root>

Controlled mode with multiple triggers

Drive the open state yourself with open and onOpenChange on <Menu.Root>. With several triggers, track the active one through bind:triggerId on <Menu.Root> and a matching id on each <Menu.Trigger>: the binding follows whichever trigger opened the menu, and writing an id to it selects that trigger.

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

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

  type MenuKey = keyof typeof MENUS

  const demoMenu = new Menu.Handle<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' }
  ]

  let open = $state(false)
  let triggerId = $state<string | null>(null)

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

<div class="flex flex-wrap items-center gap-2">
  {#each triggers as trigger (trigger.id)}
    <Menu.Trigger
      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>
  {/each}

  <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"
    onclick={openViewMenu}>Open View menu</button
  >
</div>

<Menu.Root handle={demoMenu} bind:open bind:triggerId>
  {#snippet children({ payload })}
    <Menu.Portal>
      <Menu.Positioner sideOffset={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"
        >
          {#if payload}
            {#each MENUS[payload] as item (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"
                >{item}</Menu.Item
              >
            {/each}
          {/if}
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  {/snippet}
</Menu.Root>

Arrow

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

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

<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
    {@render chevronDownIcon()}
  </Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner class="outline-hidden" sideOffset={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"
        >
          {@render arrow()}
        </Menu.Arrow>
        {#each ['Copy link', 'Manage access'] as item (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"
            >{item}</Menu.Item
          >
        {/each}
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

{#snippet chevronDownIcon()}
  <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>
{/snippet}

{#snippet arrow()}
  <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>
{/snippet}

Animating the Menu

When several detached triggers share one menu, you can animate its position, size, and content 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 — space-separated horizontal (left or right) and vertical (up or down) values such as right down — so your animation can lean toward the direction of travel.

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 lang="ts">
  import { Menu } from '@shardsui/svelte/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 = new Menu.Handle<MenuKey>()

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

<div class="flex flex-wrap items-center gap-2">
  {#each triggers as trigger (trigger.payload)}
    <Menu.Trigger
      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
    >
  {/each}

  <Menu.Root handle={demoMenu} modal={false}>
    {#snippet children({ payload })}
      <Menu.Portal>
        <Menu.Positioner
          sideOffset={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"
            >
              {@render arrow()}
            </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"
            >
              {#if payload}
                {@const menu = MENUS[payload]}
                {#each menu.groups as group, groupIndex (group[0])}
                  <Menu.Group>
                    {#if groupIndex === 0}
                      <Menu.GroupLabel
                        class="px-4 py-2 text-xs tracking-wider text-gray-500 uppercase"
                      >
                        {menu.heading}
                      </Menu.GroupLabel>
                    {/if}
                    {#each group as item (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"
                        >{item}</Menu.Item
                      >
                    {/each}
                  </Menu.Group>
                  {#if groupIndex < menu.groups.length - 1}
                    <Menu.Separator class="m-1 h-px bg-gray-200" />
                  {/if}
                {/each}
              {/if}
            </Menu.Viewport>
          </Menu.Popup>
        </Menu.Positioner>
      </Menu.Portal>
    {/snippet}
  </Menu.Root>
</div>

{#snippet arrow()}
  <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>
{/snippet}

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. This component is only required if one popup can be opened by multiple triggers, its content changes based on the trigger, and switching between them 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 that can be used to navigate 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 = new Menu.Handle<Payload>()
MemberType