Skip to main content

Creating an Extension

An extension keeps a feature's nodes, configuration, dependencies, and behavior together. Adding it to an editor includes everything the feature needs, and the editor cleans up its registrations on disposal. It works with both buildEditorFromExtensions and LexicalExtensionComposer.

This guide builds EmojiExtension: a node transform replaces shortcodes such as :) and :smiley: with a custom EmojiNode that displays an emoji image. The runnable example includes the lookup data and CSS, and loads the displayed images on demand. The code blocks below are read directly from named regions in that example.

Finding emoji shortcodes​

The example's findEmoji.ts uses emoji-datasource-facebook to find the first space-delimited shortcode in a string. Its result has this shape:

examples/vanilla-js-plugin/src/emoji-plugin/findEmoji.ts
export type EmojiMatch = Readonly<{
position: number;
shortcode: string;
unifiedID: string;
}>;

For example, findEmoji('Hello :)') returns the match position, :), and a hexadecimal Unicode code point ID used to find the corresponding image.

Creating a custom node​

An emoji is text with a custom appearance, so extend TextNode. Use ElementNode for nodes with children, or DecoratorNode for arbitrary embedded UI. See Nodes for those alternatives.

Declare the __unifiedID property in a $config JSON schema. Lexical then supplies cloning and JSON serialization, including inherited text properties, without handwritten clone, importJSON, or exportJSON methods. Keep the node, its factory, and the extension together in EmojiExtension.ts:

examples/vanilla-js-plugin/src/emoji-plugin/EmojiExtension.ts
import {
defineImportRule,
DOMImportExtension,
domOverride,
DOMRenderExtension,
sel,
} from '@lexical/html';
import {version as emojiVersion} from 'emoji-datasource-facebook/package.json';
import {
$create,
$isTextNode,
configExtension,
defineExtension,
isHTMLElement,
nodeSchema,
stringValue,
TextNode,
withField,
} from 'lexical';

import findEmoji from './findEmoji';

const emojiNodeSchema = nodeSchema<EmojiNode>()({
unifiedID: withField(stringValue(), {field: '__unifiedID'}),
});

export class EmojiNode extends TextNode {
__unifiedID: string = '';

$config() {
return this.config('emoji', {
extends: TextNode,
json: emojiNodeSchema,
});
}

getUnifiedID(): string {
return this.getLatest().__unifiedID;
}

setUnifiedID(unifiedID: string): this {
const self = this.getWritable();
self.__unifiedID = unifiedID.toLowerCase();
return self;
}
}

export function $createEmojiNode(unifiedID: string): EmojiNode {
const text = String.fromCodePoint(
...unifiedID.split('-').map(value => parseInt(value, 16)),
);
return $create(EmojiNode)
.setTextContent(text)
.setMode('token')
.setUnifiedID(unifiedID);
}

The inherited TextNode constructor accepts no arguments, which allows $create and the generated deserializer to construct the node. The factory sets its text, its ID, and token mode: an emoji is deleted as a unit, and typing beside it creates regular text.

withField maps the top-level JSON property unifiedID to __unifiedID on the node, preserving the example's serialized format. stringValue() validates imported values and defaults missing or invalid values to an empty string. The schema also carries the property across clones. Getters use getLatest() and setters use getWritable() to respect Lexical's immutable editor states.

The node inherits TextNode's DOM creation, updates, and HTML export. The extension below adds the image through a $decorateDOM override in DOMRenderExtension, which only runs for the live editor. Its separate $exportDOM override adds a stable data-emoji-id attribute for HTML import. Copying or exporting HTML preserves the Unicode text and formatting without loading images or including the live editor's classes and loading attributes.

The native Unicode text stays visible until the image loads successfully. An unknown ID or a failed request therefore keeps the native emoji visible. The URL check prevents a late response for an old ID from replacing the current image.

Only the loaded class hides the Unicode text; it remains in the document for selection, copying, and serialization:

examples/vanilla-js-plugin/src/styles.css
.emoji-node {
caret-color: #050505;
}

.emoji-node-loaded {
color: transparent;
background-size: 1em 1em;
display: inline-block;
vertical-align: top;
width: 1em;
height: 1em;
}

The example requests only the images it displays instead of bundling the entire PNG collection. It reads the CDN version from the installed emoji package's package.json, keeping images and lookup data on the same version. To host images yourself, change BASE_EMOJI_URI in EmojiExtension.ts to your own URL.

Creating a node transform​

A node transform runs before DOM reconciliation, within the update that changed a node. Use it to replace shortcodes without starting a second update from an update listener.

The transform below:

  1. Skips custom text nodes, token nodes, and inline code.
  2. Finds a shortcode and splits it out of the surrounding text.
  3. Replaces that part with an EmojiNode, preserving text formatting and styles.

The remaining text is dirty after splitting, so Lexical runs transforms on it again to find additional shortcodes. The isSimpleText() guard excludes EmojiNode, so replacements do not transform themselves in a loop.

The next fragment of EmojiExtension.ts defines the transform and image helper using the node and imports above:

examples/vanilla-js-plugin/src/emoji-plugin/EmojiExtension.ts
const BASE_EMOJI_URI = `https://cdn.jsdelivr.net/npm/emoji-datasource-facebook@${emojiVersion}/img/facebook/64`;

function applyEmojiImage(dom: HTMLElement, unifiedID: string): void {
const url = `${BASE_EMOJI_URI}/${encodeURIComponent(unifiedID.toLowerCase())}.png`;
const backgroundImage = `url('${url}')`;
if (dom.dataset.emojiUrl === url) {
// TextNode may have updated inline styles. Restore the loaded background
// without clearing it or starting another preload for the same image.
if (dom.classList.contains('emoji-node-loaded')) {
dom.style.backgroundImage = backgroundImage;
}
return;
}
// Keep the native emoji visible while loading, including for missing images.
dom.classList.remove('emoji-node-loaded');
dom.style.backgroundImage = '';
dom.dataset.emojiUrl = url;
const image = dom.ownerDocument.createElement('img');
image.onload = () => {
// A different ID may have been assigned while this image was loading.
if (dom.dataset.emojiUrl === url) {
dom.style.backgroundImage = backgroundImage;
dom.classList.add('emoji-node-loaded');
}
};
image.src = url;
}

function $textNodeTransform(node: TextNode): void {
if (!node.isSimpleText() || node.hasFormat('code')) {
return;
}

const text = node.getTextContent();

// Find only 1st occurrence as transform will be re-run anyway for the rest
// because newly inserted nodes are considered to be dirty
const emojiMatch = findEmoji(text);
if (emojiMatch === null) {
return;
}

let targetNode;
if (emojiMatch.position === 0) {
// First text chunk within string, splitting into 2 parts
[targetNode] = node.splitText(
emojiMatch.position + emojiMatch.shortcode.length,
);
} else {
// In the middle of a string
[, targetNode] = node.splitText(
emojiMatch.position,
emojiMatch.position + emojiMatch.shortcode.length,
);
}

const emojiNode = $createEmojiNode(emojiMatch.unifiedID)
.setFormat(targetNode.getFormat())
.setStyle(targetNode.getStyle());
targetNode.replace(emojiNode);
}

The decorator remembers the URL on the DOM element. When an update changes inline styles, it restores the loaded background without clearing the image or starting another preload. Changing the emoji ID starts a new load and shows the native text until it succeeds.

Preserving emojis in HTML​

Unicode text alone does not identify an EmojiNode on import. The extension's $exportDOM override calls $next() to keep the usual text formatting, then adds data-emoji-id to the exported element. The same output is used for HTML export and copying to the clipboard.

EmojiImportRule matches that attribute and delegates to $next() first, so the normal rules import text and nested formatting. It restores an EmojiNode only when the result is a single text node whose Unicode code points agree with the attribute. Comparing against the actual text avoids parsing untrusted code point values. Invalid or mismatched attributes fall back to the ordinary imported content. The new node retains the imported formatting and styles.

Register the import rule and both DOM hooks in the same extension:

examples/vanilla-js-plugin/src/emoji-plugin/EmojiExtension.ts
const EmojiImportRule = defineImportRule({
$import(_context, element, $next) {
// Preserve the usual text import behavior, including nested formatting.
const nodes = $next();
const node = nodes[0];
if (nodes.length === 1 && $isTextNode(node)) {
const text = node.getTextContent();
const unifiedID = Array.from(text, char =>
char.codePointAt(0)!.toString(16).padStart(4, '0'),
).join('-');
const attribute = element.getAttribute('data-emoji-id');
// Only restore the emoji when the attribute agrees with the actual text.
// Invalid or mismatched attributes leave the imported content unchanged.
if (attribute !== null && unifiedID === attribute.toLowerCase()) {
return [
$create(EmojiNode)
.setUnifiedID(unifiedID)
.setTextContent(text)
.setMode('token')
.setFormat(node.getFormat())
.setStyle(node.getStyle()),
];
}
}
return nodes;
},
match: sel.any().attr('data-emoji-id', true),
name: '@lexical/examples/emoji',
});

export const EmojiExtension = defineExtension({
dependencies: [
configExtension(DOMImportExtension, {rules: [EmojiImportRule]}),
configExtension(DOMRenderExtension, {
overrides: [
domOverride([EmojiNode], {
$decorateDOM(node, _prevNode, dom) {
dom.classList.add('emoji-node');
applyEmojiImage(dom, node.getUnifiedID());
},
$exportDOM(node, $next) {
const output = $next();
if (isHTMLElement(output.element)) {
output.element.setAttribute('data-emoji-id', node.getUnifiedID());
}
return output;
},
}),
],
}),
],
name: '@lexical/examples/Emoji',
nodes: () => [EmojiNode],
register(editor) {
return editor.registerNodeTransform(TextNode, $textNodeTransform);
},
});

nodes registers the custom node before the editor is initialized. register installs the transform and returns its cleanup function. Consumers only add EmojiExtension; they do not separately register EmojiNode or call a bootstrap function.

Putting it all together​

Add the feature to a root extension:

examples/vanilla-js-plugin/src/AppExtension.ts
import {ClipboardDOMImportExtension} from '@lexical/clipboard';
import {HistoryExtension} from '@lexical/history';
import {
$generateHtmlFromNodes,
$generateNodesFromDOMViaExtension,
} from '@lexical/html';
import {RichTextExtension} from '@lexical/rich-text';
import {
$getRoot,
configExtension,
defineExtension,
mergeRegister,
registerEventListener,
} from 'lexical';

import {EmojiExtension} from './emoji-plugin/EmojiExtension';
import $prepopulatedRichText from './prepopulatedRichText';

export const AppExtension = defineExtension({
$initialEditorState: $prepopulatedRichText,
dependencies: [
RichTextExtension,
ClipboardDOMImportExtension,
configExtension(HistoryExtension, {delay: 300}),
EmojiExtension,
],
name: '@lexical/examples/vanilla-js-plugin',
namespace: 'Vanilla JS Emoji Demo',
register(editor) {
const stateRef =
document.querySelector<HTMLTextAreaElement>('#lexical-state')!;
const html = document.querySelector<HTMLTextAreaElement>('#html')!;
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
stateRef.value = JSON.stringify(editorState.toJSON(true), null, 2);
}),
registerEventListener(
document.getElementById('export-html')!,
'click',
() => {
html.value = editor.read('latest', () =>
$generateHtmlFromNodes(editor),
);
},
),
registerEventListener(
document.getElementById('import-html')!,
'click',
() => {
const dom = new DOMParser().parseFromString(html.value, 'text/html');
editor.update(() => {
const nodes = $generateNodesFromDOMViaExtension(dom);
$getRoot()
.clear()
.append(...nodes);
});
},
),
);
},
});

Then build and attach the editor:

examples/vanilla-js-plugin/src/main.ts
import './styles.css';

import {buildEditorFromExtensions, HMRExtension} from '@lexical/extension';
import {configExtension} from 'lexical';

import {AppExtension} from './AppExtension';

const editor = buildEditorFromExtensions(
AppExtension,
configExtension(HMRExtension, {hot: import.meta.hot ?? null}),
);
editor.setRootElement(document.getElementById('lexical-editor'));

// Accept Vite updates; HMRExtension preserves editor state.
// In an application, also call dispose() when removing the editor permanently.
if (import.meta.hot) {
import.meta.hot.accept();
import.meta.hot.dispose(() => editor.dispose());
}

Use the editable element from Quick Start. In React, pass the same root extension to LexicalExtensionComposer instead of building and attaching the editor yourself. No React-specific emoji plugin is needed.

In the example below, type a shortcode, then use Export HTML and Import HTML to try the round trip. The markup contains the Unicode text and data-emoji-id; Editor state JSON confirms that import restores the emoji type, unifiedID, and token mode. HTML pasted from another application goes through the same import rule via ClipboardDOMImportExtension.

To add data to an existing node without defining a subclass, continue with Adding Data to Nodes.

Publishing your extension​

If the extension ships as its own npm package, declare lexical and any @lexical/* packages you import as peerDependencies, plus devDependencies for your build and tests. Applications must resolve one copy of each Lexical package.

For configurable features, outputs, and dependency configuration, continue with Defining Extensions.