Equation

PreviousNext

Block and inline LaTeX equation nodes rendered with KaTeX.

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.

Loading…

Features

  • Block equation element.
  • Inline void inline_equation element.
  • Bound editor.tf.insert.equation and editor.tf.insert.inlineEquation transforms.
  • Direct insertEquation and insertInlineEquation helpers.
  • 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

LayerOwnerWhat It Does
@platejs/mathPackageExports base plugins, transforms, MathRules, and getEquationHtml.
@platejs/math/reactPackageExports React plugins plus useEquationElement and useEquationInput.
math-kitRegistryAdds block and inline React plugins with input rules and interactive UI components.
math-base-kitRegistryAdds static equation components for read-only rendering.
equation-nodeRegistry UIRenders editable, static, and DOCX equation elements.
equation-toolbar-buttonRegistry UIInserts inline equations from the toolbar.
@platejs/markdownPackageSerializes 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/math
pnpm add @platejs/math

Add 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',
  },
];
NodeTypeBehavior
BaseEquationPluginequationBlock void equation.
BaseInlineEquationPlugininline_equationInline void equation.
TEquationElement.texExpressionstringLaTeX source rendered by KaTeX.

Input Rules

MathRules.markdown creates editor input rules. It is separate from Markdown serialization.

RuleTriggerBehavior
MathRules.markdown({ variant: '$' })$...$Deletes the delimited text and inserts an inline equation with the matched expression.
MathRules.markdown({ on: 'break', variant: '$$' })$$ then line breakReplaces the paragraph fence with a block equation.
MathRules.markdown({ on: 'match', variant: '$$' })$$...$$ matchCreates 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.

SurfaceBehavior
Editable block equationuseEquationElement calls katex.render with display-mode options.
Editable inline equationOpens a popover when the inline void node is selected and the selection is collapsed.
Popover inputuseEquationInput writes texExpression as the textarea changes.
EnterSubmits and closes the input.
EscapeDismisses; inline equations restore the initial expression.
Inline left/right edge arrowsMove selection out of the inline equation.
Static renderinggetEquationHtml 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$ math
Inline $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

APIPackageUse
BaseEquationPlugin@platejs/mathHeadless block equation plugin.
BaseInlineEquationPlugin@platejs/mathHeadless inline equation plugin.
EquationPlugin@platejs/math/reactReact block equation plugin.
InlineEquationPlugin@platejs/math/reactReact inline equation plugin.
insertEquation(editor, options)@platejs/mathInserts a blank block equation.
insertInlineEquation(editor, texExpression?, options?)@platejs/mathInserts an inline equation. Defaults to the selected string when no expression is passed.
editor.tf.insert.equation(options)plugin-bound transformBound block equation insert transform.
editor.tf.insert.inlineEquation(texExpression?, options?)plugin-bound transformBound inline equation insert transform.
MathRules.markdown(options)@platejs/mathCreates inline or block math input rules.
useEquationElement(options)@platejs/math/reactRenders an equation into a KaTeX DOM target.
useEquationInput(options)@platejs/math/reactWires the equation textarea and keyboard behavior.
getEquationHtml(options)@platejs/mathReturns static KaTeX HTML.
TEquationElementplatejsElement shape with texExpression.