Equation adds block and inline void nodes for LaTeX expressions. Both nodes store source in texExpression and render through KaTeX. This page covers kit setup, block versus inline ownership, insertion, input rules, Markdown serialization, and registry UI behavior.
Features
- Block
equationelement. - Inline void
inline_equationelement. - Bound
editor.tf.insert.equationandeditor.tf.insert.inlineEquationtransforms. - Direct
insertEquationandinsertInlineEquationhelpers. - KaTeX rendering in editable and static UI.
$...$inline input rule and$$block input rule.- Markdown round-trip for inline math and block math.
Fast Path
Add The Kit
MathKit installs both equation plugins, their registry components, and the default math input rules.
'use client';
import { MathRules } from '@platejs/math';
import { EquationPlugin, InlineEquationPlugin } from '@platejs/math/react';
import {
EquationElement,
InlineEquationElement,
} from '@/components/ui/equation-node';
export const MathKit = [
InlineEquationPlugin.configure({
inputRules: [MathRules.markdown({ variant: '$' })],
node: {
component: InlineEquationElement,
},
}),
EquationPlugin.configure({
inputRules: [MathRules.markdown({ on: 'break', variant: '$$' })],
node: {
component: EquationElement,
},
}),
];'use client';
import { MathRules } from '@platejs/math';
import { EquationPlugin, InlineEquationPlugin } from '@platejs/math/react';
import {
EquationElement,
InlineEquationElement,
} from '@/components/ui/equation-node';
export const MathKit = [
InlineEquationPlugin.configure({
inputRules: [MathRules.markdown({ variant: '$' })],
node: {
component: InlineEquationElement,
},
}),
EquationPlugin.configure({
inputRules: [MathRules.markdown({ on: 'break', variant: '$$' })],
node: {
component: EquationElement,
},
}),
];import { createPlateEditor } from 'platejs/react';
import { MathKit } from '@/components/editor/plugins/math-kit';
export const editor = createPlateEditor({
plugins: MathKit,
});import { createPlateEditor } from 'platejs/react';
import { MathKit } from '@/components/editor/plugins/math-kit';
export const editor = createPlateEditor({
plugins: MathKit,
});Render The Nodes
equation-node owns the block equation, inline equation, editable popover, textarea input, KaTeX render target, static elements, and DOCX fallback elements.
'use client';
import * as React from 'react';
import TextareaAutosize, {
type TextareaAutosizeProps,
} from 'react-textarea-autosize';
import type { TEquationElement } from 'platejs';
import type { PlateElementProps } from 'platejs/react';
import { useEquationElement, useEquationInput } from '@platejs/math/react';
import { BlockSelectionPlugin } from '@platejs/selection/react';
import { CornerDownLeftIcon, RadicalIcon } from 'lucide-react';
import {
createPrimitiveComponent,
PlateElement,
useEditorRef,
useEditorSelector,
useElement,
useReadOnly,
useSelected,
} from 'platejs/react';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { inlineSuggestionVariants } from '@/lib/suggestion';
export function EquationElement(props: PlateElementProps<TEquationElement>) {
const selected = useSelected();
const [open, setOpen] = React.useState(selected);
const katexRef = React.useRef<HTMLDivElement | null>(null);
const lineBreakBadge = (
props as PlateElementProps<TEquationElement> & {
lineBreakBadge?: React.ReactNode;
}
).lineBreakBadge;
useEquationElement({
element: props.element,
katexRef,
options: {
displayMode: true,
errorColor: '#cc0000',
fleqn: false,
leqno: false,
macros: { '\\f': '#1f(#2)' },
output: 'htmlAndMathml',
strict: 'warn',
throwOnError: false,
trust: false,
},
});
return (
<PlateElement className="my-1" {...props}>
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger asChild>
<div
className={cn(
'group flex cursor-pointer select-none items-center justify-center rounded-sm hover:bg-primary/10 data-[selected=true]:bg-primary/10',
props.element.texExpression.length === 0
? 'bg-muted p-3 pr-9'
: 'px-2 py-1'
)}
data-selected={selected}
contentEditable={false}
role="button"
>
{props.element.texExpression.length > 0 ? (
<span ref={katexRef} />
) : (
<div className="flex h-7 w-full items-center gap-2 whitespace-nowrap text-muted-foreground text-sm">
<RadicalIcon className="size-6 text-muted-foreground/80" />
<div>Add a Tex equation</div>
</div>
)}
{lineBreakBadge}
</div>
</PopoverTrigger>
<EquationPopoverContent
open={open}
placeholder={
'f(x) = \\begin{cases}\n x^2, &\\quad x > 0 \\\\\n 0, &\\quad x = 0 \\\\\n -x^2, &\\quad x < 0\n\\end{cases}'
}
isInline={false}
setOpen={setOpen}
/>
</Popover>
{props.children}
</PlateElement>
);
}
export function InlineEquationElement(
props: PlateElementProps<TEquationElement>
) {
const { element } = props;
const katexRef = React.useRef<HTMLDivElement | null>(null);
const selected = useSelected();
const isCollapsed = useEditorSelector(
(editor) => editor.api.isCollapsed(),
[]
);
const [open, setOpen] = React.useState(selected && isCollapsed);
React.useEffect(() => {
if (selected && isCollapsed) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Open the inline equation popover when editor selection enters it.
setOpen(true);
}
}, [selected, isCollapsed]);
useEquationElement({
element,
katexRef,
options: {
displayMode: true,
errorColor: '#cc0000',
fleqn: false,
leqno: false,
macros: { '\\f': '#1f(#2)' },
output: 'htmlAndMathml',
strict: 'warn',
throwOnError: false,
trust: false,
},
});
return (
<PlateElement
{...props}
className={cn(
'mx-1 inline-block select-none rounded-sm [&_.katex-display]:my-0!'
)}
>
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger asChild>
<div
className={cn(
'after:-top-0.5 after:-left-1 after:absolute after:inset-0 after:z-1 after:h-[calc(100%)+4px] after:w-[calc(100%+8px)] after:rounded-sm after:content-[""]',
'h-6',
inlineSuggestionVariants(),
((element.texExpression.length > 0 && open) || selected) &&
'after:bg-brand/15',
element.texExpression.length === 0 &&
'text-muted-foreground after:bg-neutral-500/10'
)}
contentEditable={false}
>
<span
ref={katexRef}
className={cn(
element.texExpression.length === 0 && 'hidden',
'font-mono leading-none'
)}
/>
{element.texExpression.length === 0 && (
<span>
<RadicalIcon className="mr-1 inline-block h-[19px] w-4 py-[1.5px] align-text-bottom" />
New equation
</span>
)}
</div>
</PopoverTrigger>
<EquationPopoverContent
className="my-auto"
open={open}
placeholder="E = mc^2"
setOpen={setOpen}
isInline
/>
</Popover>
{props.children}
</PlateElement>
);
}
const EquationInput = createPrimitiveComponent(TextareaAutosize)({
propsHook: useEquationInput,
});
const EquationPopoverContent = ({
className,
isInline,
open,
setOpen,
...props
}: {
isInline: boolean;
open: boolean;
setOpen: (open: boolean) => void;
} & TextareaAutosizeProps) => {
const editor = useEditorRef();
const readOnly = useReadOnly();
const element = useElement<TEquationElement>();
React.useEffect(() => {
if (isInline && open) {
setOpen(true);
}
}, [isInline, open, setOpen]);
if (readOnly) return null;
const onClose = () => {
setOpen(false);
if (isInline) {
editor.tf.select(element, { focus: true, next: true });
} else {
editor
.getApi(BlockSelectionPlugin)
.blockSelection.set(element.id as string);
}
};
return (
<PopoverContent
className="flex gap-2"
onEscapeKeyDown={(e) => {
e.preventDefault();
}}
contentEditable={false}
>
<EquationInput
className={cn('max-h-[50vh] grow resize-none p-2 text-sm', className)}
state={{ isInline, open, onClose }}
autoFocus
{...props}
/>
<Button variant="secondary" className="px-3" onClick={onClose}>
Done <CornerDownLeftIcon className="size-3.5" />
</Button>
</PopoverContent>
);
};'use client';
import * as React from 'react';
import TextareaAutosize, {
type TextareaAutosizeProps,
} from 'react-textarea-autosize';
import type { TEquationElement } from 'platejs';
import type { PlateElementProps } from 'platejs/react';
import { useEquationElement, useEquationInput } from '@platejs/math/react';
import { BlockSelectionPlugin } from '@platejs/selection/react';
import { CornerDownLeftIcon, RadicalIcon } from 'lucide-react';
import {
createPrimitiveComponent,
PlateElement,
useEditorRef,
useEditorSelector,
useElement,
useReadOnly,
useSelected,
} from 'platejs/react';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { inlineSuggestionVariants } from '@/lib/suggestion';
export function EquationElement(props: PlateElementProps<TEquationElement>) {
const selected = useSelected();
const [open, setOpen] = React.useState(selected);
const katexRef = React.useRef<HTMLDivElement | null>(null);
const lineBreakBadge = (
props as PlateElementProps<TEquationElement> & {
lineBreakBadge?: React.ReactNode;
}
).lineBreakBadge;
useEquationElement({
element: props.element,
katexRef,
options: {
displayMode: true,
errorColor: '#cc0000',
fleqn: false,
leqno: false,
macros: { '\\f': '#1f(#2)' },
output: 'htmlAndMathml',
strict: 'warn',
throwOnError: false,
trust: false,
},
});
return (
<PlateElement className="my-1" {...props}>
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger asChild>
<div
className={cn(
'group flex cursor-pointer select-none items-center justify-center rounded-sm hover:bg-primary/10 data-[selected=true]:bg-primary/10',
props.element.texExpression.length === 0
? 'bg-muted p-3 pr-9'
: 'px-2 py-1'
)}
data-selected={selected}
contentEditable={false}
role="button"
>
{props.element.texExpression.length > 0 ? (
<span ref={katexRef} />
) : (
<div className="flex h-7 w-full items-center gap-2 whitespace-nowrap text-muted-foreground text-sm">
<RadicalIcon className="size-6 text-muted-foreground/80" />
<div>Add a Tex equation</div>
</div>
)}
{lineBreakBadge}
</div>
</PopoverTrigger>
<EquationPopoverContent
open={open}
placeholder={
'f(x) = \\begin{cases}\n x^2, &\\quad x > 0 \\\\\n 0, &\\quad x = 0 \\\\\n -x^2, &\\quad x < 0\n\\end{cases}'
}
isInline={false}
setOpen={setOpen}
/>
</Popover>
{props.children}
</PlateElement>
);
}
export function InlineEquationElement(
props: PlateElementProps<TEquationElement>
) {
const { element } = props;
const katexRef = React.useRef<HTMLDivElement | null>(null);
const selected = useSelected();
const isCollapsed = useEditorSelector(
(editor) => editor.api.isCollapsed(),
[]
);
const [open, setOpen] = React.useState(selected && isCollapsed);
React.useEffect(() => {
if (selected && isCollapsed) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Open the inline equation popover when editor selection enters it.
setOpen(true);
}
}, [selected, isCollapsed]);
useEquationElement({
element,
katexRef,
options: {
displayMode: true,
errorColor: '#cc0000',
fleqn: false,
leqno: false,
macros: { '\\f': '#1f(#2)' },
output: 'htmlAndMathml',
strict: 'warn',
throwOnError: false,
trust: false,
},
});
return (
<PlateElement
{...props}
className={cn(
'mx-1 inline-block select-none rounded-sm [&_.katex-display]:my-0!'
)}
>
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger asChild>
<div
className={cn(
'after:-top-0.5 after:-left-1 after:absolute after:inset-0 after:z-1 after:h-[calc(100%)+4px] after:w-[calc(100%+8px)] after:rounded-sm after:content-[""]',
'h-6',
inlineSuggestionVariants(),
((element.texExpression.length > 0 && open) || selected) &&
'after:bg-brand/15',
element.texExpression.length === 0 &&
'text-muted-foreground after:bg-neutral-500/10'
)}
contentEditable={false}
>
<span
ref={katexRef}
className={cn(
element.texExpression.length === 0 && 'hidden',
'font-mono leading-none'
)}
/>
{element.texExpression.length === 0 && (
<span>
<RadicalIcon className="mr-1 inline-block h-[19px] w-4 py-[1.5px] align-text-bottom" />
New equation
</span>
)}
</div>
</PopoverTrigger>
<EquationPopoverContent
className="my-auto"
open={open}
placeholder="E = mc^2"
setOpen={setOpen}
isInline
/>
</Popover>
{props.children}
</PlateElement>
);
}
const EquationInput = createPrimitiveComponent(TextareaAutosize)({
propsHook: useEquationInput,
});
const EquationPopoverContent = ({
className,
isInline,
open,
setOpen,
...props
}: {
isInline: boolean;
open: boolean;
setOpen: (open: boolean) => void;
} & TextareaAutosizeProps) => {
const editor = useEditorRef();
const readOnly = useReadOnly();
const element = useElement<TEquationElement>();
React.useEffect(() => {
if (isInline && open) {
setOpen(true);
}
}, [isInline, open, setOpen]);
if (readOnly) return null;
const onClose = () => {
setOpen(false);
if (isInline) {
editor.tf.select(element, { focus: true, next: true });
} else {
editor
.getApi(BlockSelectionPlugin)
.blockSelection.set(element.id as string);
}
};
return (
<PopoverContent
className="flex gap-2"
onEscapeKeyDown={(e) => {
e.preventDefault();
}}
contentEditable={false}
>
<EquationInput
className={cn('max-h-[50vh] grow resize-none p-2 text-sm', className)}
state={{ isInline, open, onClose }}
autoFocus
{...props}
/>
<Button variant="secondary" className="px-3" onClick={onClose}>
Done <CornerDownLeftIcon className="size-3.5" />
</Button>
</PopoverContent>
);
};Add The Toolbar Button
equation-toolbar-button inserts an inline equation with insertInlineEquation(editor).
'use client';
import * as React from 'react';
import { insertInlineEquation } from '@platejs/math';
import { RadicalIcon } from 'lucide-react';
import { useEditorRef } from 'platejs/react';
import { ToolbarButton } from './toolbar';
export function InlineEquationToolbarButton(
props: React.ComponentProps<typeof ToolbarButton>
) {
const editor = useEditorRef();
return (
<ToolbarButton
{...props}
onClick={() => {
insertInlineEquation(editor);
}}
tooltip="Mark as equation"
>
<RadicalIcon />
</ToolbarButton>
);
}'use client';
import * as React from 'react';
import { insertInlineEquation } from '@platejs/math';
import { RadicalIcon } from 'lucide-react';
import { useEditorRef } from 'platejs/react';
import { ToolbarButton } from './toolbar';
export function InlineEquationToolbarButton(
props: React.ComponentProps<typeof ToolbarButton>
) {
const editor = useEditorRef();
return (
<ToolbarButton
{...props}
onClick={() => {
insertInlineEquation(editor);
}}
tooltip="Mark as equation"
>
<RadicalIcon />
</ToolbarButton>
);
}Ownership
| Layer | Owner | What It Does |
|---|---|---|
@platejs/math | Package | Exports base plugins, transforms, MathRules, and getEquationHtml. |
@platejs/math/react | Package | Exports React plugins plus useEquationElement and useEquationInput. |
math-kit | Registry | Adds block and inline React plugins with input rules and interactive UI components. |
math-base-kit | Registry | Adds static equation components for read-only rendering. |
equation-node | Registry UI | Renders editable, static, and DOCX equation elements. |
equation-toolbar-button | Registry UI | Inserts inline equations from the toolbar. |
@platejs/markdown | Package | Serializes and deserializes math and inlineMath nodes when remark-math is configured. |
Block and inline equations share the TEquationElement shape, but they are different node types with different plugin keys.
Manual Setup
Install Package
pnpm add @platejs/mathpnpm add @platejs/mathAdd Plugins
Use both React plugins when your editor supports block and inline equations.
import { MathRules } from '@platejs/math';
import {
EquationPlugin,
InlineEquationPlugin,
} from '@platejs/math/react';
import { createPlateEditor } from 'platejs/react';
import {
EquationElement,
InlineEquationElement,
} from '@/components/ui/equation-node';
export const editor = createPlateEditor({
plugins: [
InlineEquationPlugin.configure({
inputRules: [MathRules.markdown({ variant: '$' })],
node: { component: InlineEquationElement },
}),
EquationPlugin.configure({
inputRules: [MathRules.markdown({ on: 'break', variant: '$$' })],
node: { component: EquationElement },
}),
],
});import { MathRules } from '@platejs/math';
import {
EquationPlugin,
InlineEquationPlugin,
} from '@platejs/math/react';
import { createPlateEditor } from 'platejs/react';
import {
EquationElement,
InlineEquationElement,
} from '@/components/ui/equation-node';
export const editor = createPlateEditor({
plugins: [
InlineEquationPlugin.configure({
inputRules: [MathRules.markdown({ variant: '$' })],
node: { component: InlineEquationElement },
}),
EquationPlugin.configure({
inputRules: [MathRules.markdown({ on: 'break', variant: '$$' })],
node: { component: EquationElement },
}),
],
});Add Static Rendering
Use the base kit when rendering read-only output with platejs/static.
import { BaseEquationPlugin, BaseInlineEquationPlugin } from '@platejs/math';
import {
EquationElementStatic,
InlineEquationElementStatic,
} from '@/components/ui/equation-node-static';
export const BaseMathKit = [
BaseInlineEquationPlugin.withComponent(InlineEquationElementStatic),
BaseEquationPlugin.withComponent(EquationElementStatic),
];import { BaseEquationPlugin, BaseInlineEquationPlugin } from '@platejs/math';
import {
EquationElementStatic,
InlineEquationElementStatic,
} from '@/components/ui/equation-node-static';
export const BaseMathKit = [
BaseInlineEquationPlugin.withComponent(InlineEquationElementStatic),
BaseEquationPlugin.withComponent(EquationElementStatic),
];Insert Equations
Use plugin-bound transforms when you already have a configured editor.
editor.tf.insert.equation({ select: true });
editor.tf.insert.inlineEquation('E = mc^2', { select: true });editor.tf.insert.equation({ select: true });
editor.tf.insert.inlineEquation('E = mc^2', { select: true });Use package helpers directly from toolbar, slash-command, or app-local action code.
import { insertEquation, insertInlineEquation } from '@platejs/math';
insertEquation(editor, { select: true });
insertInlineEquation(editor, 'E = mc^2', { select: true });import { insertEquation, insertInlineEquation } from '@platejs/math';
insertEquation(editor, { select: true });
insertInlineEquation(editor, 'E = mc^2', { select: true });Value Shape
Both equation nodes store source in texExpression. The child text is only the Slate-required child for a void element.
const value = [
{
children: [
{ text: 'Mass-energy equivalence: ' },
{
children: [{ text: '' }],
texExpression: 'E = mc^2',
type: 'inline_equation',
},
{ text: '.' },
],
type: 'p',
},
{
children: [{ text: '' }],
texExpression: '\\\\int_{a}^{b} f(x) \\\\, dx = F(b) - F(a)',
type: 'equation',
},
];const value = [
{
children: [
{ text: 'Mass-energy equivalence: ' },
{
children: [{ text: '' }],
texExpression: 'E = mc^2',
type: 'inline_equation',
},
{ text: '.' },
],
type: 'p',
},
{
children: [{ text: '' }],
texExpression: '\\\\int_{a}^{b} f(x) \\\\, dx = F(b) - F(a)',
type: 'equation',
},
];| Node | Type | Behavior |
|---|---|---|
BaseEquationPlugin | equation | Block void equation. |
BaseInlineEquationPlugin | inline_equation | Inline void equation. |
TEquationElement.texExpression | string | LaTeX source rendered by KaTeX. |
Input Rules
MathRules.markdown creates editor input rules. It is separate from Markdown serialization.
| Rule | Trigger | Behavior |
|---|---|---|
MathRules.markdown({ variant: '$' }) | $...$ | Deletes the delimited text and inserts an inline equation with the matched expression. |
MathRules.markdown({ on: 'break', variant: '$$' }) | $$ then line break | Replaces the paragraph fence with a block equation. |
MathRules.markdown({ on: 'match', variant: '$$' }) | $$...$$ match | Creates a block equation on match. |
Math input rules are disabled inside code blocks, block equations, and inline equations.
Rendering
The registry components render KaTeX and keep source editing in a popover.
| Surface | Behavior |
|---|---|
| Editable block equation | useEquationElement calls katex.render with display-mode options. |
| Editable inline equation | Opens a popover when the inline void node is selected and the selection is collapsed. |
| Popover input | useEquationInput writes texExpression as the textarea changes. |
Enter | Submits and closes the input. |
Escape | Dismisses; inline equations restore the initial expression. |
| Inline left/right edge arrows | Move selection out of the inline equation. |
| Static rendering | getEquationHtml calls katex.renderToString. |
KaTeX is configured with throwOnError: false, strict: 'warn', and trust: false in the registry UI.
Markdown
Markdown math support comes from @platejs/markdown plus remark-math, as configured by the registry MarkdownKit.
Inline $x+1$ mathInline $x+1$ math$$
x+1
$$$$
x+1
$$Inline math deserializes to inline_equation. Block math deserializes to equation. Serialization writes the same Markdown math shapes from texExpression.
Plate Plus
- Mark text as equation from the toolbar
- Insert equation from slash command
- Beautifully crafted UI
API Reference
| API | Package | Use |
|---|---|---|
BaseEquationPlugin | @platejs/math | Headless block equation plugin. |
BaseInlineEquationPlugin | @platejs/math | Headless inline equation plugin. |
EquationPlugin | @platejs/math/react | React block equation plugin. |
InlineEquationPlugin | @platejs/math/react | React inline equation plugin. |
insertEquation(editor, options) | @platejs/math | Inserts a blank block equation. |
insertInlineEquation(editor, texExpression?, options?) | @platejs/math | Inserts an inline equation. Defaults to the selected string when no expression is passed. |
editor.tf.insert.equation(options) | plugin-bound transform | Bound block equation insert transform. |
editor.tf.insert.inlineEquation(texExpression?, options?) | plugin-bound transform | Bound inline equation insert transform. |
MathRules.markdown(options) | @platejs/math | Creates inline or block math input rules. |
useEquationElement(options) | @platejs/math/react | Renders an equation into a KaTeX DOM target. |
useEquationInput(options) | @platejs/math/react | Wires the equation textarea and keyboard behavior. |
getEquationHtml(options) | @platejs/math | Returns static KaTeX HTML. |
TEquationElement | platejs | Element shape with texExpression. |