EditorMentionMenu

GitHub
A mention menu that displays user suggestions when typing a trigger character in the editor.

Usage

The EditorMentionMenu component displays a menu of user suggestions when typing a trigger character (defaults to @) in the editor and inserts the selected mention using the @tiptap/extension-mention package. The trigger character is also used as the prefix when rendering the inserted mention.

It uses the useEditorMenu composable built on top of TipTap's Suggestion utility to filter items as you type and support keyboard navigation (arrow keys, enter to select, escape to close).
It must be used inside an Editor component's default slot to have access to the editor instance.
<script setup lang="ts">
import type { EditorMentionMenuItem } from 'pohon-ui'

const value = ref(`# Mention Menu

Type @ to mention someone and select from the list of available users.`)

const items: Array<EditorMentionMenuItem> = [
  {
    label: 'benjamincanac',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/739984?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'atinux',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/904724?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'danielroe',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/28706372?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'pi0',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/5158436?v=4',
      loading: 'lazy' as const
    }
  }
]

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type @ to mention someone..."
    class="min-h-21 w-full"
  >
    <PEditorMentionMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </PEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorMentionMenuItem } from 'pohon-ui'

const value = ref(`# Mention Menu

Type @ to mention someone and select from the list of available users.`)

const items: Array<EditorMentionMenuItem> = [
  {
    label: 'benjamincanac',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/739984?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'atinux',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/904724?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'danielroe',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/28706372?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'pi0',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/5158436?v=4',
      loading: 'lazy' as const
    }
  }
]

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type @ to mention someone..."
    class="min-h-21 w-full"
  >
    <PEditorMentionMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </PEditor>
</template>
Learn more about the Mention extension in the TipTap documentation.

Items

Use the items prop as an array of objects with the following properties:

  • label: string
  • avatar?: AvatarProps
  • icon?: string
  • description?: string
  • disabled?: boolean
<script setup lang="ts">
import type { EditorMentionMenuItem } from 'pohon-ui';

const value = ref(`Type @ to mention a user.

You can customize the items with avatars, icons, and descriptions.`);

const items: Array<EditorMentionMenuItem> = [{
  label: 'benjamincanac',
  avatar: { src: 'https://avatars.githubusercontent.com/u/739984?v=4', loading: 'lazy' as const },
}, {
  label: 'HugoRCD',
  avatar: { src: 'https://avatars.githubusercontent.com/u/71938701?v=4', loading: 'lazy' as const },
}, {
  label: 'atinux',
  avatar: { src: 'https://avatars.githubusercontent.com/u/904724?v=4', loading: 'lazy' as const },
}, {
  label: 'romhml',
  avatar: { src: 'https://avatars.githubusercontent.com/u/25613751?v=4', loading: 'lazy' as const },
}, {
  label: 'sandros94',
  avatar: { src: 'https://avatars.githubusercontent.com/u/13056429?v=4', loading: 'lazy' as const },
}, {
  label: 'J-Michalek',
  avatar: { src: 'https://avatars.githubusercontent.com/u/71264422?v=4', loading: 'lazy' as const },
}, {
  label: 'hywax',
  avatar: { src: 'https://avatars.githubusercontent.com/u/149865959?v=4', loading: 'lazy' as const },
}];
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type @ to mention..."
    class="min-h-19 w-full"
  >
    <PEditorMentionMenu
      :editor="editor"
      :items="items"
    />
  </PEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorMentionMenuItem } from 'pohon-ui';

const value = ref(`Type @ to mention a user.

You can customize the items with avatars, icons, and descriptions.`);

const items: Array<EditorMentionMenuItem> = [{
  label: 'benjamincanac',
  avatar: { src: 'https://avatars.githubusercontent.com/u/739984?v=4', loading: 'lazy' as const },
}, {
  label: 'HugoRCD',
  avatar: { src: 'https://avatars.githubusercontent.com/u/71938701?v=4', loading: 'lazy' as const },
}, {
  label: 'atinux',
  avatar: { src: 'https://avatars.githubusercontent.com/u/904724?v=4', loading: 'lazy' as const },
}, {
  label: 'romhml',
  avatar: { src: 'https://avatars.githubusercontent.com/u/25613751?v=4', loading: 'lazy' as const },
}, {
  label: 'sandros94',
  avatar: { src: 'https://avatars.githubusercontent.com/u/13056429?v=4', loading: 'lazy' as const },
}, {
  label: 'J-Michalek',
  avatar: { src: 'https://avatars.githubusercontent.com/u/71264422?v=4', loading: 'lazy' as const },
}, {
  label: 'hywax',
  avatar: { src: 'https://avatars.githubusercontent.com/u/149865959?v=4', loading: 'lazy' as const },
}];
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type @ to mention..."
    class="min-h-19 w-full"
  >
    <PEditorMentionMenu
      :editor="editor"
      :items="items"
    />
  </PEditor>
</template>
You can also pass an array of arrays to the items prop to create separated groups of items.

Char

Use the char prop to change the trigger character. Defaults to @. The trigger character is also used as the prefix when rendering the inserted mention (e.g. #channel instead of @channel).

<template>
  <PEditor v-slot="{ editor }">
    <PEditorMentionMenu
      :editor="editor"
      :items="channels"
      char="#"
    />
  </PEditor>
</template>
You can use multiple EditorMentionMenu components on the same editor with different char and plugin-key props to support different mention types.
<template>
  <PEditor v-slot="{ editor }">
    <PEditorMentionMenu
      :editor="editor"
      :items="users"
      plugin-key="mentionMenu"
    />
    <PEditorMentionMenu
      :editor="editor"
      :items="tags"
      char="#"
      plugin-key="tagMenu"
    />
  </PEditor>
</template>

Suggestion

Use the suggestion prop to customize TipTap's Suggestion matching behavior.

This is useful when the trigger character should open directly after other characters instead of requiring the default whitespace prefix.

<template>
  <PEditor v-slot="{ editor }">
    <PEditorMentionMenu
      :editor="editor"
      :items="items"
      char="#"
      :suggestion="{
        allowedPrefixes: null,
      }"
    />
  </PEditor>
</template>

Options

Use the options prop to customize the positioning behavior using Floating UI options.

<template>
  <PEditor v-slot="{ editor }">
    <PEditorMentionMenu
      :editor="editor"
      :items="items"
      :options="{
        placement: 'bottom-start',
        offset: 4,
      }"
    />
  </PEditor>
</template>

Examples

With ignore filter

You can set the ignore-filter prop to true to disable the internal search and use your own search logic. Use v-model:search-term to access the current search term and fetch items from an API.

<script setup lang="ts">
import { refDebounced } from '@vueuse/core';

const value = ref(`# Async Mention Menu

Type @ to mention someone. Results are fetched from an API as you type.`);

const searchTerm = ref('');
const searchTermDebounced = refDebounced(searchTerm, 200);

const { data: items } = useLazyFetch('https://dummyjson.com/users/search?limit=10', {
  key: 'editor-mention-users-search',
  query: { q: searchTermDebounced },
  transform: (data: { users: Array<{ id: number; firstName: string; lastName: string; image: string }> }) => {
    return data.users?.map((user) => ({ id: user.id, label: `${user.firstName} ${user.lastName}`, avatar: { src: user.image, loading: 'lazy' as const } })) || [];
  },
  server: false,
});

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined;
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type @ to mention someone..."
    class="min-h-21 w-full"
  >
    <PEditorMentionMenu
      v-model:search-term="searchTerm"
      :editor="editor"
      :items="items"
      :append-to="appendToBody"
      ignore-filter
    />
  </PEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { refDebounced } from '@vueuse/core';

const value = ref(`# Async Mention Menu

Type @ to mention someone. Results are fetched from an API as you type.`);

const searchTerm = ref('');
const searchTermDebounced = refDebounced(searchTerm, 200);

const { data: items } = useLazyFetch('https://dummyjson.com/users/search?limit=10', {
  key: 'editor-mention-users-search',
  query: { q: searchTermDebounced },
  transform: (data: { users: Array<{ id: number; firstName: string; lastName: string; image: string }> }) => {
    return data.users?.map((user) => ({ id: user.id, label: `${user.firstName} ${user.lastName}`, avatar: { src: user.image, loading: 'lazy' as const } })) || [];
  },
  server: false,
});

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined;
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type @ to mention someone..."
    class="min-h-21 w-full"
  >
    <PEditorMentionMenu
      v-model:search-term="searchTerm"
      :editor="editor"
      :items="items"
      :append-to="appendToBody"
      ignore-filter
    />
  </PEditor>
</template>
This example uses refDebounced to debounce the API calls.

API

Props

Prop Default Type

Theme

app.config.ts
export default defineAppConfig({
  ui: {
    editorMentionMenu: {
      slots: {
        content: 'rounded-md bg-background flex flex-col max-h-96 max-w-60 min-w-48 ring ring-border shadow-lg origin-$akar-dropdown-menu-content-transform-origin overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
        viewport: 'relative divide-y divide-border scroll-py-1 overflow-y-auto flex-1',
        group: 'p-1 isolate',
        label: 'color-text-highlighted font-semibold flex w-full items-center',
        separator: '-mx-1 my-1 h-px bg-border',
        item: 'group outline-none flex w-full select-none items-start relative before:rounded-md before:content-empty before:inset-px before:absolute before:-z-1 data-[disabled]:opacity-75 data-[disabled]:cursor-not-allowed',
        itemLeadingIcon: 'shrink-0 flex items-center justify-center',
        itemLeadingAvatar: 'shrink-0',
        itemLeadingAvatarSize: '',
        itemWrapper: 'flex-1 flex flex-col text-start min-w-0',
        itemLabel: 'truncate',
        itemDescription: 'truncate color-text-muted',
        itemLabelExternalIcon: 'inline-block size-3 align-top color-text-dimmed'
      },
      variants: {
        size: {
          xs: {
            label: 'p-1 text-[10px]/3 gap-1',
            item: 'p-1 text-xs gap-1',
            itemLeadingIcon: 'size-4 text-sm',
            itemLeadingAvatarSize: '3xs'
          },
          sm: {
            label: 'p-1.5 text-[10px]/3 gap-1.5',
            item: 'p-1.5 text-xs gap-1.5',
            itemLeadingIcon: 'size-4 text-sm',
            itemLeadingAvatarSize: '3xs'
          },
          md: {
            label: 'p-1.5 text-xs gap-1.5',
            item: 'p-1.5 text-sm gap-1.5',
            itemLeadingIcon: 'size-5 text-base',
            itemLeadingAvatarSize: '2xs'
          },
          lg: {
            label: 'p-2 text-xs gap-2',
            item: 'p-2 text-sm gap-2',
            itemLeadingIcon: 'size-5 text-base',
            itemLeadingAvatarSize: '2xs'
          },
          xl: {
            label: 'p-2 text-sm gap-2',
            item: 'p-2 text-base gap-2',
            itemLeadingIcon: 'size-6 text-xl',
            itemLeadingAvatarSize: 'xs'
          }
        },
        active: {
          true: {
            item: 'color-text-highlighted before:bg-background-elevated/75',
            itemLeadingIcon: 'color-text'
          },
          false: {
            item: [
              'color-text data-[highlighted]:not-data-[disabled]:color-text-highlighted data-[highlighted]:not-data-[disabled]:before:bg-background-elevated/50',
              'transition-colors before:transition-colors'
            ],
            itemLeadingIcon: [
              'color-text-dimmed [.group[data-highlighted]:not([data-disabled])_&]:color-text',
              'transition-colors'
            ]
          }
        }
      },
      defaultVariants: {
        size: 'md'
      }
    }
  }
})
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from 'pohon-ui/vite'

export default defineConfig({
  plugins: [
    vue(),
    ui({
      ui: {
        editorMentionMenu: {
          slots: {
            content: 'rounded-md bg-background flex flex-col max-h-96 max-w-60 min-w-48 ring ring-border shadow-lg origin-$akar-dropdown-menu-content-transform-origin overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
            viewport: 'relative divide-y divide-border scroll-py-1 overflow-y-auto flex-1',
            group: 'p-1 isolate',
            label: 'color-text-highlighted font-semibold flex w-full items-center',
            separator: '-mx-1 my-1 h-px bg-border',
            item: 'group outline-none flex w-full select-none items-start relative before:rounded-md before:content-empty before:inset-px before:absolute before:-z-1 data-[disabled]:opacity-75 data-[disabled]:cursor-not-allowed',
            itemLeadingIcon: 'shrink-0 flex items-center justify-center',
            itemLeadingAvatar: 'shrink-0',
            itemLeadingAvatarSize: '',
            itemWrapper: 'flex-1 flex flex-col text-start min-w-0',
            itemLabel: 'truncate',
            itemDescription: 'truncate color-text-muted',
            itemLabelExternalIcon: 'inline-block size-3 align-top color-text-dimmed'
          },
          variants: {
            size: {
              xs: {
                label: 'p-1 text-[10px]/3 gap-1',
                item: 'p-1 text-xs gap-1',
                itemLeadingIcon: 'size-4 text-sm',
                itemLeadingAvatarSize: '3xs'
              },
              sm: {
                label: 'p-1.5 text-[10px]/3 gap-1.5',
                item: 'p-1.5 text-xs gap-1.5',
                itemLeadingIcon: 'size-4 text-sm',
                itemLeadingAvatarSize: '3xs'
              },
              md: {
                label: 'p-1.5 text-xs gap-1.5',
                item: 'p-1.5 text-sm gap-1.5',
                itemLeadingIcon: 'size-5 text-base',
                itemLeadingAvatarSize: '2xs'
              },
              lg: {
                label: 'p-2 text-xs gap-2',
                item: 'p-2 text-sm gap-2',
                itemLeadingIcon: 'size-5 text-base',
                itemLeadingAvatarSize: '2xs'
              },
              xl: {
                label: 'p-2 text-sm gap-2',
                item: 'p-2 text-base gap-2',
                itemLeadingIcon: 'size-6 text-xl',
                itemLeadingAvatarSize: 'xs'
              }
            },
            active: {
              true: {
                item: 'color-text-highlighted before:bg-background-elevated/75',
                itemLeadingIcon: 'color-text'
              },
              false: {
                item: [
                  'color-text data-[highlighted]:not-data-[disabled]:color-text-highlighted data-[highlighted]:not-data-[disabled]:before:bg-background-elevated/50',
                  'transition-colors before:transition-colors'
                ],
                itemLeadingIcon: [
                  'color-text-dimmed [.group[data-highlighted]:not([data-disabled])_&]:color-text',
                  'transition-colors'
                ]
              }
            }
          },
          defaultVariants: {
            size: 'md'
          }
        }
      }
    })
  ]
})

Changelog

No recent changes