Suggestion Menu
A powerful and flexible suggestion menu system for Tiptap editors. Creates floating dropdown menus triggered by configurable characters with full keyboard navigation, filtering, and customizable rendering support.
Installation
Add the component via the Tiptap CLI:
npx @tiptap/cli@latest add suggestion-menu
Components
<SuggestionMenu />
The core suggestion menu component that provides a floating dropdown interface triggered by typing specific characters.
Usage
<SuggestionMenu
editor={editor}
char="@"
pluginKey="myMentionMenu"
items={async ({ query, editor }) => [
{
title: 'John Doe',
subtext: 'Software Engineer',
onSelect: ({ editor, range }) => {
editor.chain().focus().insertContentAt(range, '@john').run()
},
},
]}
>
{({ items, selectedIndex, onSelect }) => (
<MenuList items={items} selectedIndex={selectedIndex} onSelect={onSelect} />
)}
</SuggestionMenu>
Props
Name | Type | Default | Description |
---|---|---|---|
editor | Editor | null | undefined | The Tiptap editor instance |
char | string | "@" | Character that triggers the suggestion menu |
items | (props) => Item[] | Promise<Item[]> | () => [] | Function returning suggestion items |
children | (props) => ReactNode | undefined | Render function for menu content |
floatingOptions | Partial<UseFloatingOptions> | undefined | Additional floating UI positioning options |
selector | string | "tiptap-suggestion-menu" | CSS selector for the menu container |
pluginKey | string | PluginKey | SuggestionPluginKey | Unique identifier for the suggestion plugin |
allowSpaces | boolean | false | Allow spaces in suggestion queries |
allowToIncludeChar | boolean | false | Include trigger character in query |
allowedPrefixes | string[] | null | [" "] | Characters that can precede the trigger |
startOfLine | boolean | false | Only trigger at line start |
decorationTag | string | "span" | HTML tag for decoration element |
decorationClass | string | "suggestion" | CSS class for decoration styling |
decorationContent | string | "" | Placeholder text in decoration |
Types
SuggestionItem<T>
Interface defining the structure of suggestion items.
interface SuggestionItem<T = any> {
title: string // Main display text
subtext?: string // Secondary context text
badge?: IconComponent | string // Icon or badge component
group?: string // Group identifier for organization
keywords?: string[] // Additional search keywords
context?: T // Custom data passed to onSelect
onSelect: (props: {
// Selection handler
editor: Editor
range: Range
context?: T
}) => void
}
SuggestionMenuRenderProps<T>
Props passed to the children render function.
type SuggestionMenuRenderProps<T = any> = {
items: SuggestionItem<T>[] // Filtered suggestion items
selectedIndex?: number // Currently selected item index
onSelect: (item: SuggestionItem<T>) => void // Item selection handler
}
Utilities
filterSuggestionItems(items, query)
Filters and prioritizes suggestion items based on a search query.
import { filterSuggestionItems } from '@/registry/tiptap-ui-utils/suggestion-menu'
const filteredItems = filterSuggestionItems(allItems, 'john')
Parameters
Name | Type | Description |
---|---|---|
items | SuggestionItem[] | Array of suggestion items to filter |
query | string | Search query string |
Filtering Logic
- Matches against
title
,subtext
, andkeywords
properties - Case-insensitive matching
- Prioritizes exact matches and "starts with" matches
- Returns empty array for empty queries
calculateStartPosition(cursorPosition, previousNode, triggerChar)
Calculates the start position of a suggestion command in the text.
import { calculateStartPosition } from '@/registry/tiptap-ui-utils/suggestion-menu'
const startPos = calculateStartPosition(100, textNode, '@')
Parameters
Name | Type | Description |
---|---|---|
cursorPosition | number | Current cursor position in document |
previousNode | Node | null | Text node before cursor |
triggerChar | string | Character that triggered suggestions |
Advanced Usage
Custom Suggestion Menu
Create a custom suggestion menu with full control over items and rendering:
function CustomMentionMenu() {
const getSuggestionItems = async ({ query, editor }) => {
const users = await fetchUsers(query)
return users.map((user) => ({
title: user.name,
subtext: user.email,
context: user,
badge: UserIcon,
keywords: [user.department, user.role],
onSelect: ({ editor, range, context }) => {
editor
.chain()
.focus()
.insertContentAt(range, {
type: 'mention',
attrs: { id: context.id, label: context.name },
})
.run()
},
}))
}
return (
<SuggestionMenu
char="@"
pluginKey="customMention"
items={getSuggestionItems}
allowSpaces={false}
decorationClass="my-mention-decoration"
>
{({ items, selectedIndex, onSelect }) => (
<Card>
<CardBody>
{items.map((item, index) => (
<MentionItem
key={item.title}
item={item}
isSelected={index === selectedIndex}
onSelect={() => onSelect(item)}
/>
))}
</CardBody>
</Card>
)}
</SuggestionMenu>
)
}
With Grouped Items
Organize suggestions into groups for better navigation:
function GroupedSuggestionMenu() {
const getSuggestionItems = async ({ query, editor }) => {
return [
{
title: 'Add Heading',
group: 'Formatting',
badge: HeadingIcon,
onSelect: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleHeading({ level: 1 }).run()
},
},
{
title: 'Add Table',
group: 'Insert',
badge: TableIcon,
onSelect: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).insertTable().run()
},
},
]
}
return (
<SuggestionMenu char="/" items={getSuggestionItems}>
{({ items, selectedIndex, onSelect }) => (
<Card>
<CardBody>
{Object.entries(groupBy(items, 'group')).map(([groupName, groupItems]) => (
<div key={groupName}>
<CardGroupLabel>{groupName}</CardGroupLabel>
<CardItemGroup>
{groupItems.map((item, index) => (
<SuggestionItem
key={item.title}
item={item}
isSelected={index === selectedIndex}
onSelect={() => onSelect(item)}
/>
))}
</CardItemGroup>
</div>
))}
</CardBody>
</Card>
)}
</SuggestionMenu>
)
}