---
title: "Suggestion utility"
description: "Customize autocomplete suggestions using nodes like Mention and Emoji. Explore settings and configurations in our docs."
canonical_url: "https://tiptap.dev/docs/editor/api/utilities/suggestion"
---

# Suggestion utility

Customize autocomplete suggestions using nodes like Mention and Emoji. Explore settings and configurations in our docs.

This utility helps with all kinds of suggestions in the editor. Have a look at the [`Mention`](https://tiptap.dev/docs/editor/extensions/nodes/mention.md) or [`Emoji`](https://tiptap.dev/docs/editor/extensions/nodes/emoji.md) node to see it in action.

## Settings

### char

The character that triggers the autocomplete popup.

Default: `'@'`

### pluginKey

A ProseMirror PluginKey.

Default: `SuggestionPluginKey`

### allow

A function that returns a boolean to indicate if the suggestion should be active.

Default: `(props: { editor: Editor; state: EditorState; range: Range, isActive?: boolean }) => true`

### shouldShow

A function that returns a boolean to indicate if the suggestion should be active. This is useful to prevent suggestions from opening for remote users in collaborative environments.

```js
shouldShow: ({ editor, range, query, text, transaction }) => {
  return !isChangeOrigin(transaction)
}
```

Default: `null`

### shouldResetDismissed

Controls when a dismissed suggestion becomes active again after the user closes it with Escape or `exitSuggestion()`.

By default, the utility keeps the dismissed suggestion closed while the user stays in the same trigger context. With `allowSpaces: true`, that dismissed context can span spaces until the cursor leaves it.

Use `shouldResetDismissed` if you need to decide yourself when that dismissed context should be cleared.

The callback is not called for every transaction. It is only evaluated on transactions where the suggestion plugin finds a valid match, the match passes `allow` and `shouldShow`, and there is a dismissed suggestion state to potentially clear.

```js
Suggestion({
  // ...
  shouldResetDismissed: ({ transaction, allowSpaces, range, match }) => {
    if (!allowSpaces) {
      return false
    }

    return transaction.doc.textBetween(range.from, match.range.to, '\n').includes('.')
  },
})
```

Return `true` to clear the dismissed state for the current transaction and allow the suggestion to open again.

Default: `null`

### allowSpaces

Allows or disallows spaces in suggested items. Not compatible with `allowToIncludeChar` — if `allowToIncludeChar` is `true`, this option is disabled.

Default: `false`

### allowToIncludeChar

Allows the trigger character itself to be included in the suggestion query. Not compatible with `allowSpaces`.

Default: `false`

### allowedPrefixes

The prefix characters that are allowed to trigger a suggestion. Set to `null` to allow any prefix character.

Default: `[' ']`

### startOfLine

Trigger the autocomplete popup at the start of a line only.

Default: `false`

### minQueryLength

The minimum number of characters the user must type after the trigger character before the `items()` function is called.

Useful when fetching suggestions from an API — it prevents unnecessary network requests when the query is too short to be meaningful.

Default: `0`

```js
Suggestion({
  minQueryLength: 2,
  items: async ({ query, signal }) => {
    // Only called when query.length >= 2
    const response = await fetch(`/api/search?q=${query}`, { signal })
    return response.json()
  },
})
```

### debounce

The number of milliseconds to wait after the user stops typing before calling `items()`. This prevents expensive operations (like API requests) from being triggered on every keystroke.

The debounce timer resets on each keystroke, so the `items()` function is only called once the user pauses typing for the specified duration.

Default: `0` (no debounce)

```js
Suggestion({
  debounce: 300,
  items: async ({ query }) => {
    // Called 300ms after the user stops typing
    return searchApi(query)
  },
})
```

### initialItems

An array of items shown immediately when the suggestion popup opens, before the async `items()` call resolves. After `items()` completes, its result replaces the initial items.

Useful for showing recent or popular items while the real results are loading.

```js
Suggestion({
  initialItems: ['Lea Thompson', 'Cyndi Lauper', 'Tom Cruise'],
  items: async ({ query }) => {
    // These replace the initial items once resolved
    return fetchSearchResults(query)
  },
})
```

Default: `undefined`

### decorationTag

The HTML tag that should be rendered for the suggestion.

Default: `'span'`

### decorationClass

A CSS class that should be added to the suggestion.

Default: `'suggestion'`

### decorationContent

The content that should be rendered in the suggestion decoration.

Default: `''`

### decorationEmptyClass

A CSS class that should be added to the suggestion when it is empty.

Default: `'is-empty'`

### placement

The preferred placement of the suggestion popup relative to the cursor or trigger decoration. Uses [Floating UI placement values](https://floating-ui.com/docs/computePosition#placement).

The resolved placement is forwarded to `SuggestionProps.floatingUi.placement`, which you can read in your render callbacks.

Default: `'bottom-start'`

```js
Suggestion({
  placement: 'top-start',
})
```

### offset

Offsets the suggestion popup from its anchor by the specified number of pixels on each axis. These values are forwarded to the Floating UI [`offset` middleware](https://floating-ui.com/docs/offset).

Default: `{ mainAxis: 4, crossAxis: 0 }`

```js
Suggestion({
  offset: { mainAxis: 8, crossAxis: 4 },
})
```

### flip

When enabled, the popup automatically flips to the opposite side when there isn't enough space in the preferred direction. This toggles the Floating UI [`flip` middleware](https://floating-ui.com/docs/flip).

Default: `true`

```js
Suggestion({
  placement: 'top-start',
  flip: false,
})
```

### container

A CSS selector string or an `HTMLElement` that defines the container for the suggestion popup. Setting this can help with scroll containment or z-index stacking contexts.

Default: `undefined`

```js
Suggestion({
  container: '#my-editor-container',
})
```

### floatingUi

Advanced Floating UI configuration for cases where the top-level options don't offer enough control. The plugin keeps ownership of the anchor and placement — set the side with the top-level [`placement`](#placement) option — while `floatingUi` lets you switch the positioning `strategy` and append extra `middleware`.

Any middleware you pass is appended *after* the `offset` and `flip` middleware the plugin builds from your top-level `offset` and `flip` options, so the two compose rather than replace each other.

```js
import { shift, size } from '@floating-ui/dom'

Suggestion({
  placement: 'top-start',
  floatingUi: {
    strategy: 'fixed',
    middleware: [shift({ padding: 8 }), size()],
  },
})
```

All properties are optional. The full type is:

```ts
type SuggestionFloatingUiOptions = {
  strategy?: 'absolute' | 'fixed'
  middleware?: Middleware[]
}
```

Default: `undefined`

### dismissOnOutsideClick

When `true`, a pointer interaction outside both the popup and the editor dismisses the active suggestion. This only applies when you render the popup with [managed positioning](#managed-positioning-with-propsmount) (`props.mount`), because the plugin needs to know which element is the popup to decide what counts as "outside".

Default: `true`

### command

Executed when a suggestion is selected.

Default: `() => {}`

### items

A function or async function that returns filtered suggestions based on user input. The function receives an `AbortSignal` (`signal`) which is aborted when the user continues typing (e.g., a new keystroke makes the current request stale). Use this to cancel in-progress fetch requests.

```js
items: async ({ editor, query, signal }) => {
  const response = await fetch(`/api/search?q=${query}`, { signal })
  return response.json()
}
```

Default: `({ editor, query, signal }) => []`

### render

A render function for the autocomplete popup. Returns an object with lifecycle hooks (`onStart`, `onBeforeStart`, `onUpdate`, `onBeforeUpdate`, `onExit`, `onKeyDown`) that receive a `SuggestionProps` object.

The `SuggestionProps` object passed to each hook includes the following properties in addition to the existing `editor`, `range`, `query`, `text`, `items`, `command`, `decorationNode`, and `clientRect`:

```ts
{
  /** Mounts your popup element and takes over positioning. The recommended,
   *  default way to render a suggestion popup — see "Positioning" below.
   *  Returns an `unmount` function to call in `onExit`. */
  mount: (element: HTMLElement, options?: SuggestionMountOptions) => () => void

  /** True while an async items() call is in progress, false otherwise.
   *  Only relevant when items() returns a Promise. */
  loading: boolean

  /** The resolved positioning options, passed through from the suggestion
   *  options so you can read them in your render callbacks. */
  placement: SuggestionPlacement
  offset: { mainAxis: number; crossAxis: number }
  flip: boolean
  container?: string | HTMLElement

  /** Resolved Floating UI config, ready to hand to `computePosition()`.
   *  This is the escape hatch for running your own positioning loop instead
   *  of using `mount()`. */
  floatingUi: {
    placement: SuggestionPlacement
    strategy: 'absolute' | 'fixed'
    middleware: Middleware[]
  }
}
```

Default: `() => ({})`

### findSuggestionMatch

Optional param to replace the built-in regex matching of editor content that triggers a suggestion.
See [the
source](https://github.com/ueberdosis/tiptap/blob/main/packages/suggestion/src/findSuggestionMatch.ts#L18)
for more detail.

Default: `findSuggestionMatch(config: Trigger): SuggestionMatch`

## Async behavior

When your `items()` function returns a `Promise`, the suggestion plugin handles the async lifecycle automatically:

1. **Initial state:** The popup opens and `loading` is `true`. If `initialItems` was provided, those items are shown immediately.
2. **Fetching:** The `items()` function is called with an `AbortSignal`. If the user keeps typing, the previous request's signal is aborted and a new one starts after the `debounce` delay.
3. **Resolution:** Once the promise resolves, the returned items replace the current items and `loading` becomes `false`. The `onUpdate` callback is triggered with the new `SuggestionProps`.
4. **Empty state:** If no `items()` function is provided or it returns an empty array, the popup shows an empty state. The `loading` property remains `false` unless a fetch is in progress.
5. **Dismissal:** When the popup closes (e.g., user presses Escape), any in-flight request is aborted automatically.

Here's a complete example:

```js
Suggestion({
  minQueryLength: 2,
  debounce: 300,
  initialItems: ['Lea Thompson', 'Cyndi Lauper'],
  items: async ({ query, signal }) => {
    // Simulate an API call — this will be aborted if the query changes
    const response = await fetch(`/api/users?q=${query}`, { signal })
    const data = await response.json()
    return data.slice(0, 5)
  },
  render: () => {
    let component
    let unmount

    return {
      onStart(props) {
        component = new ReactRenderer(MyList, {
          props,
          editor: props.editor,
        })
        // Let the plugin mount and position the popup for you.
        unmount = props.mount(component.element)
      },
      onUpdate(props) {
        // `props.loading` is true while fetching, false otherwise
        // `props.items` contains the resolved data (or initialItems before resolution)
        component.updateProps(props)
      },
      onExit() {
        unmount?.()
        component.destroy()
      },
    }
  },
})
```

## Positioning

The suggestion utility integrates with [Floating UI](https://floating-ui.com/) for popup positioning. There are two ways to position your popup:

- **Managed positioning with `props.mount`** — the recommended default. The plugin mounts the element, anchors it to the cursor, and keeps it positioned for you.
- **Manual positioning** — the escape hatch. You mount the element yourself and run your own positioning loop using `props.floatingUi` and `props.clientRect`.

### Managed positioning with `props.mount`

Inside your `render` callbacks, call `props.mount(element)` with the popup element you rendered (for example, the `element` of a `ReactRenderer` or `VueRenderer`). The plugin then:

- appends the element into the configured [`container`](#container) (default `document.body`),
- anchors it to the suggestion's cursor rect using your [`placement`](#placement), [`offset`](#offset), [`flip`](#flip), and [`floatingUi`](#floatingui) options,
- automatically repositions it on scroll, resize, and layout shifts via Floating UI's [`autoUpdate`](https://floating-ui.com/docs/autoUpdate) — no manual listeners required, and
- dismisses it on outside clicks when [`dismissOnOutsideClick`](#dismissonoutsideclick) is enabled.

`mount()` returns an `unmount` function. **You must call it in `onExit`** to tear down the auto-update loop and outside-click listener (and to remove the element, if the plugin added it). Forgetting this leaks listeners and leaves orphaned popups in the DOM.

```js
import { ReactRenderer } from '@tiptap/react'
import DropdownList from './DropdownList.jsx'

Suggestion({
  placement: 'top-start',
  offset: { mainAxis: 8 },
  items: ({ query }) => fetchItems(query),
  render: () => {
    let component
    let unmount = null

    return {
      onStart(props) {
        component = new ReactRenderer(DropdownList, {
          props,
          editor: props.editor,
        })

        // The plugin mounts the element, positions it, and keeps it anchored.
        unmount = props.mount(component.element)
      },
      onUpdate(props) {
        component.updateProps(props)
        // No reposition call needed — autoUpdate keeps it anchored.
      },
      onKeyDown(props) {
        if (props.event.key === 'Escape') {
          component.destroy()
          return true
        }
        return component.ref?.onKeyDown(props)
      },
      onExit() {
        unmount?.()
        component.destroy()
      },
    }
  },
})
```

> **Note:**
>
> If the element you pass is already attached to the DOM, the plugin will not move or remove it — it
> only positions it. In that case you remain responsible for mounting and unmounting the element
> yourself. Pass a detached element if you want the plugin to manage the DOM for you.

### Configuring placement, offset, and flip

Use the top-level [`placement`](#placement), [`offset`](#offset), and [`flip`](#flip) options to control where the managed popup appears:

```js
Suggestion({
  placement: 'top-start',
  offset: { mainAxis: 8, crossAxis: 0 },
  flip: true,
})
```

For a positioning `strategy` change or custom middleware (for example `shift` or `size`), add the [`floatingUi`](#floatingui) option. Its `middleware` is appended after the plugin's own `offset` and `flip` middleware:

```js
import { shift, size } from '@floating-ui/dom'

Suggestion({
  placement: 'bottom-end',
  floatingUi: {
    strategy: 'fixed',
    middleware: [
      shift({ padding: 16 }),
      size({
        apply({ availableWidth, availableHeight, elements }) {
          Object.assign(elements.floating.style, {
            maxWidth: `${availableWidth}px`,
            maxHeight: `${availableHeight}px`,
          })
        },
      }),
    ],
  },
})
```

### Customizing how the position is applied

By default, the managed popup's position is applied by writing `position`, `left`, and `top` to the element's style (and the element is hidden until the first measurement resolves, to avoid a flash at the wrong coordinates).

If you need to apply the position differently — a CSS transform, an animation, or writing into a framework ref — pass an `onPosition` callback in the mount options. When you do, the plugin stops writing styles itself and hands you the computed coordinates instead:

```js
onStart(props) {
  component = new ReactRenderer(DropdownList, { props, editor: props.editor })

  unmount = props.mount(component.element, {
    onPosition({ x, y, placement, strategy }) {
      Object.assign(component.element.style, {
        position: strategy,
        transform: `translate(${x}px, ${y}px)`,
      })
    },
  })
}
```

> **Note:**
>
> When you provide `onPosition`, the plugin no longer hides the element before the first
> measurement, so apply the initial position yourself to avoid a flash.

### Following animated or transformed anchors

`autoUpdate` already reacts to scroll, resize, and layout shifts. For anchors that move continuously — inside a transformed or animated container, for instance — opt into frame-by-frame updates by forwarding [`autoUpdate` options](https://floating-ui.com/docs/autoUpdate#options):

```js
unmount = props.mount(component.element, {
  autoUpdate: { animationFrame: true },
})
```

### Mounting into a container

To render the popup inside a specific stacking or scroll context — a modal or dialog, for example — set the [`container`](#container) option. The managed popup is appended there instead of `document.body`:

```js
Suggestion({
  container: '#my-dialog',
})
```

A string is treated as a CSS selector; you can also pass an `HTMLElement` directly. If the selector matches nothing (or is invalid), the plugin falls back to `document.body`.

### Manual positioning (escape hatch)

If you need full control over mounting and positioning, skip `props.mount` entirely. Mount the element yourself, then run your own Floating UI loop using the resolved `props.floatingUi` config together with `props.clientRect`. Because you own the DOM here, you are also responsible for repositioning on `onUpdate` and cleaning up in `onExit`:

```js
import { computePosition, shift } from '@floating-ui/dom'
import { ReactRenderer } from '@tiptap/react'

function reposition(props, element) {
  const reference = { getBoundingClientRect: () => props.clientRect() }

  computePosition(reference, element, props.floatingUi).then(({ x, y, strategy }) => {
    Object.assign(element.style, {
      position: strategy,
      left: `${x}px`,
      top: `${y}px`,
    })
  })
}

Suggestion({
  floatingUi: {
    strategy: 'fixed',
    middleware: [shift({ padding: 8 })],
  },
  render: () => {
    let component

    return {
      onStart(props) {
        component = new ReactRenderer(DropdownList, { props, editor: props.editor })

        if (!props.clientRect) {
          return
        }

        document.body.appendChild(component.element)
        reposition(props, component.element)
      },
      onUpdate(props) {
        component.updateProps(props)

        if (!props.clientRect) {
          return
        }

        reposition(props, component.element)
      },
      onExit() {
        component.element.remove()
        component.destroy()
      },
    }
  },
})
```

> **Note:**
>
> With manual positioning you only reposition on `onUpdate`, so the popup will not follow scroll,
> resize, or layout shifts unless you wire up your own listeners (or Floating UI's `autoUpdate`).
> `props.mount` handles all of that for you, which is why it is the recommended approach.

> **Interactive demo:** [SuggestionPositioning](https://embed.tiptap.dev/preview/Examples/SuggestionPositioning)

## Exiting open suggestions

Sometimes you want your users to be able to exit an an open suggestion without selecting an item. To achieve this, users can either press Escape which will close the open suggestion.
If you want to manually trigger the closing of the suggestion, you can use use `exitSuggestion` utility function to close existing suggestions on your view.

```js
import { exitSuggestion } from '@tiptap/suggestion'
import { PluginKey } from 'prosemirror-state' // optional, if you need to create a custom key

const MySuggestionPluginKey = new PluginKey('my-suggestions') // or use the default 'suggestion'

exitSuggestion(editor.view, MySuggestionPluginKey)

// Alternatively, use the default plugin key:
// exitSuggestion(editor.view, 'suggestion')
```

## Collaboration

When using Tiptap in a collaborative environment, you might notice that suggestions (like mentions) pop open for all users when one user triggers them. This happens because the document change is synced to all clients, and each client's suggestion plugin reacts to the change.

To prevent this, use the `shouldShow` option combined with the `isChangeOrigin` helper from `@tiptap/extension-collaboration`.

```js
import { isChangeOrigin } from '@tiptap/extension-collaboration'

Suggestion({
  // …
  shouldShow: ({ transaction }) => {
    return !isChangeOrigin(transaction)
  },
})
```

By returning `!isChangeOrigin(props.transaction)`, the suggestion will only be activated if the current user initiated the change.

## Source code

[packages/suggestion/](https://github.com/ueberdosis/tiptap/blob/main/packages/suggestion/)
