Create an Emoji Inline Node with Markdown Support
This guide shows how to add Markdown support for a small atomic inline node that renders emoji shortcodes (for example :smile:). We'll walk through four clear steps and include a full example at each step so you always have the complete context:
- Create the basic
emojiNode (no Markdown). - Step 2: Add a tokenizer to convert
:name:into tokens. - Step 3: Add the parser to turn tokens into Tiptap JSON.
- Step 4: Add the renderer to serialize Tiptap JSON back to Markdown.
Example shorthand we'll support:
Hello :smile: world!Step 1: Create the basic emoji node
Start by defining a small atomic inline node that stores a name attribute and renders an emoji in HTML.
import { Node } from '@tiptap/core' const emojiMap = { smile: '😊', heart: '❤️', thumbsup: '👍', fire: '🔥', // add more mappings as needed } export const Emoji = Node.create({ name: 'emoji', group: 'inline', inline: true, atom: true, addAttributes() { return { name: { default: 'smile' }, } }, parseHTML() { return [{ tag: 'span[data-emoji]' }] }, renderHTML({ node }) { const emoji = emojiMap[node.attrs.name] || node.attrs.name || 'smile' return ['span', { 'data-emoji': node.attrs.name }, emoji] }, })Notes:
- The node is
atom: trueandinlineso it behaves like an indivisible inline piece of content. emojiMapis used to map short names to actual emoji characters for HTML output.
Step 2: Add a custom Markdown tokenizer
The tokenizer recognizes :name: shortcodes in inline Markdown and returns a token with the emoji name. Below is the full extension including the tokenizer so you can see how it integrates with the base Node.
import { Node } from '@tiptap/core' const emojiMap = { smile: '😊', heart: '❤️', thumbsup: '👍', fire: '🔥', // add more mappings as needed } export const Emoji = Node.create({ name: 'emoji', group: 'inline', inline: true, atom: true, addAttributes() { return { name: { default: 'smile' }, } }, parseHTML() { return [{ tag: 'span[data-emoji]' }] }, renderHTML({ node }) { const emoji = emojiMap[node.attrs.name] || node.attrs.name || 'smile' return ['span', { 'data-emoji': node.attrs.name }, emoji] }, // define a Markdown tokenizer to recognize :name: shortcodes markdownTokenizer: { name: 'emoji', level: 'inline', // Fast hint for the lexer start: (src) => src.indexOf(':'), tokenize: (src, tokens, lexer) => { // Match :name: where name can include letters, numbers, underscores, plus signs const match = /^:([a-z0-9_+]+):/i.exec(src) if (!match) return undefined return { type: 'emoji', raw: match[0], // full match like ":smile:" emojiName: match[1], // captured name like "smile" } }, }, })Implementation notes:
startis an optimization used by the lexer to quickly find candidate positions.- The tokenizer returns a token object with
type,raw, andemojiNamefields that the parser will consume. - Keep the tokenizer inline-level (
level: 'inline') so it integrates with inline parsing.
Step 3: Add the parser
The parseMarkdown function converts the tokenizer token into a Tiptap node. For an atomic inline node, it should return a node object with the type and attrs. Here's the full extension now including tokenizer + parse.
import { Node } from '@tiptap/core' const emojiMap = { smile: '😊', heart: '❤️', thumbsup: '👍', fire: '🔥', // add more mappings as needed } export const Emoji = Node.create({ name: 'emoji', group: 'inline', inline: true, atom: true, addAttributes() { return { name: { default: 'smile' }, } }, parseHTML() { return [{ tag: 'span[data-emoji]' }] }, renderHTML({ node }) { const emoji = emojiMap[node.attrs.name] || node.attrs.name || 'smile' return ['span', { 'data-emoji': node.attrs.name }, emoji] }, markdownTokenizer: { name: 'emoji', level: 'inline', // Fast hint for the lexer start: (src) => src.indexOf(':'), tokenize: (src, tokens, lexer) => { // Match :name: where name can include letters, numbers, underscores, plus signs const match = /^:([a-z0-9_+]+):/i.exec(src) if (!match) return undefined return { type: 'emoji', raw: match[0], // full match like ":smile:" emojiName: match[1], // captured name like "smile" } }, }, // Parse token into a tiptap node parseMarkdown: (token, helpers) => { return { type: 'emoji', attrs: { name: token.emojiName }, } }, })Notes:
- The
parseMarkdownfunction should return an object wheretypematches the nodename. - Atomic nodes do not supply
content; they are standalone nodes with attributes.
Step 4: Add the renderer
To support serializing the editor state back to Markdown shortcodes, implement the renderMarkdown function. It receives a Tiptap node and should return a Markdown string representing that node. Below is the full extension with tokenizer, parse, and render included.
import { Node } from '@tiptap/core' const emojiMap = { smile: '😊', heart: '❤️', thumbsup: '👍', fire: '🔥', // add more mappings as needed } export const Emoji = Node.create({ name: 'emoji', group: 'inline', inline: true, atom: true, addAttributes() { return { name: { default: 'smile' }, } }, parseHTML() { return [{ tag: 'span[data-emoji]' }] }, renderHTML({ node }) { const emoji = emojiMap[node.attrs.name] || node.attrs.name || 'smile' return ['span', { 'data-emoji': node.attrs.name }, emoji] }, markdownTokenizer: { name: 'emoji', level: 'inline', // Fast hint for the lexer start: (src) => src.indexOf(':'), tokenize: (src, tokens, lexer) => { // Match :name: where name can include letters, numbers, underscores, plus signs const match = /^:([a-z0-9_+]+):/i.exec(src) if (!match) return undefined return { type: 'emoji', raw: match[0], // full match like ":smile:" emojiName: match[1], // captured name like "smile" } }, }, // Parse token into a tiptap node parseMarkdown: (token, helpers) => { return { type: 'emoji', attrs: { name: token.emojiName }, } }, // Render tiptap node back to Markdown renderMarkdown: (node, helpers) => { // Serialize back to :name: shortcode. Use the stored name attribute. return `:${node.attrs?.name || 'unknown'}:` }, })Usage
Set the editor content from Markdown that contains emoji shortcodes. Depending on your Markdown integration, pass contentType: 'markdown' or use the API your setup provides:
editor.commands.setContent('Hello :smile: :heart: :thumbsup:', { contentType: 'markdown' })This will produce inline emoji nodes with corresponding name attributes, and HTML rendering will display the mapped emoji characters (via emojiMap).
Testing and edge cases
- Unknown names: If a shortcode isn't in
emojiMap, the node currently renders the rawnamein the span (you may prefer to show a fallback or remove the node). Validate or normalize names inmarkdownTokenizerorparseMarkdownif needed. - Case sensitivity: The tokenizer uses an
iflag to allow case-insensitive names; ensure youremojiMapkeys match your chosen convention or normalize them with.toLowerCase(). - Inline parsing: Because this is an inline tokenizer, it will be tried within paragraph and inline contexts — ensure it doesn't conflict with other inline tokenizers (like emphasis) by adjusting the regex or using surrounding whitespace checks if necessary.
- Atomic behavior: The node is atomic, so it won't be editable as text. This is appropriate for emoji elements but might need different behavior for editable shortcodes.