Claude Code for Lexical: Meta's Rich Text Editor Framework — Claude Skills 360 Blog
Blog / Frontend / Claude Code for Lexical: Meta's Rich Text Editor Framework
Frontend

Claude Code for Lexical: Meta's Rich Text Editor Framework

Published: April 10, 2027
Read time: 8 min read
By: Claude Skills 360

Lexical is Meta’s extensible JavaScript text editor framework — LexicalComposer wraps the editor with initialConfig. RichTextPlugin with contentEditable renders the editing surface. HistoryPlugin adds undo/redo. OnChangePlugin captures EditorState as JSON for persistence. Custom nodes extend DecoratorNode or ElementNode with static getType(), clone(), importJSON(), and exportJSON(). $getSelection() and $isRangeSelection() read editor state. FORMAT_TEXT_COMMAND dispatches bold/italic/underline. editor.update(() => { $getRoot().clear() }) mutates state in a transaction. AutoLinkPlugin converts URLs to links automatically. MarkdownShortcuts enables **bold** syntax. Lexical’s architecture separates the DOM from state for predictable server-side rendering and collaborative editing. Claude Code generates Lexical editor setups, custom node types, toolbar components, state serialization, and plugin compositions.

CLAUDE.md for Lexical

## Lexical Stack
- Version: lexical >= 0.16, @lexical/react >= 0.16
- Setup: <LexicalComposer initialConfig={config}><RichTextPlugin /><HistoryPlugin /></LexicalComposer>
- Change: <OnChangePlugin onChange={(editorState) => editorState.toJSON()} />
- Commands: editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold") — from LexicalEditor
- Selection: editor.update(() => { const sel = $getSelection(); ... })
- Custom node: class ImageNode extends DecoratorNode — static getType/clone/importJSON/exportJSON
- Serialize: editor.getEditorState().toJSON() / editor.parseEditorState(json)
- Plugins: AutoLinkPlugin, MarkdownShortcutPlugin, CheckListPlugin, ListPlugin

Editor Setup

// components/editor/RichEditor.tsx — full Lexical editor
"use client"
import { useCallback, useRef } from "react"
import { LexicalComposer } from "@lexical/react/LexicalComposer"
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
import { ContentEditable } from "@lexical/react/LexicalContentEditable"
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"
import { ListPlugin } from "@lexical/react/LexicalListPlugin"
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"
import { AutoLinkPlugin } from "@lexical/react/LexicalAutoLinkPlugin"
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"
import { TRANSFORMERS } from "@lexical/markdown"
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
import { ListNode, ListItemNode } from "@lexical/list"
import { LinkNode, AutoLinkNode } from "@lexical/link"
import { CodeNode, CodeHighlightNode } from "@lexical/code"
import type { EditorState, SerializedEditorState } from "lexical"
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
import { EditorToolbar } from "./EditorToolbar"

const URL_REGEX = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/
const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/

const AUTO_LINK_MATCHERS = [
  (text: string) => {
    const match = URL_REGEX.exec(text)
    if (match === null) return null
    return { index: match.index, length: match[0].length, text: match[0], url: match[0] }
  },
  (text: string) => {
    const match = EMAIL_REGEX.exec(text)
    if (match === null) return null
    return { index: match.index, length: match[0].length, text: match[0], url: `mailto:${match[0]}` }
  },
]

interface RichEditorProps {
  initialContent?: SerializedEditorState
  onChange?: (state: SerializedEditorState) => void
  placeholder?: string
  className?: string
}

export function RichEditor({
  initialContent,
  onChange,
  placeholder = "Start writing...",
  className,
}: RichEditorProps) {
  const initialConfig = {
    namespace: "RichEditor",
    theme: editorTheme,
    onError: (error: Error) => console.error("[Lexical]", error),
    nodes: [
      HeadingNode,
      QuoteNode,
      ListNode,
      ListItemNode,
      LinkNode,
      AutoLinkNode,
      CodeNode,
      CodeHighlightNode,
    ],
    // Restore from saved state
    ...(initialContent && { editorState: JSON.stringify(initialContent) }),
  }

  const handleChange = useCallback(
    (editorState: EditorState) => {
      onChange?.(editorState.toJSON())
    },
    [onChange],
  )

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <div className={`lexical-editor ${className ?? ""}`}>
        <EditorToolbar />

        <div className="relative">
          <RichTextPlugin
            contentEditable={
              <ContentEditable
                className="min-h-[200px] outline-none px-4 py-3 text-sm leading-relaxed"
                aria-placeholder={placeholder}
              />
            }
            placeholder={
              <div className="absolute top-3 left-4 text-sm text-muted-foreground pointer-events-none">
                {placeholder}
              </div>
            }
            ErrorBoundary={LexicalErrorBoundary}
          />
        </div>

        <HistoryPlugin />
        <AutoFocusPlugin />
        <ListPlugin />
        <CheckListPlugin />
        <LinkPlugin />
        <AutoLinkPlugin matchers={AUTO_LINK_MATCHERS} />
        <MarkdownShortcutPlugin transformers={TRANSFORMERS} />
        <OnChangePlugin onChange={handleChange} />
      </div>
    </LexicalComposer>
  )
}

// Theme maps node types to CSS classes
const editorTheme = {
  heading: {
    h1: "text-3xl font-bold mb-4",
    h2: "text-2xl font-semibold mb-3",
    h3: "text-xl font-semibold mb-2",
  },
  quote: "border-l-4 border-muted-foreground pl-4 italic text-muted-foreground my-4",
  list: {
    ul: "list-disc ml-6 my-2 space-y-1",
    ol: "list-decimal ml-6 my-2 space-y-1",
    listitem: "my-1",
    listitemChecked: "line-through opacity-50",
    listitemUnchecked: "",
  },
  link: "text-primary underline hover:no-underline cursor-pointer",
  code: "font-mono text-sm bg-muted px-1 py-0.5 rounded",
  codeHighlight: {
    atrule: "text-blue-600",
    attr: "text-purple-600",
    boolean: "text-red-600",
    builtin: "text-cyan-600",
    comment: "text-gray-500 italic",
    function: "text-yellow-600",
    keyword: "text-blue-700",
    string: "text-green-600",
  },
  text: {
    bold: "font-bold",
    italic: "italic",
    underline: "underline",
    strikethrough: "line-through",
    code: "font-mono text-sm bg-muted px-1 py-0.5 rounded",
  },
}

Toolbar Plugin

// components/editor/EditorToolbar.tsx — formatting toolbar
"use client"
import { useCallback, useEffect, useState } from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import {
  FORMAT_TEXT_COMMAND,
  FORMAT_ELEMENT_COMMAND,
  $getSelection,
  $isRangeSelection,
  UNDO_COMMAND,
  REDO_COMMAND,
  TextFormatType,
} from "lexical"
import { $isHeadingNode } from "@lexical/rich-text"
import { $isListNode, ListNode } from "@lexical/list"
import { $getNearestNodeOfType } from "@lexical/utils"
import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from "@lexical/list"

type FormatState = {
  isBold: boolean
  isItalic: boolean
  isUnderline: boolean
  isStrikethrough: boolean
  isCode: boolean
  blockType: string
  canUndo: boolean
  canRedo: boolean
}

export function EditorToolbar() {
  const [editor] = useLexicalComposerContext()
  const [formats, setFormats] = useState<FormatState>({
    isBold: false,
    isItalic: false,
    isUnderline: false,
    isStrikethrough: false,
    isCode: false,
    blockType: "paragraph",
    canUndo: false,
    canRedo: false,
  })

  const updateFormats = useCallback(() => {
    const selection = $getSelection()
    if (!$isRangeSelection(selection)) return

    const anchorNode = selection.anchor.getNode()
    const element = anchorNode.getKey() === "root"
      ? anchorNode
      : anchorNode.getTopLevelElementOrThrow()

    let blockType = "paragraph"
    if ($isHeadingNode(element)) blockType = element.getTag()
    else if ($isListNode(element)) {
      const parentList = $getNearestNodeOfType<ListNode>(anchorNode, ListNode)
      blockType = parentList ? parentList.getListType() : element.getListType()
    }

    setFormats({
      isBold: selection.hasFormat("bold"),
      isItalic: selection.hasFormat("italic"),
      isUnderline: selection.hasFormat("underline"),
      isStrikethrough: selection.hasFormat("strikethrough"),
      isCode: selection.hasFormat("code"),
      blockType,
      canUndo: editor.canUndo(),
      canRedo: editor.canRedo(),
    })
  }, [editor])

  useEffect(() => {
    return editor.registerUpdateListener(({ editorState }) => {
      editorState.read(updateFormats)
    })
  }, [editor, updateFormats])

  const format = (type: TextFormatType) => editor.dispatchCommand(FORMAT_TEXT_COMMAND, type)

  return (
    <div className="flex flex-wrap items-center gap-1 border-b p-2">
      <ToolbarButton
        onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
        disabled={!formats.canUndo}
        title="Undo"
      >↩</ToolbarButton>
      <ToolbarButton
        onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
        disabled={!formats.canRedo}
        title="Redo"
      >↪</ToolbarButton>

      <div className="w-px h-5 bg-border mx-1" />

      <ToolbarButton onClick={() => format("bold")} active={formats.isBold} title="Bold">
        <strong>B</strong>
      </ToolbarButton>
      <ToolbarButton onClick={() => format("italic")} active={formats.isItalic} title="Italic">
        <em>I</em>
      </ToolbarButton>
      <ToolbarButton onClick={() => format("underline")} active={formats.isUnderline} title="Underline">
        <span className="underline">U</span>
      </ToolbarButton>
      <ToolbarButton onClick={() => format("strikethrough")} active={formats.isStrikethrough} title="Strikethrough">
        <span className="line-through">S</span>
      </ToolbarButton>
      <ToolbarButton onClick={() => format("code")} active={formats.isCode} title="Code">
        {"<>"}
      </ToolbarButton>

      <div className="w-px h-5 bg-border mx-1" />

      <ToolbarButton
        onClick={() => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)}
        active={formats.blockType === "bullet"}
        title="Bullet list"
      >• List</ToolbarButton>
      <ToolbarButton
        onClick={() => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)}
        active={formats.blockType === "number"}
        title="Ordered list"
      >1. List</ToolbarButton>
    </div>
  )
}

function ToolbarButton({
  onClick,
  active = false,
  disabled = false,
  title,
  children,
}: {
  onClick: () => void
  active?: boolean
  disabled?: boolean
  title?: string
  children: React.ReactNode
}) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      title={title}
      className={`px-2 py-1 rounded text-sm transition-colors ${
        active ? "bg-primary text-primary-foreground" : "hover:bg-muted"
      } ${disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
    >
      {children}
    </button>
  )
}

State Serialization and Custom Nodes

// lib/lexical-utils.ts — serialize/deserialize and custom ImageNode
import { DecoratorNode, type LexicalNode, type NodeKey, type SerializedLexicalNode, type Spread } from "lexical"

// Custom image node
type SerializedImageNode = Spread<
  { src: string; alt: string; width?: number; height?: number },
  SerializedLexicalNode
>

export class ImageNode extends DecoratorNode<React.JSX.Element> {
  __src: string
  __alt: string
  __width: number | undefined
  __height: number | undefined

  static getType(): string { return "image" }

  static clone(node: ImageNode): ImageNode {
    return new ImageNode(node.__src, node.__alt, node.__width, node.__height, node.__key)
  }

  constructor(src: string, alt: string, width?: number, height?: number, key?: NodeKey) {
    super(key)
    this.__src = src
    this.__alt = alt
    this.__width = width
    this.__height = height
  }

  static importJSON(data: SerializedImageNode): ImageNode {
    return new ImageNode(data.src, data.alt, data.width, data.height)
  }

  exportJSON(): SerializedImageNode {
    return {
      type: "image",
      version: 1,
      src: this.__src,
      alt: this.__alt,
      width: this.__width,
      height: this.__height,
    }
  }

  createDOM(): HTMLElement {
    const span = document.createElement("span")
    return span
  }

  updateDOM(): boolean { return false }

  decorate(): React.JSX.Element {
    return (
      <img
        src={this.__src}
        alt={this.__alt}
        width={this.__width}
        height={this.__height}
        className="max-w-full rounded-lg my-4"
      />
    )
  }
}

For the TipTap alternative when a headless rich text editor with an extensive extension ecosystem, ProseMirror under the hood, and @tiptap/react integration with Slash commands, collaboration, and Notion-style blocks are preferred — TipTap has more pre-built extensions than Lexical’s plugin system, see the TipTap guide. For the Quill alternative when a mature, simpler rich text editor with a stable API, Delta format, and a broad browser support matrix is preferred over the newer Lexical architecture — Quill is more appropriate for traditional CMS integrations without React, see the rich text editor guide. The Claude Skills 360 bundle includes Lexical skill sets covering plugins, custom nodes, and collaboration. Start with the free tier to try editor generation.

Keep Reading

Frontend

Claude Code for Chart.js Advanced: Custom Plugins and Mixed Charts

Advanced Chart.js patterns with Claude Code — chart.register() for tree-shaking, mixed chart types combining bar and line, custom plugin API with beforeDraw and afterDatasetsDraw hooks, ScriptableContext for computed colors, ChartDataLabels plugin for value labels, chartjs-plugin-zoom for pan and zoom, custom gradient fills via ctx.createLinearGradient, ChartJS annotation plugin for threshold lines, streaming data with chartjs-plugin-streaming, and react-chartjs-2 with useRef and chart instance.

6 min read Jun 27, 2027
Frontend

Claude Code for Nivo: Rich SVG and Canvas Charts

Build rich data visualizations with Nivo and Claude Code — ResponsiveLine and ResponsiveBar for adaptive charts, ResponsiveHeatMap for matrix data, ResponsiveTreeMap for hierarchal data, ResponsiveSunburst for nested proportions, ResponsiveChord for relationship diagrams, ResponsiveCalendar for activity heat maps, ResponsiveNetwork for force graphs, NivoTheme for consistent styling, tooltip customization with sliceTooltip, and motion config for spring animations.

6 min read Jun 26, 2027
Frontend

Claude Code for Victory Charts: React Native and Web Charts

Build cross-platform charts with Victory and Claude Code — VictoryChart, VictoryLine, VictoryBar, and VictoryScatter for web and React Native, VictoryPie for donut charts, VictoryArea for stacked areas, VictoryAxis for custom axes, VictoryTooltip and VictoryVoronoiContainer for hover tooltips, VictoryBrushContainer for range selection, VictoryZoomContainer for pan and zoom, VictoryLegend for series labels, custom theme with VictoryTheme, and VictoryStack for grouped bars.

6 min read Jun 25, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free