Skip to content
Sponsored by

MagicMenu

MagicMenu is a flexible collection of components intended to build various types of menus and navigation.

<template>
  <magic-menu-provider
    id="magic-menu-default-demo"
    class="bg-surface-elevation-high inline-flex gap-2 rounded-2xl p-1"
  >
    <magic-menu-view v-for="(menuItem, index) in menu" :key="index">
      <magic-menu-trigger v-slot="{ viewActive }" as-child>
        <m-button :mode="viewActive ? 'translucent' : 'ghost'" size="xs">
          {{ menuItem.label }}
        </m-button>
      </magic-menu-trigger>
      <magic-menu-content :middleware="offsetMiddleware">
        <div class="bg-surface-elevation-high w-[220px] rounded-2xl p-1">
          <nested-menu
            v-for="(item, itemIndex) in menuItem.items"
            :key="itemIndex"
            :item="item"
          />
        </div>
      </magic-menu-content>
    </magic-menu-view>
  </magic-menu-provider>
</template>

<script lang="ts" setup>
import NestedMenu from './components/NestedMenu.vue'

import { MButton } from '@maas/mirror/vue'
import { offset } from '@floating-ui/dom'

import '@maas/vue-equipment/utils/css/animations/fade-in.css'
import '@maas/vue-equipment/utils/css/animations/fade-out.css'

const offsetMiddleware = [offset({ crossAxis: -4, mainAxis: 8 })]

const menu = [
  {
    label: 'Edit',
    type: 'button',
    items: [
      { label: 'Undo', cmd: '⌘ Z' },
      { label: 'Redo', cmd: '⇧ ⌘ Z' },
      {
        label: 'Find',
        items: [
          { label: 'Search the web…' },
          { label: 'Find…' },
          { label: 'Find Next' },
          { label: 'Find Previous' },
        ],
      },
      { label: 'Cut', cmd: '⌘ X' },
      { label: 'Copy', cmd: '⌘ C' },
      { label: 'Paste', cmd: '⌘ V' },
    ],
  },
  {
    label: 'File',
    type: 'ghost',
    items: [
      { label: 'New Tab', cmd: '⌘ T' },
      { label: 'New Window', cmd: '⌘ W' },
      { label: 'New Incognito Window', disabled: true },
      {
        label: 'Share',
        items: [
          { label: 'Email Link', disabled: true },
          { label: 'Messages' },
          { label: 'Notes' },
          {
            label: 'Socials',
            items: [{ label: 'Instagram' }, { label: 'Bluesky' }],
          },
        ],
      },
      { label: 'Print', cmd: '⌘ P' },
    ],
  },
  {
    label: 'View',
    type: 'ghost',
    items: [
      { label: 'Show Bookmarks', cmd: '⇧ ⌘ B' },
      { label: 'Show Full URLs' },
      { label: 'Reload', cmd: '⌘ R' },
      { label: 'Force Reload', cmd: '⇧ ⌘ R' },
      { label: 'Fullscreen', cmd: '⌘ F' },
      { label: 'Hide Sidebar' },
    ],
  },
  {
    label: 'Profiles',
    type: 'ghost',
    items: [
      {
        label: 'Christoph Jeworutzki',
        items: [
          { label: 'User Settings' },
          { label: 'Edit Profile' },
          { label: 'Subscribtions' },
        ],
      },
      {
        label: 'Robin Scholz',
        items: [
          { label: 'User Settings' },
          { label: 'Edit Profile' },
          { label: 'Subscribtions' },
        ],
      },
      { label: 'Edit…' },
      { label: 'Add Profile…' },
    ],
  },
]
</script>

Overview

Anatomy

vue
<template>
  <magic-menu-provider id="your-menu-id">
    <magic-menu-view>
      <magic-menu-trigger>
        <!-- your content -->
      </magic-menu-trigger>
      <magic-menu-content>
        <magic-menu-item>
          <!-- your content -->
        </magic-menu-item>
      </magic-menu-content>
    </magic-menu-view>
  </magic-menu-provider>
</template>

<script setup>
const { selectView } = useMagicMenu('your-menu-id')
</script>

Installation

CLI

Add @maas/vue-equipment to your dependencies.

sh
pnpm install @maas/vue-equipment
sh
npm install @maas/vue-equipment
sh
yarn add @maas/vue-equipment
sh
bun install @maas/vue-equipment

Vue

If you are using Vue, import and add MagicMenuPlugin to your app.

js
import { createApp } from 'vue'
import { MagicMenuPlugin } from '@maas/vue-equipment/plugins'

const app = createApp({})

app.use(MagicMenuPlugin)

Nuxt

The menu is available as a Nuxt module. In your Nuxt config file add @maas/vue-equipment/nuxt to your modules and add MagicMenu to the plugins in your configuration.

js
export default defineNuxtConfig({
  modules: ['@maas/vue-equipment/nuxt'],
  vueEquipment: {
    plugins: ['MagicMenu'],
  },
})

Composable

In order to interact with the menu from anywhere within your app, we provide a useMagicMenu composable. Import it directly when needed.

js
import { useMagicMenu } from '@maas/vue-equipment/plugins'

const { selectView } = useMagicMenu('your-menu-id')

function handleClick() {
  selectView('your-view-id')
}

TIP

If you have installed the component as a Nuxt module, the composable will be auto-imported and is automatically available in your Nuxt app.

API Reference

MagicMenuProvider

The MagicMenuProvider wraps the menu and configures all child components according to the provided options.

Props

PropTypeRequired
id
MaybeRef<string>true
asChild
booleanfalse
options
MagicMenuOptionsfalse

Options

To customize the menu, override the necessary options. Any custom options will be merged with the default options.

OptionTypeDefault
mode
MenuMode
menubar
debug
booleanfalse
scrollLock
boolean | objectobject
scrollLock.padding
booleantrue
transition.content.default
string
'magic-menu-content--default' | 'magic-menu-content--fade'
transition.content.nested
stringmagic-menu-content--fade
transition.channel
stringmagic-menu-channel
floating.strategy
string
'fixed' | 'absolute'
delay.mouseenternumber
0 | 50
delay.mouseleavenumber
0 | 200
delay.clicknumber0
delay.rightClicknumber0

MagicMenuView

Props

PropTypeRequired
id
MaybeRef<string>false
placement
Placement
false

MagicMenuContent

Props

PropTypeRequired
arrow
booleanfalse
transition
stringfalse
referenceEl
HTMLElement | ComponentPublicInstancefalse

MagicMenuItem

Props

PropTypeRequired
id
stringfalse
disabled
booleanfalse

MagicMenuChannel

Props

PropTypeRequired
id
MaybeRef<string>false
transition
stringfalse

MagicMenuRemote

Props

PropTypeRequired
channelId
stringtrue
viewId
stringfalse
instanceId
stringfalse
disabled
booleanfalse
trigger
Interaction[]
false
asChild
booleanfalse

MagicMenuTrigger

Props

PropTypeRequired
disabled
booleanfalse
viewId
stringfalse
instanceId
stringfalse
trigger
Interaction[]
false
asChild
booleanfalse

Examples

The menu includes four different modes which preconfigure its appeareance and behavior. You can set the mode via the options.mode prop on the MagicMenuProvider.

A menu common in desktop applications which provides top-level as well as nested commands. Its behavior is modeled after the MacOS Finder.

<template>
  <magic-menu-provider
    id="magic-menu-menubar"
    class="inline-flex gap-2 p-1 rounded-2xl bg-surface-elevation-high"
  >
    <magic-menu-view v-for="(menuItem, index) in menu" :key="index">
      <magic-menu-trigger v-slot="{ viewActive }" as-child>
        <m-button :mode="viewActive ? 'translucent' : 'ghost'" size="xs">
          {{ menuItem.label }}
        </m-button>
      </magic-menu-trigger>
      <magic-menu-content :middleware="offsetMiddleware">
        <div class="bg-surface-elevation-high p-1 rounded-2xl w-[220px]">
          <nested-menu
            v-for="(item, itemIndex) in menuItem.items"
            :key="itemIndex"
            :item="item"
          />
        </div>
      </magic-menu-content>
    </magic-menu-view>
  </magic-menu-provider>
</template>

<script lang="ts" setup>
import NestedMenu from './components/NestedMenu.vue'

import { MButton } from '@maas/mirror/vue'
import { offset } from '@floating-ui/dom'

import '@maas/vue-equipment/utils/css/animations/fade-in.css'
import '@maas/vue-equipment/utils/css/animations/fade-out.css'

const offsetMiddleware = [offset({ crossAxis: -4, mainAxis: 8 })]

const menu = [
  {
    label: 'Edit',
    type: 'button',
    items: [
      { label: 'Undo', cmd: '⌘ Z' },
      { label: 'Redo', cmd: '⇧ ⌘ Z' },
      {
        label: 'Find',
        items: [
          { label: 'Search the web…' },
          { label: 'Find…' },
          { label: 'Find Next' },
          { label: 'Find Previous' },
        ],
      },
      { label: 'Cut', cmd: '⌘ X' },
      { label: 'Copy', cmd: '⌘ C' },
      { label: 'Paste', cmd: '⌘ V' },
    ],
  },
  {
    label: 'File',
    type: 'ghost',
    items: [
      { label: 'New Tab', cmd: '⌘ T' },
      { label: 'New Window', cmd: '⌘ W' },
      { label: 'New Incognito Window', disabled: true },
      {
        label: 'Share',
        items: [
          { label: 'Email Link', disabled: true },
          { label: 'Messages' },
          { label: 'Notes' },
          {
            label: 'Socials',
            items: [{ label: 'Instagram' }, { label: 'Bluesky' }],
          },
        ],
      },
      { label: 'Print', cmd: '⌘ P' },
    ],
  },
  {
    label: 'View',
    type: 'ghost',
    items: [
      { label: 'Show Bookmarks', cmd: '⇧ ⌘ B' },
      { label: 'Show Full URLs' },
      { label: 'Reload', cmd: '⌘ R' },
      { label: 'Force Reload', cmd: '⇧ ⌘ R' },
      { label: 'Fullscreen', cmd: '⌘ F' },
      { label: 'Hide Sidebar' },
    ],
  },
  {
    label: 'Profiles',
    type: 'ghost',
    items: [
      {
        label: 'Christoph Jeworutzki',
        items: [
          { label: 'User Settings' },
          { label: 'Edit Profile' },
          { label: 'Subscribtions' },
        ],
      },
      {
        label: 'Robin Scholz',
        items: [
          { label: 'User Settings' },
          { label: 'Edit Profile' },
          { label: 'Subscribtions' },
        ],
      },
      { label: 'Edit…' },
      { label: 'Add Profile…' },
    ],
  },
]
</script>

A collection of links for navigating websites.

<template>
  <div
    class="h-15 border-surface bg-surface-elevation-high relative flex items-center gap-1 overflow-hidden rounded-[1.25rem] p-1"
  >
    <magic-menu-provider
      v-if="menu"
      id="navigation-bar-demo"
      :options="{
        mode: 'navigation',
      }"
      class="flex"
    >
      <magic-menu-view ref="view">
        <div class="flex gap-1">
          <magic-menu-trigger
            v-for="(item, i) in menu"
            :key="i"
            as-child
            class="ui-menu-button"
          >
            <magic-menu-remote
              v-slot="{ channelActive }"
              :channel-id="item.id"
              as-child
            >
              <m-button :mode="channelActive ? 'translucent' : 'ghost'">
                <span class="flex items-center gap-2.5">
                  <span>{{ item.label }}</span>
                  <m-badge
                    v-if="item.badge"
                    mode="outline"
                    variant="primary"
                    size="sm"
                  >
                    {{ item.badge }}
                  </m-badge>
                </span>
              </m-button>
            </magic-menu-remote>
          </magic-menu-trigger>
        </div>
        <magic-menu-content :reference-el="viewRef?.$el">
          <div class="p-1 pt-2">
            <m-menu-box class="navigation-bar__menu-box overflow-hidden">
              <auto-size :duration="100">
                <magic-menu-channel
                  v-for="(item, i) in menu"
                  :id="item.id"
                  :key="i"
                  class="relative inline-flex gap-4"
                >
                  <div
                    v-for="(entry, j) in item.lists"
                    :key="j"
                    class="w-[16rem]"
                  >
                    <div
                      v-if="entry.label"
                      class="flex items-center gap-2 pb-2 pl-7 pt-4"
                    >
                      <span class="type-surface-callout-sm text-surface-muted">
                        {{ entry.label }}
                      </span>
                      <m-badge v-if="'badge' in entry" size="xs" mode="tone">
                        {{ entry.badge }}
                      </m-badge>
                    </div>

                    <menu-card
                      v-for="(data, k) in entry.list"
                      :key="k"
                      :data="data"
                    />
                  </div>
                </magic-menu-channel>
              </auto-size>
            </m-menu-box>
          </div>
        </magic-menu-content>
      </magic-menu-view>
    </magic-menu-provider>
  </div>
</template>

<script lang="ts" setup>
import { useTemplateRef, type ComponentPublicInstance } from 'vue'
import { AutoSize } from '@maas/vue-autosize'
import { MMenuBox, MBadge, MButton } from '@maas/mirror/vue'

import MenuCard from './components/MenuCard.vue'

const viewRef = useTemplateRef<ComponentPublicInstance>('view')

const menu = [
  {
    label: 'Catalogue',
    id: 'catalogue-channel',
    lists: [
      {
        label: 'Commercial',
        list: [
          {
            label: 'Mirror Ui',
            callout: 'Interface System',
            icon: 'maas-mr',
          },
          {
            label: 'Dreamtype™',
            badge: 'Soon',

            callout: 'Commercial Fonts',

            icon: 'maas-dt',
          },
          {
            label: 'Azzets',
            badge: 'Soon',
            callout: 'Visual Content App',
            icon: 'maas-az',
          },
        ],
      },
      {
        label: 'Open Source',
        badge: 'OSS',

        list: [
          {
            label: 'Vue Equipment',
            callout: 'Open Source Plugins',
            icon: 'maas-ve',
          },

          {
            label: 'Open Foundry',
            callout: 'Open Source Fonts',
            icon: 'maas-of',
          },
        ],
      },
    ],
  },
  {
    label: 'Resources',
    id: 'resources-channel',
    lists: [
      {
        label: 'Company',
        list: [
          {
            label: 'Readme',
            badge: 'Blog',

            callout: 'Written by MaaS™',
            icon: 'edit-alt',
          },

          {
            label: 'About us',
            callout: 'Who we Are',
            icon: 'maas-robot',
          },
        ],
      },
      {
        label: 'Community',
        id: 'community-channel',
        list: [
          {
            icon: 'brand-github',
            label: 'GitHub',
            callout: 'What we Code',
          },

          {
            icon: 'brand-figma',
            label: 'Figma',
            badge: 'Soon',

            callout: 'Design Resources',
          },
        ],
      },
      {
        label: 'Packages',
        badge: 'OSS',

        list: [
          {
            icon: 'brand-vue',
            label: 'Vue Primitive',
            callout: '@maas/vue-primitive',
          },

          {
            icon: 'brand-nuxt',
            label: 'MagicImage',
            callout: '@maas/magic-image',
          },
        ],
      },
    ],
  },
  {
    label: 'Enterprise',
    id: 'enterprise-channel',
    badge: 'Pro',
    lists: [
      {
        label: 'Solutions',
        list: [
          {
            label: 'Mirror Ui',
            callout: 'Interface System',
            icon: 'maas-mr',
          },
          {
            label: 'Vue Equipment',
            callout: 'Frontend Toolkit',
            icon: 'maas-ve',
          },
        ],
      },
      {
        label: 'Group',
        list: [
          {
            label: 'International Magic',
            callout: 'Creative Studio',
          },

          {
            label: 'ONE',
            callout: 'Production Collective',
          },
        ],
      },
    ],
  },
]
</script>

<style>
.navigation-bar__menu-box {
  --menu-box-box-shadow: none;
  --menu-box-color-bg: theme('backgroundColor.surface.elevation.high.DEFAULT');
}
</style>

A single top level menu which provides top-level as well as nested commands, triggered by a click, anchored at a reference element, like a button.

<template>
  <magic-menu-provider id="magic-menu-dropdown" :options="{ mode: 'dropdown' }">
    <magic-menu-view>
      <magic-menu-trigger as-child>
        <m-button size="xs">
          {{ menu.label }}
        </m-button>
      </magic-menu-trigger>
      <magic-menu-content>
        <div
          class="bg-surface-elevation-high text-black p-1 rounded-2xl w-[220px]"
        >
          <nested-menu
            v-for="(item, itemIndex) in menu.items"
            :key="itemIndex"
            :item="item"
          />
        </div>
      </magic-menu-content>
    </magic-menu-view>
  </magic-menu-provider>
</template>

<script lang="ts" setup>
import NestedMenu from './components/NestedMenu.vue'
import { MButton } from '@maas/mirror/vue'

const menu = {
  label: 'Menu',
  type: 'button',
  items: [
    { label: 'New Tab', cmd: '⌘ T' },
    { label: 'New Window', cmd: '⌘ W' },
    { label: 'New Incognito Window', disabled: true },
    {
      label: 'Share',
      items: [
        { label: 'Email Link', disabled: true },
        { label: 'Messages' },
        { label: 'Notes' },
        {
          label: 'Socials',
          items: [{ label: 'Instagram' }, { label: 'Bluesky' }],
        },
      ],
    },
    { label: 'Print', cmd: '⌘ P' },
    { label: 'Show Bookmarks', cmd: '⇧ ⌘ B' },
    { label: 'Show Full URLs' },
    { label: 'Reload', cmd: '⌘ R' },
    { label: 'Force Reload', cmd: '⇧ ⌘ R' },
    { label: 'Fullscreen', cmd: '⌘ F' },
    { label: 'Hide Sidebar' },
  ],
}
</script>

<style>
:root {
  --magic-menu-float-arrow-color: theme(
    'backgroundColor.surface.elevation.high.DEFAULT'
  );
}
</style>

Context Menu

A single top level menu which provides top-level as well as nested commands, triggered by a right click, anchored at the pointer event coordinates.

<template>
  <magic-menu-provider
    id="magic-menu-context"
    :options="{ mode: 'context' }"
    class="w-full"
  >
    <magic-menu-view>
      <magic-menu-trigger
        class="w-full h-36 border-2 border-dashed border-surface rounded-lg flex items-center justify-center"
      >
        <span class="type-control-text-sm text-surface-subtle">
          Right Click
        </span>
      </magic-menu-trigger>
      <magic-menu-content>
        <div
          class="bg-surface-elevation-high text-black p-1 rounded-2xl w-[220px]"
        >
          <nested-menu
            v-for="(item, itemIndex) in menu.items"
            :key="itemIndex"
            :item="item"
          />
        </div>
      </magic-menu-content>
    </magic-menu-view>
  </magic-menu-provider>
</template>

<script lang="ts" setup>
import NestedMenu from './components/NestedMenu.vue'

const menu = {
  label: 'Menu',
  type: 'button',
  items: [
    { label: 'New Tab', cmd: '⌘ T' },
    { label: 'New Window', cmd: '⌘ W' },
    { label: 'New Incognito Window', disabled: true },
    {
      label: 'Share',
      items: [
        { label: 'Email Link', disabled: true },
        { label: 'Messages' },
        { label: 'Notes' },
        {
          label: 'Socials',
          items: [{ label: 'Instagram' }, { label: 'Bluesky' }],
        },
      ],
    },
    { label: 'Print', cmd: '⌘ P' },
    { label: 'Show Bookmarks', cmd: '⇧ ⌘ B' },
    { label: 'Show Full URLs' },
    { label: 'Reload', cmd: '⌘ R' },
    { label: 'Force Reload', cmd: '⇧ ⌘ R' },
    { label: 'Fullscreen', cmd: '⌘ F' },
    { label: 'Hide Sidebar' },
  ],
}
</script>