Suggestion utility

VersionDownloads

This utility helps with all kinds of suggestions in the editor. Have a look at the Mention or Emoji 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.

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.

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

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)

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.

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.

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

Default: 'bottom-start'

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.

Default: { mainAxis: 4, crossAxis: 0 }

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.

Default: true

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

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 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.

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:

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 (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.

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:

{
  /** 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 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:

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 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 (default document.body),
  • anchors it to the suggestion's cursor rect using your placement, offset, flip, and floatingUi options,
  • automatically repositions it on scroll, resize, and layout shifts via Floating UI's autoUpdate — no manual listeners required, and
  • dismisses it on outside clicks when 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.

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, offset, and flip options to control where the managed popup appears:

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 option. Its middleware is appended after the plugin's own offset and flip middleware:

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:

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:

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 option. The managed popup is appended there instead of document.body:

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:

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.

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.

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.

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/