Keyboard shortcut: Ctrl + K
Get started

Context menu

VContextMenu opens a menu at the pointer on a right click, a long press on Android, the Menu key or Shift+F10. Its menu slot receives target, the element the menu was opened on, so one menu serves a whole list.

Usage

vue

Right-click a file, or focus it and press Shift+F10.

<script setup lang="ts">
import { ref } from 'vue'
import { VContextMenu, VMenuItem, VMenuSeparator, VTypography } from 'vectis-ui'

const files = ['report.pdf', 'budget.xlsx', 'notes.txt']
const last = ref('')

function run(command: string, target: Element | null) {
  const file = target?.closest<HTMLElement>('[data-file]')?.dataset.file
  last.value = file ? `${command} ${file}` : command
}
</script>

<template>
  <div class="column">
    <VContextMenu as="ul" class="files" aria-label="Files">
      <li v-for="file in files" :key="file" :data-file="file">
        <button type="button" class="file">{{ file }}</button>
      </li>
      <template #menu="{ target }">
        <VMenuItem label="Open" @select="run('Open', target)" />
        <VMenuItem label="Rename" @select="run('Rename', target)" />
        <VMenuSeparator />
        <VMenuItem label="Delete" tone="danger" @select="run('Delete', target)" />
      </template>
    </VContextMenu>

    <VTypography variant="body-sm" tone="muted">
      {{ last ? `Last command: ${last}.` : 'Right-click a file, or focus it and press Shift+F10.' }}
    </VTypography>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-3);
}
.files {
  display: grid;
  gap: var(--vectis-space-1);
  max-inline-size: 20rem;
  margin: 0;
  padding: var(--vectis-space-2);
  border: 1px dashed var(--vectis-color-border);
  border-radius: var(--vectis-radius-md);
  list-style: none;
}
.file {
  inline-size: 100%;
  padding: var(--vectis-space-2);
  border: none;
  border-radius: var(--vectis-radius-sm);
  background: var(--vectis-color-surface-muted);
  color: var(--vectis-color-text);
  font: inherit;
  text-align: start;
}
</style>

Examples

The commands are VMenu items, so groups, separators, submenus, size and compact work the same. Near an edge of the viewport the menu flips to stay on screen.

vue
Right-click this area.
<script setup lang="ts">
import { VContextMenu, VMenuItem, VMenuSeparator } from 'vectis-ui'
</script>

<template>
  <VContextMenu class="zone" tabindex="0">
    Right-click this area.
    <template #menu>
      <VMenuItem label="Paste" />
      <VMenuItem label="Sort by">
        <template #submenu>
          <VMenuItem label="Name" selected />
          <VMenuItem label="Date modified" />
          <VMenuItem label="Size" />
        </template>
      </VMenuItem>
      <VMenuSeparator />
      <VMenuItem label="New folder" />
    </template>
  </VContextMenu>
</template>

<style scoped>
.zone {
  display: grid;
  place-items: center;
  min-block-size: 8rem;
  border: 1px dashed var(--vectis-color-border);
  border-radius: var(--vectis-radius-md);
  color: var(--vectis-color-text-muted);
}
</style>

The browser's menu

Shift with a right click shows the browser's menu. disabled gives the whole zone back to it.

vue
Right-click this area, with or without Shift.
<script setup lang="ts">
import { ref } from 'vue'
import { VContextMenu, VMenuItem, VSwitch } from 'vectis-ui'

const disabled = ref(false)
</script>

<template>
  <div class="column">
    <VSwitch v-model="disabled" label="Use the browser's menu" />
    <VContextMenu :disabled="disabled" class="zone" tabindex="0">
      Right-click this area, with or without Shift.
      <template #menu>
        <VMenuItem label="Copy" />
        <VMenuItem label="Paste" />
      </template>
    </VContextMenu>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-3);
}
.zone {
  display: grid;
  place-items: center;
  min-block-size: 8rem;
  border: 1px dashed var(--vectis-color-border);
  border-radius: var(--vectis-radius-md);
  color: var(--vectis-color-text-muted);
}
</style>

Knowing whether it is open

v-model:open follows every opening and closing. Opened from code, the menu appears under the focused element of the zone, or under the zone itself. Keep every command reachable another way too, a visible button for instance: iOS Safari has no long press menu, and nothing tells a reader the menu exists.

vue
Right-click this area.

The menu is closed.

<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VContextMenu, VMenuItem, VTypography } from 'vectis-ui'

const open = ref(false)
</script>

<template>
  <div class="column">
    <VContextMenu v-model:open="open" class="zone" tabindex="0">
      Right-click this area.
      <template #menu>
        <VMenuItem label="Copy" />
        <VMenuItem label="Paste" />
      </template>
    </VContextMenu>

    <div class="row">
      <VButton variant="ghost" tone="neutral" @click="open = true">Open from code</VButton>
      <VTypography variant="body-sm" tone="muted">
        The menu is {{ open ? 'open' : 'closed' }}.
      </VTypography>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-3);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-3);
}
.zone {
  display: grid;
  place-items: center;
  min-block-size: 8rem;
  border: 1px dashed var(--vectis-color-border);
  border-radius: var(--vectis-radius-md);
  color: var(--vectis-color-text-muted);
}
</style>

API

Props

PropTypeDefault
asstring'div'
Element wrapping the zone.
sizeMenuSize'sm' | 'md' | 'lg''sm'
Row size inherited by submenus.
compactbooleanfalse
Reduces row height, including submenus.
widthnumber | stringnone
Panel width. Numbers use pixels; strings use CSS lengths or keywords.
disabledbooleanfalse
Leaves the zone to the browser's own menu.
v-model:openbooleanfalse
Open state synchronized with outside clicks, Escape and command selection.

Slots

SlotType
default{}
Content of the zone.
menu{ target: Element | null; }
VMenuItem, VMenuGroup and VMenuSeparator children. Receives target.