How to Add Custom Emoji Picker Support to Tiptap Editor in React

You can add custom emoji picker support to the Tiptap editor by replacing the default picker component in RichTextEmoji.tsx with a custom React component that implements the onEmojiSelect callback, allowing you to insert any emoji string at the cursor position without modifying the core extension logic.

The hunghg255/reactjs-tiptap-editor repository implements emoji functionality through a decoupled architecture that separates the Tiptap extension logic from the React UI layer. Adding custom emoji picker support requires only swapping the presentation component in the toolbar button wrapper while maintaining the underlying insertion mechanism defined in src/extensions/Emoji/Emoji.ts.

Architecture Overview

The editor’s emoji system consists of three decoupled layers that enable easy customization:

Because the picker UI is isolated from the extension core, you can replace emoji-picker.tsx with any custom component that accepts an onEmojiSelect prop receiving an object with an emoji string property.

Step-by-Step Implementation

1. Create a Custom Picker Component

Create a new file at src/components/ui/custom-emoji-picker.tsx that exposes the required callback interface. The component must accept an onEmojiSelect prop that receives { emoji: string } when a user selects an emoji.

import React, { useState } from 'react';
import { MY_EMOJI_LIST } from '@/constants/my-emoji-list';

interface CustomEmojiPickerProps {
  className?: string;
  onEmojiSelect: (payload: { emoji: string }) => void;
}

export const CustomEmojiPicker = ({ 
  className, 
  onEmojiSelect 
}: CustomEmojiPickerProps) => {
  const [query, setQuery] = useState('');
  
  const filteredEmojis = MY_EMOJI_LIST.filter(e =>
    e.name.toLowerCase().includes(query.toLowerCase()),
  );

  return (
    <div className={`p-2 w-64 ${className}`}>
      <input
        type="text"
        placeholder="Search emojis..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        className="w-full p-2 mb-2 border rounded"
      />
      <div className="grid grid-cols-8 gap-1">
        {filteredEmojis.map(({ emoji, name }) => (
          <button
            key={name}
            onClick={() => onEmojiSelect({ emoji })}
            className="p-1 text-xl hover:bg-gray-100 rounded"
            title={name}
          >
            {emoji}
          </button>
        ))}
      </div>
    </div>
  );
};

2. Wire the Custom Picker into the Toolbar

Modify src/extensions/Emoji/components/RichTextEmoji.tsx to import your custom picker and replace the default <EmojiPicker /> component. The EmojiPickerWrap function handles the popover state and passes the selected emoji to the onSelectEmoji callback, which executes the Tiptap chain command.

import React, { useState } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components';
import { CustomEmojiPicker } from '@/components/ui/custom-emoji-picker';

interface IProps {
  onSelectEmoji: (emoji: string) => void;
  children: React.ReactNode;
  disabled?: boolean;
}

function EmojiPickerWrap({ onSelectEmoji, children, disabled }: IProps) {
  const [isOpen, setIsOpen] = useState(false);

  const onOpenChange = (open: boolean) => {
    if (disabled) return;
    setIsOpen(open);
  };

  return (
    <Popover onOpenChange={onOpenChange} open={isOpen}>
      <PopoverTrigger asChild>
        {children}
      </PopoverTrigger>
      <PopoverContent className="!richtext-w-fit !richtext-p-0">
        <CustomEmojiPicker
          className="!richtext-h-[342px]"
          onEmojiSelect={({ emoji }) => {
            onSelectEmoji(emoji);
            setIsOpen(false);
          }}
        />
      </PopoverContent>
    </Popover>
  );
}

export default EmojiPickerWrap;

3. Provide a Custom Emoji Data Source

Define your emoji list in a separate file (e.g., src/constants/my-emoji-list.ts) to replace or extend the default EMOJI_LIST from playground/src/emojis.ts. Each entry must contain a name and emoji property.

// src/constants/my-emoji-list.ts
export const MY_EMOJI_LIST = [
  { name: 'heart', emoji: '❤️' },
  { name: 'fire', emoji: '🔥' },
  { name: 'brand-logo', emoji: '🅱️' },
  { name: 'product-star', emoji: '⭐' },
  // Add custom SVG references or brand-specific icons here
];

Why This Integration Works

The architectural decoupling in reactjs-tiptap-editor makes custom picker integration seamless for three reasons:

  • Isolated UI layer: The RichTextEmoji component only requires an onSelectEmoji callback. The underlying extension (Emoji.ts) executes editor.chain().focus().insertContentAt($anchor.pos, emoji).run() regardless of which UI produced the emoji string.
  • Highest extension priority: The emoji node is registered with priority 200 (EXTENSION_PRIORITY_HIGHEST), ensuring it renders before other inline nodes and maintains consistent cursor positioning after insertion.
  • Flexible data contracts: The picker receives a flat array of { name, emoji } objects, allowing you to substitute EMOJI_LIST with API-fetched data, filtered subsets, or brand-specific icon sets without touching the extension core.

Summary

  • Decoupled architecture: The emoji UI in RichTextEmoji.tsx is separate from the Tiptap extension logic in Emoji.ts, enabling drop-in picker replacements.
  • Required callback interface: Custom pickers must implement onEmojiSelect: (payload: { emoji: string }) => void to communicate with the editor.
  • Zero core modifications: You can swap the picker component and emoji data source without changing the extension definition or priority settings.
  • Custom data support: Replace EMOJI_LIST from playground/src/emojis.ts with your own dataset to control available emojis.

Frequently Asked Questions

Can I use a third-party emoji picker library like Emoji Mart?

Yes. Import any React emoji picker library into a wrapper component that translates its selection event to the onEmojiSelect({ emoji: string }) interface expected by RichTextEmoji.tsx. As long as your wrapper calls onEmojiSelect with the emoji string, the Tiptap extension will insert it at the cursor position without requiring changes to src/extensions/Emoji/Emoji.ts.

Do I need to modify the Tiptap extension core to change the emoji picker appearance?

No. The extension core in src/extensions/Emoji/Emoji.ts only defines the node type and toolbar button configuration. The picker UI is rendered entirely within src/extensions/Emoji/components/RichTextEmoji.tsx and src/components/ui/emoji-picker.tsx, allowing you to modify or replace the visual layer independently of the document model.

How do I restrict which emojis appear in the picker?

Create a filtered array in your custom picker component or define a new constant file (e.g., src/constants/limited-emojis.ts) that exports only the emoji objects you want to display. Import this array instead of the default EMOJI_LIST in your custom picker. The extension will accept any string passed to onEmojiSelect, regardless of whether it exists in a predefined list.

Why is the emoji extension priority set to highest (200)?

The EXTENSION_PRIORITY_HIGHEST setting ensures the emoji node is processed before other inline nodes during the document rendering pipeline. This guarantees that emoji insertion at the cursor position takes precedence over other inline decorations or marks, preventing layout conflicts and ensuring consistent behavior when the picker inserts content via insertContentAt.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →