Mention

Mentions for everyone! This plugin allows the user to choose an entry from a list. After selection an entry the search text will be replace with the selected entity. The list of suggestions mentions needs to contain at least a name to display. If desired a link and/or an avatar image can be provided.

Escape Behaviour

While the suggestion popover is open, the user can close it by pressing ESC. This will be stored for as long as the the selection stays inside the word that triggered the search. After the selection left this word once the escape behaviour will be reset. The suggestions will appear again once the user selects the word that that triggered the selection.

Supported Environment

  • Desktop: Yes
  • Mobile: Yes
  • Screen-reader: Yes

Getting Started

npm install @draft-js-plugins/editor
npm install @draft-js-plugins/mention
Please checkout the 'Simple Example' further down the page.

Importing the default styles

The plugin ships with a default styling available at this location in the installed package:  node_modules/@draft-js-plugins/mention/lib/plugin.css

Webpack Usage

  • 1. Install Webpack loaders:  npm i style-loader css-loader --save-dev
  • 2. Add the below section to Webpack config (if your config already has a loaders array, simply add the below loader object to your existing list.
    module.exports = {
      module: {
        loaders: [
          {
            test: /plugin\.css$/,
            loaders: ['style-loader', 'css'],
          },
        ],
      },
    };
    
  • 3. Add the below import line to your component to tell Webpack to inject the style to your component.
    import '@draft-js-plugins/mention/lib/plugin.css';
  • 4. Restart Webpack.

Configuration Parameters

themeObject of CSS classes with the following keys.
mention:CSS class for mention text.
mentionSuggestions:CSS class for suggestions component.
mentionSuggestionsPopup:CSS class for popup.js suggestions component.
mentionSuggestionsPopupVisible:CSS class for popup.js suggestions component when it is visible. Can be used for animations.
mentionSuggestionsEntry:CSS class for an entry in the suggestions component.
mentionSuggestionsEntryFocused:CSS class for the focused entry in the suggestions component.
mentionSuggestionsEntryText:CSS class for an entry’s text in the suggestions component.
mentionSuggestionsEntryAvatar:CSS class for an entry’s avatar image in the suggestions component.
positionSuggestionsDeprecated, use popperOptions instead The function can be used to manipulate the position of the popover containing the suggestions. It receives one object as arguments containing the visible rectangle surrounding the decorated search string including the @. In addition the object contains prevProps and props. An object should be returned which can contain all sorts of styles. The defined properties will be applied as inline-styles.
popperOptionsThis options will be used to initialize popper.js. Read in detail about it here.
entityMutabilityCan be one of: "IMMUTABLE", "SEGMENTED" or "MUTABLE". Read in detail about it here
mentionPrefixAllows you to define a custom prefix to add to the selected mention's name. Can either be a string or a function that takes the `mentionTrigger` as an argument and returns a string. Defaults to an empty string.
mentionTriggerAllows you to provide a array custom character to change when the search is triggered. By default it is set to `@`. By default typing `@` will trigger the search for mentions. It also does support a multi-charater mentionTrigger. You can use it like `mentionTrigger: ['@', '(']`.
mentionRegExpAllows you to overwrite the regular expression for initiating the dropdown. By default this supports any alphanumeric character as well as Chinese, Japanese & Korean characters. We are happy to accept pull requests to extend the default mentionRegExp as well.
supportWhitespaceAllows you to support a whitespace while typing a search option, useful for searching first and last names. By default this is set to `false`
mentionComponentIf provided the passed component is used to render a Mention. It receives the following props: entityKey, mention, className & decoratedText

MentionSuggestions

The MentionSuggestions component is part of the plugin and should placed somewhere in the JSX after the Editor. It takes the following props:
openControlled state of mention popup.
onOpenChangeA callback which is triggered whenever the suggestions popover should be opened or closed.
suggestionsThe list of suggestions to be shown.
onSearchChangeA callback which is triggered whenever the search term changes. The first argument is an object which contains the search term in the property value.
entryComponentComponent to be used as the template for each of the suggestions entry.
onAddMentionA callback which is triggered whenever the mention is about to be added. The first argument of this callback will contain the mention entry.
popoverComponentDeprecated, use popperOptions/popoverContainer instead Component to be used as the template for the popover (the parent of entryComponent). Defaults to a div.
popoverContainerDefaults to a div which is bound to popper.js. If don't want to have a popover you can overwrite the component with you own.
renderEmptyPopupIf set to true if will render the popup even if there are no mentions to show. This can for example be used to show a message that there are no matching mentions.
mentionSuggestionsComponentComponent to be used to render the suggestions dropdown. It must implement the same interface like MentionSuggestions. Defaults to MentionSuggestions.
Additional properties are passed to the popoverComponent

Additional Exports

In addition to the plugin the module exports defaultSuggestionsFilter. As first argument it takes the search term as a String. The second argument is an array of mentions. The third argument is a trigger that is used to filter multi mentions. The function returns the filter list based on substring matches.
import { defaultSuggestionsFilter } from '@draft-js-plugins/mention';

Simple Example

SimpleMentionEditor.tsx
import React, {
  ReactElement,
  useCallback,
  useMemo,
  useRef,
  useState,
} from 'react';
import { EditorState } from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createMentionPlugin, {
  defaultSuggestionsFilter,
} from '@draft-js-plugins/mention';
import editorStyles from './SimpleMentionEditor.module.css';
import mentions from './Mentions';

export default function SimpleMentionEditor(): ReactElement {
  const ref = useRef<Editor>(null);
  const [editorState, setEditorState] = useState(() =>
    EditorState.createEmpty()
  );
  const [open, setOpen] = useState(false);
  const [suggestions, setSuggestions] = useState(mentions);

  const { MentionSuggestions, plugins } = useMemo(() => {
    const mentionPlugin = createMentionPlugin();
    // eslint-disable-next-line no-shadow
    const { MentionSuggestions } = mentionPlugin;
    // eslint-disable-next-line no-shadow
    const plugins = [mentionPlugin];
    return { plugins, MentionSuggestions };
  }, []);

  const onOpenChange = useCallback((_open: boolean) => {
    setOpen(_open);
  }, []);
  const onSearchChange = useCallback(({ value }: { value: string }) => {
    setSuggestions(defaultSuggestionsFilter(value, mentions));
  }, []);

  return (
    <div
      className={editorStyles.editor}
      onClick={() => {
        ref.current!.focus();
      }}
    >
      <Editor
        editorKey={'editor'}
        editorState={editorState}
        onChange={setEditorState}
        plugins={plugins}
        ref={ref}
      />
      <MentionSuggestions
        open={open}
        onOpenChange={onOpenChange}
        suggestions={suggestions}
        onSearchChange={onSearchChange}
        onAddMention={() => {
          // get the mention object selected
        }}
      />
    </div>
  );
}
Mentions.ts
import { MentionData } from '@draft-js-plugins/mention';

const mentions: MentionData[] = [
  {
    name: 'Matthew Russell',
    link: 'https://twitter.com/mrussell247',
    avatar:
      'https://pbs.twimg.com/profile_images/517863945/mattsailing_400x400.jpg',
  },
  {
    name: 'Julian Krispel-Samsel',
    link: 'https://twitter.com/juliandoesstuff',
    avatar: 'https://avatars2.githubusercontent.com/u/1188186?v=3&s=400',
  },
  {
    name: 'Jyoti Puri',
    link: 'https://twitter.com/jyopur',
    avatar: 'https://avatars0.githubusercontent.com/u/2182307?v=3&s=400',
  },
  {
    name: 'Max Stoiber',
    link: 'https://twitter.com/mxstbr',
    avatar: 'https://avatars0.githubusercontent.com/u/7525670?s=200&v=4',
  },
  {
    name: 'Nik Graf',
    link: 'https://twitter.com/nikgraf',
    avatar: 'https://avatars0.githubusercontent.com/u/223045?v=3&s=400',
  },
  {
    name: 'Pascal Brandt',
    link: 'https://twitter.com/psbrandt',
    avatar:
      'https://pbs.twimg.com/profile_images/688487813025640448/E6O6I011_400x400.png',
  },
];

export default mentions;
SimpleMentionEditor.module.css
.editor {
  box-sizing: border-box;
  border: 1px solid #ddd;
  cursor: text;
  padding: 16px;
  border-radius: 2px;
  margin-bottom: 2em;
  box-shadow: inset 0px 1px 8px -3px #ABABAB;
  background: #fefefe;
}

.editor :global(.public-DraftEditor-content) {
  min-height: 140px;
}

Custom Themed Mention Example

CustomMentionEditor.ts
import React, {
  MouseEvent,
  ReactElement,
  useCallback,
  useMemo,
  useRef,
  useState,
} from 'react';
import { EditorState } from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createMentionPlugin, {
  defaultSuggestionsFilter,
  MentionData,
  MentionPluginTheme,
} from '@draft-js-plugins/mention';
import editorStyles from './CustomMentionEditor.module.css';
import mentionsStyles from './MentionsStyles.module.css';
import mentions from './Mentions';

export interface EntryComponentProps {
  className?: string;
  onMouseDown(event: MouseEvent): void;
  onMouseUp(event: MouseEvent): void;
  onMouseEnter(event: MouseEvent): void;
  role: string;
  id: string;
  'aria-selected'?: boolean | 'false' | 'true';
  theme?: MentionPluginTheme;
  mention: MentionData;
  isFocused: boolean;
  searchValue?: string;
}

function Entry(props: EntryComponentProps): ReactElement {
  const {
    mention,
    theme,
    searchValue, // eslint-disable-line @typescript-eslint/no-unused-vars
    isFocused, // eslint-disable-line @typescript-eslint/no-unused-vars
    ...parentProps
  } = props;

  return (
    <div {...parentProps}>
      <div className={theme?.mentionSuggestionsEntryContainer}>
        <div className={theme?.mentionSuggestionsEntryContainerLeft}>
          <img
            src={mention.avatar}
            className={theme?.mentionSuggestionsEntryAvatar}
            role="presentation"
          />
        </div>

        <div className={theme?.mentionSuggestionsEntryContainerRight}>
          <div className={theme?.mentionSuggestionsEntryText}>
            {mention.name}
          </div>

          <div className={theme?.mentionSuggestionsEntryTitle}>
            {mention.title}
          </div>
        </div>
      </div>
    </div>
  );
}

export default function CustomMentionEditor(): ReactElement {
  const ref = useRef<Editor>(null);
  const [editorState, setEditorState] = useState(() =>
    EditorState.createEmpty()
  );
  const [open, setOpen] = useState(false);
  const [suggestions, setSuggestions] = useState(mentions);

  const { MentionSuggestions, plugins } = useMemo(() => {
    const mentionPlugin = createMentionPlugin({
      entityMutability: 'IMMUTABLE',
      theme: mentionsStyles,
      mentionPrefix: '@',
      supportWhitespace: true,
    });
    // eslint-disable-next-line no-shadow
    const { MentionSuggestions } = mentionPlugin;
    // eslint-disable-next-line no-shadow
    const plugins = [mentionPlugin];
    return { plugins, MentionSuggestions };
  }, []);

  const onChange = useCallback((_editorState: EditorState) => {
    setEditorState(_editorState);
  }, []);
  const onOpenChange = useCallback((_open: boolean) => {
    setOpen(_open);
  }, []);
  const onSearchChange = useCallback(({ value }: { value: string }) => {
    setSuggestions(defaultSuggestionsFilter(value, mentions));
  }, []);

  return (
    <div
      className={editorStyles.editor}
      onClick={() => {
        ref.current!.focus();
      }}
    >
      <Editor
        editorKey={'editor'}
        editorState={editorState}
        onChange={onChange}
        plugins={plugins}
        ref={ref}
      />
      <MentionSuggestions
        open={open}
        onOpenChange={onOpenChange}
        suggestions={suggestions}
        onSearchChange={onSearchChange}
        onAddMention={() => {
          // get the mention object selected
        }}
        entryComponent={Entry}
        popoverContainer={({ children }) => <div>{children}</div>}
      />
    </div>
  );
}
MentionsStyles.module.css
.mention {
  color: #4a85bb;
  text-decoration: none;
}

.mentionSuggestions {
  border-top: 1px solid #eee;
  background: #fff;
  border-radius: 2px;
  cursor: pointer;
  padding-top: 8px;
  padding-bottom: 8px;
  display: flex;
  flex-direction: column;
  box-sizing: border-box;
  transform-origin: 50% 0%;
  transform: scaleY(0);
  margin: -16px;
}

.mentionSuggestionsEntryContainer {
  display: table;
  width: 100%;
}

.mentionSuggestionsEntryContainerLeft,
.mentionSuggestionsEntryContainerRight {
  display: table-cell;
  vertical-align: middle;
}

.mentionSuggestionsEntryContainerRight {
  width: 100%;
  padding-left: 8px;
}

.mentionSuggestionsEntry {
  padding: 7px 10px 3px 10px;
  transition: background-color 0.4s cubic-bezier(.27,1.27,.48,.56);
}

.mentionSuggestionsEntry:active {
  background-color: #cce7ff;
}

.mentionSuggestionsEntryFocused {
  composes: mentionSuggestionsEntry;
  background-color: #e6f3ff;
}

.mentionSuggestionsEntryText,
.mentionSuggestionsEntryTitle {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.mentionSuggestionsEntryText {
}

.mentionSuggestionsEntryTitle {
  font-size: 80%;
  color: #a7a7a7;
}

.mentionSuggestionsEntryAvatar {
  display: block;
  width: 30px;
  height: 30px;
  border-radius: 50%;
}
Mentions.ts
import { MentionData } from '@draft-js-plugins/mention';

const mentions: MentionData[] = [
  {
    name: 'Matthew Russell',
    title: 'Senior Software Engineer',
    avatar:
      'https://pbs.twimg.com/profile_images/517863945/mattsailing_400x400.jpg',
  },
  {
    name: 'Julian Krispel-Samsel',
    title: 'United Kingdom',
    avatar: 'https://avatars2.githubusercontent.com/u/1188186?v=3&s=400',
  },
  {
    name: 'Jyoti Puri',
    title: 'New Delhi, India',
    avatar: 'https://avatars0.githubusercontent.com/u/2182307?v=3&s=400',
  },
  {
    name: 'Max Stoiber',
    title:
      'Travels around the world, brews coffee, skis mountains and makes stuff on the web.',
    avatar: 'https://avatars0.githubusercontent.com/u/7525670?s=200&v=4',
  },
  {
    name: 'Nik Graf',
    title: 'Passionate about Software Architecture, UX, Skiing & Triathlons',
    avatar: 'https://avatars0.githubusercontent.com/u/223045?v=3&s=400',
  },
  {
    name: 'Pascal Brandt',
    title: 'HeathIT hacker and researcher',
    avatar:
      'https://pbs.twimg.com/profile_images/688487813025640448/E6O6I011_400x400.png',
  },
  {
    name: 'Łukasz Bąk',
    title: 'Randomly Generated User',
    avatar: 'https://randomuser.me/api/portraits/men/36.jpg',
  },
  {
    name: '佐々木 小次郎',
    title: 'Famous Japanese swordsman (SAMURAI)',
    avatar:
      'https://upload.wikimedia.org/wikipedia/commons/0/08/Sasaki-Ganryu-%28Kojiro%29-by-Utagawa-Kuniyoshi-1845.png',
    url: 'https://en.wikipedia.org/wiki/Sasaki_Kojir%C5%8D',
  },
];

export default mentions;
CustomMentionEditor.module.css
.editor {
  box-sizing: border-box;
  border: 1px solid #ddd;
  cursor: text;
  padding: 16px;
  border-radius: 2px;
  margin-bottom: 2em;
  box-shadow: inset 0px 1px 8px -3px #ABABAB;
  background: #fefefe;
}

.editor :global(.public-DraftEditor-content) {
  min-height: 140px;
}

Remote Data Mention Example

RemoteMentionEditor.tsx
import React, {
  ReactElement,
  useCallback,
  useMemo,
  useRef,
  useState,
} from 'react';
import { EditorState } from 'draft-js';

import Editor from '@draft-js-plugins/editor';

import createMentionPlugin from '@draft-js-plugins/mention';
import editorStyles from './RemoteMentionEditor.module.css';

export default function RemoteMentionEditor(): ReactElement {
  const ref = useRef<Editor>(null);
  const [editorState, setEditorState] = useState(() =>
    EditorState.createEmpty()
  );
  const [open, setOpen] = useState(false);
  const [suggestions, setSuggestions] = useState([]);

  const { MentionSuggestions, plugins } = useMemo(() => {
    const mentionPlugin = createMentionPlugin();
    // eslint-disable-next-line no-shadow
    const { MentionSuggestions } = mentionPlugin;
    // eslint-disable-next-line no-shadow
    const plugins = [mentionPlugin];
    return { plugins, MentionSuggestions };
  }, []);

  const onOpenChange = useCallback((_open: boolean) => {
    setOpen(_open);
  }, []);
  const onSearchChange = useCallback(({ value }: { value: string }) => {
    // An import statment would break server-side rendering.
    require('whatwg-fetch'); // eslint-disable-line global-require

    // while you normally would have a dynamic server that takes the value as
    // a workaround we use this workaround to show different results
    let url = '/data/mentionsA.json';
    if (value.length === 1) {
      url = '/data/mentionsB.json';
    } else if (value.length > 1) {
      url = '/data/mentionsC.json';
    }

    fetch(url)
      .then((response) => response.json())
      .then((data) => {
        setSuggestions(data);
      });
  }, []);

  return (
    <div
      className={editorStyles.editor}
      onClick={() => {
        ref.current!.focus();
      }}
    >
      <Editor
        editorKey={'editor'}
        editorState={editorState}
        onChange={setEditorState}
        plugins={plugins}
        ref={ref}
      />
      <MentionSuggestions
        open={open}
        onOpenChange={onOpenChange}
        suggestions={suggestions}
        onSearchChange={onSearchChange}
        onAddMention={() => {
          // get the mention object selected
        }}
      />
    </div>
  );
}
RemoteMentionEditor.module.css
.editor {
  box-sizing: border-box;
  border: 1px solid #ddd;
  cursor: text;
  padding: 16px;
  border-radius: 2px;
  margin-bottom: 2em;
  box-shadow: inset 0px 1px 8px -3px #ABABAB;
  background: #fefefe;
}

.editor :global(.public-DraftEditor-content) {
  min-height: 140px;
}

Custom Mention Component Example

CustomComponentMentionEditor.tsx
import React, {
  ReactElement,
  useCallback,
  useMemo,
  useRef,
  useState,
} from 'react';
import { EditorState } from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createMentionPlugin, {
  defaultSuggestionsFilter,
} from '@draft-js-plugins/mention';
import editorStyles from './CustomComponentMentionEditor.module.css';
import mentions from './Mentions';

export default function CustomComponentMentionEditor(): ReactElement {
  const ref = useRef<Editor>(null);
  const [editorState, setEditorState] = useState(() =>
    EditorState.createEmpty()
  );
  const [open, setOpen] = useState(false);
  const [suggestions, setSuggestions] = useState(mentions);

  const { MentionSuggestions, plugins } = useMemo(() => {
    const mentionPlugin = createMentionPlugin({
      mentionComponent(mentionProps) {
        return (
          <span
            className={mentionProps.className}
            // eslint-disable-next-line no-alert
            onClick={() => alert('Clicked on the Mention!')}
          >
            {mentionProps.children}
          </span>
        );
      },
    });
    // eslint-disable-next-line no-shadow
    const { MentionSuggestions } = mentionPlugin;
    // eslint-disable-next-line no-shadow
    const plugins = [mentionPlugin];
    return { plugins, MentionSuggestions };
  }, []);

  const onOpenChange = useCallback((_open: boolean) => {
    setOpen(_open);
  }, []);
  const onSearchChange = useCallback(
    ({ trigger, value }: { trigger: string; value: string }) => {
      setSuggestions(defaultSuggestionsFilter(value, mentions, trigger));
    },
    []
  );

  return (
    <div
      className={editorStyles.editor}
      onClick={() => {
        ref.current!.focus();
      }}
    >
      <Editor
        editorKey={'editor'}
        editorState={editorState}
        onChange={setEditorState}
        plugins={plugins}
        ref={ref}
      />
      <MentionSuggestions
        open={open}
        onOpenChange={onOpenChange}
        suggestions={suggestions}
        onSearchChange={onSearchChange}
        onAddMention={() => {
          // get the mention object selected
        }}
      />
    </div>
  );
}
CustomComponentMentionEditor.module.css
.editor {
  box-sizing: border-box;
  border: 1px solid #ddd;
  cursor: text;
  padding: 16px;
  border-radius: 2px;
  margin-bottom: 2em;
  box-shadow: inset 0px 1px 8px -3px #ABABAB;
  background: #fefefe;
}

.editor :global(.public-DraftEditor-content) {
  min-height: 140px;
}

Multi Mention Triggers

MultiMentionTriggers.tsx
import React, { ReactElement, useRef, useState, useCallback } from 'react';
import { EditorState } from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createMentionPlugin, {
  defaultSuggestionsFilter,
} from '@draft-js-plugins/mention';
import editorStyles from './MultiMentionTriggers.module.css';
import mentions from './Mentions';

const mentionPlugin = createMentionPlugin({
  mentionTrigger: ['@', '#'],
  mentionPrefix: (trigger) => trigger,
});
const { MentionSuggestions } = mentionPlugin;
const plugins = [mentionPlugin];

interface MentionData {
  link?: string;
  avatar?: string;
  name: string;
  id?: null | string | number;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  [x: string]: any;
}

const SimpleMentionEditor = (): ReactElement => {
  const ref = useRef<Editor>(null);
  const [editorState, setEditorState] = useState(EditorState.createEmpty());

  const [open, setOpen] = useState(false);
  const [suggestions, setSuggestions] = useState<MentionData[]>(mentions['@']);

  const onChange = useCallback((_editorState: EditorState) => {
    setEditorState(_editorState);
  }, []);
  const onOpenChange = useCallback((_open: boolean) => {
    setOpen(_open);
  }, []);
  const onSearchChange = useCallback(
    ({ trigger, value }: { trigger: string; value: string }) => {
      setSuggestions(
        defaultSuggestionsFilter(value, mentions, trigger) as MentionData[]
      );
    },
    []
  );

  return (
    <div
      className={editorStyles.editor}
      onClick={() => {
        ref.current!.focus();
      }}
    >
      <Editor
        editorState={editorState}
        onChange={onChange}
        plugins={plugins}
        ref={ref}
      />
      <MentionSuggestions
        open={open}
        onOpenChange={onOpenChange}
        onSearchChange={onSearchChange}
        suggestions={suggestions}
        onAddMention={() => {
          // get the mention object selected
        }}
      />
    </div>
  );
};

export default SimpleMentionEditor;
MultiMentionTriggers.module.css
.editor {
    box-sizing: border-box;
    border: 1px solid #ddd;
    cursor: text;
    padding: 16px;
    border-radius: 2px;
    margin-bottom: 2em;
    box-shadow: inset 0px 1px 8px -3px #ABABAB;
    background: #fefefe;
  }
  
  .editor :global(.public-DraftEditor-content) {
    min-height: 140px;
  }