Emoji extension for Tiptap Editor

An emoji extension for Tiptap that includes a shortcut triggered by `:` to easily insert emojis. The extension also focuses on making the emojis accessible, adhering to a11y (accessibility) standards.

Extension have 3 components:

  1. EmojiCore.jsx is core part of extension. It handles inserting emoji to tiptap document.
  2. Emoji.jsx is used in Tiptap extension. This component handles passing suggestions to EmojiList.jsx component.
  3. EmojiList.jsx render list of emoji suggestions when user type :.
EmojiCore.jsx
import { mergeAttributes, Node, Range } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
 
const EmojiCore = Node.create({
  name: "emoij",
  group: "inline",
  inline: true,
  selectable: false,
  atom: true,
 
  addOptions() {
    return {
      HTMLAttributes: {},
      deleteTriggerWithBackspace: false,
      renderText({ node }) {
        return `${node.attrs.icon ?? node.attrs.name}`;
      },
 
      renderHTML({ options, node }) {
        return [
          "span",
          mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
          `${node.attrs.icon ?? node.attrs.name}`,
        ];
      },
      suggestion: {
        char: ":",
        pluginKey: EmojiPluginKey,
        command: ({ editor, range, props }) => {
          // increase range.to by one when the next node is of type "text"
          // and starts with a space character
          const nodeAfter = editor.view.state.selection.$to.nodeAfter;
          const overrideSpace = nodeAfter?.text?.startsWith(" ");
 
          if (overrideSpace) {
            range.to += 1;
          }
          editor
            .chain()
            .focus()
            .insertContentAt(range, [
              {
                type: this.name,
                attrs: props,
              },
              {
                type: "text",
                text: " ",
              },
            ])
            .run();
 
          window.getSelection()?.collapseToEnd();
        },
        allow: ({ state, range }) => {
          const $from = state.doc.resolve(range.from);
          const type = state.schema.nodes[this.name];
          const allow = !!$from.parent.type.contentMatch.matchType(type);
 
          return allow;
        },
      },
    };
  },
 
  addAttributes() {
    return {
      name: {
        default: null,
        parseHTML: (element) => element.getAttribute("data-name"),
        renderHTML: (attributes) => {
          if (!attributes.name) {
            return {};
          }
 
          return {
            "data-label": attributes.name,
          };
        },
      },
      icon: {
        default: null,
        parseHTML: (element) => element.getAttribute("data-icon"),
        renderHTML: (attributes) => {
          if (!attributes.icon) {
            return {};
          }
 
          return {
            "data-icon": attributes.icon,
          };
        },
      },
    };
  },
 
  parseHTML() {
    return [
      {
        tag: `span[data-role="img"]`,
      },
    ];
  },
 
  renderHTML({ node, HTMLAttributes }) {
    if (this.options.renderLabel !== undefined) {
      return [
        "span",
        mergeAttributes(
          { "data-role": "img" },
          this.options.HTMLAttributes,
          HTMLAttributes
        ),
        this.options.renderLabel({
          options: this.options,
          node,
        }),
      ];
    }
    const mergedOptions = { ...this.options };
 
    mergedOptions.HTMLAttributes = mergeAttributes(
      { role: "img" },
      this.options.HTMLAttributes,
      HTMLAttributes
    );
    const html = this.options.renderHTML({
      options: mergedOptions,
      node,
    });
 
    if (typeof html === "string") {
      return [
        "span",
        mergeAttributes(
          { role: "img" },
          this.options.HTMLAttributes,
          HTMLAttributes
        ),
        html,
      ];
    }
    return html;
  },
 
  renderText({ node }) {
    if (this.options.renderLabel !== undefined) {
      return this.options.renderLabel({
        options: this.options,
        node,
      });
    }
    return this.options.renderText({
      options: this.options,
      node,
    });
  },
 
  addKeyboardShortcuts() {
    return {
      Backspace: () =>
        this.editor.commands.command(({ tr, state }) => {
          let isMention = false;
          const { selection } = state;
          const { empty, anchor } = selection;
 
          if (!empty) {
            return false;
          }
 
          state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
            if (node.type.name === this.name) {
              isMention = true;
              tr.insertText(
                this.options.deleteTriggerWithBackspace
                  ? ""
                  : this.options.suggestion.char || "",
                pos,
                pos + node.nodeSize
              );
 
              return false;
            }
          });
 
          return isMention;
        }),
    };
  },
 
  addProseMirrorPlugins() {
    return [
      Suggestion({
        editor: this.editor,
        ...this.options.suggestion,
      }),
    ];
  },
});
 
export default EmojiCore;
Emoji.jsx
import tippy from "tippy.js";
import { ReactRenderer } from "@tiptap/react";
import { init, SearchIndex } from "emoji-mart";
import data from "@emoji-mart/data";
import EmojiCore from "./EmojiCore";
import EmojiList from "./EmojiList";
 
init({ data });
 
const Emoji = EmojiCore.configure({
  suggestion: {
    items: async ({ query }) => {
      const results = [];
 
      if (query.length) {
        const response = await SearchIndex.search(query);
 
        return response.map((emoji) => ({
          id: emoji.id,
          name: emoji.name,
          icon: emoji.skins[0].native,
          shortcode: emoji.skins[0].shortcodes,
        }));
      }
 
      return results;
    },
 
    render: () => {
      let popup;
      let component;
 
      return {
        onStart: (props) => {
          component = new ReactRenderer(EmojiList, {
            props,
            editor: props.editor,
          });
 
          if (!props.clientRect) {
            return;
          }
 
          popup = tippy("body", {
            getReferenceClientRect: () => DOMRect,
            appendTo: () => document.body,
            content: component.element,
            showOnCreate: true,
            interactive: true,
            trigger: "manual",
            placement: "bottom-start",
          });
        },
 
        onUpdate(props) {
          component.updateProps(props);
 
          if (!props.clientRect) {
            return;
          }
 
          popup[0].setProps({
            getReferenceClientRect: () => DOMRect,
          });
        },
 
        onKeyDown(props) {
          if (props.event.key === "Escape") {
            popup[0].hide();
            return true;
          }
          return component.ref?.onKeyDown(props) ?? false;
        },
 
        onExit() {
          popup[0].destroy();
          component.destroy();
        },
      };
    },
  },
});
 
export default Emoji;
EmojiList.jsx
import {
  forwardRef,
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
} from "react";
 
const EmojiList = forwardRef((props, ref) => {
  const { items, command } = props;
 
  const emojiListRef = useRef([]);
  const [selectedIndex, setSelectedIndex] = useState(0);
 
  const selectItem = (index) => {
    const item = items[index];
    if (item) {
      command(item);
    }
  };
 
  const enterHandler = () => selectItem(selectedIndex);
 
  const downHandler = () => {
    setSelectedIndex((prevIndex) => (prevIndex + 1) % items.length);
  };
 
  const upHandler = () => {
    setSelectedIndex(
      (prevIndex) => (prevIndex + items.length - 1) % items.length
    );
  };
 
  useEffect(() => setSelectedIndex(0), [items]);
 
  useImperativeHandle(ref, () => ({
    onKeyDown: ({ event }) => {
      if (event.key === "ArrowUp") {
        upHandler();
        return true;
      }
 
      if (event.key === "ArrowDown") {
        downHandler();
        return true;
      }
 
      if (event.key === "Enter") {
        enterHandler();
        return true;
      }
 
      return false;
    },
  }));
 
  if (!items?.length) return null;
 
  return (
    <div className="bg-white h-72 overflow-y-auto border rounded-lg overflow-hidden shadow-xl w-56">
      <ul>
        {items.map((emoji, index) => {
          if (selectedIndex === index) {
            emojiListRef.current[selectedIndex]?.scrollIntoView({
              block: "nearest",
            });
          }
          return (
            <li
              key={emoji.id}
              ref={(element) => {
                emojiListRef.current[index] = element;
              }}
            >
              <button
                type="button"
                onClick={() => selectItem(index)}
                className={`text-black flex items-center gap-x-2 px-4 py-2 hover:bg-neutral-100 transition-all ease-in-out cursor-pointer text-left w-full ${
                  selectedIndex === index ? "bg-neutral-100" : ""
                }`}
              >
                <p className="text-base">{emoji.icon}</p>
                <p>{emoji.shortcode}</p>
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
});
 
export default EmojiList;

Usage

Editor.jsx
import { useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Emoji from "./Emoji";
 
const Editor = () => {
  const editor = useEditor({
    extensions: [StarterKit, Emoji],
  });
 
  return <EditorContent editor={editor} />;
};
 
export default Editor;

Demo

Emoji extension TipTap