Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Comments
  • Discussion
  • Suggestions
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Details
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Drag & Drop
    • Navigation Feedback
    • Linting
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • DOCX
    • HTML
    • Markdown

Footnote

PreviousNext

Parse and edit GFM footnote references and definitions as dedicated nodes.

MarkdownNavigation Feedback
Loading…
Table of ContentsDetails

On This Page

FeaturesKit usageInstallationAdd kitManual usageInstallationAdd pluginsInsert a footnoteRepair an unresolved referenceNavigate between reference and definitionHandle duplicate definitionsPaste notesRound-trip MarkdownExport and paste HTMLStatic and Word outputCustomize renderingPluginsFootnotePluginFootnoteDefinitionPluginReadseditor.read.footnote.definitioneditor.read.footnote.definitionseditor.read.footnote.definitionTexteditor.read.footnote.referenceseditor.read.footnote.refseditor.read.footnote.nextRefeditor.read.footnote.isResolvededitor.read.footnote.anchoreditor.read.footnote.isDuplicateDefinitionUpdateseditor.update.footnote.inserteditor.update.footnote.createDefinitioneditor.api.footnote.focusDefinitioneditor.api.footnote.focusReferenceeditor.update.footnote.normalizeDuplicateDefinitionRelated docs
Build your editor
Production-ready AI template and reusable components.
Get all-access

Footnote turns GFM footnote markup ([^1] references and [^1]: text definitions) into dedicated Plate nodes you can insert, repair, and jump between. The reference is an inline void <sup>; the definition is a block at the end of the document. Paired with MarkdownPlugin and remark-gfm, references and definitions round-trip as real footnote markdown instead of fallback text.

Features

  • GFM-compatible footnote references and definitions as dedicated Plate nodes.
  • One-transform insertion with automatic numeric ref allocation.
  • Typing [^ opens a reference picker from the default UI kit.
  • Recreate a missing definition from an unresolved reference without duplicating.
  • Keep the first duplicate definition canonical; renumber later duplicates on demand.
  • Navigation helpers that jump between reference and definition with a landed-target flash.
  • HTML export and static pages link each reference to its note, and footnotes copied from GitHub paste as footnotes.
  • Pasted notes land at the end of the document, wherever the caret is.
Report an issue

Kit usage

Installation

Use FootnoteKit for the reference and definition components and the [^ picker. Add MarkdownKit for Markdown parsing and serialization.

'use client';
 
import { PathApi, type Path } from 'platejs';
import {
  FootnotePlugin,
  FootnoteDefinitionPlugin,
} from 'platejs/footnote/react';
import {
  type EditableSiblingProps,
  type Editor,
  type EditorElementProps,
  EditorElement,
  useEditor,
  useEditorFocused,
  useEditorReadOnly,
  useEditorSelector,
  useElementSelected,
  usePath,
} from 'platejs/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
import {
  Command,
  CommandGroup,
  CommandItem,
  CommandList,
} from '@/components/ui/command';
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from '@/components/ui/hover-card';
import { cn } from '@/lib/utils';
import {
  FloatingPopover,
  FloatingPopoverAnchor,
  FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import {
  InlineCombobox,
  InlineComboboxContent,
  InlineComboboxEmpty,
  InlineComboboxGroup,
  InlineComboboxItem,
  useInlineComboboxQuery,
} from '@/components/editor/inline-combobox';
 
const NUMERIC_FOOTNOTE_QUERY = /^\d+$/;
 
const focusFootnoteControl = (editor: Editor, path?: Path) => {
  const key = path && editor.key(path);
 
  if (key) {
    editor.api.dom
      .resolveDOMNode(key)
      ?.querySelector<HTMLButtonElement>('button')
      ?.focus({ preventScroll: true });
  }
};
 
const getFootnotePreviewLabel = (text?: string) => {
  const normalized = text?.replace(/\s+/g, ' ').trim();
 
  if (!normalized) return 'Empty footnote';
 
  return normalized.length > 48
    ? `${normalized.slice(0, 45).trimEnd()}...`
    : normalized;
};
 
const getReferenceContextLabel = (
  editor: Editor,
  path: Path,
  index: number
) => {
  const parentEntry = editor.read.nodes.parent(path);
  const fallback = `Reference ${index + 1}`;
 
  if (!parentEntry) return fallback;
 
  const text = editor.read.text.string(parentEntry[1]);
  const normalized = text.replace(/\s+/g, ' ').trim();
 
  if (!normalized) return fallback;
 
  return normalized.length > 56
    ? `${normalized.slice(0, 53).trimEnd()}...`
    : normalized;
};
 
export function FootnoteReferenceElement(
  props: EditorElementProps<typeof FootnotePlugin>
) {
  const { element } = props;
  const path = usePath();
  const editor = useEditor();
  const readOnly = useEditorReadOnly();
  const {
    api: footnoteNavigation,
    read: footnoteApi,
    update: footnoteUpdate,
  } = editor.plugin(FootnotePlugin);
  const ref = element.ref ?? '';
  const [hoverOpen, setHoverOpen] = React.useState(false);
  const focused = useEditorFocused();
  const fallbackResolved =
    ref && footnoteApi ? footnoteApi.isResolved({ ref }) : false;
  const fallbackPreviewText =
    ref && footnoteApi ? footnoteApi.definitionText({ ref }) : undefined;
  const livePreview = useEditorSelector(() => {
    if (!hoverOpen || !ref) return null;
 
    return {
      isResolved: footnoteApi.isResolved({ ref }),
      previewText: footnoteApi.definitionText({ ref }),
    };
  });
  const isResolved = livePreview?.isResolved ?? fallbackResolved;
  const previewText = livePreview?.previewText ?? fallbackPreviewText;
  const selected = useElementSelected({ mode: 'text' });
  const isSelectionInsideAtom = useEditorSelector((currentEditor) => {
    const selection = currentEditor.read.selection();
 
    if (!path || !selection) return false;
 
    return (
      PathApi.equals(selection.anchor.path, path.concat([0])) &&
      PathApi.equals(selection.focus.path, path.concat([0])) &&
      selection.anchor.offset === selection.focus.offset
    );
  });
 
  return (
    <EditorElement
      {...props}
      as="sup"
      className="group/footnote-ref mx-0.5 align-super"
      attributes={{
        ...props.attributes,
        contentEditable: false,
        draggable: true,
      }}
    >
      {props.children}
      <HoverCard open={hoverOpen} onOpenChange={setHoverOpen}>
        <HoverCardTrigger asChild>
          <button
            type="button"
            className={cn(
              'cursor-pointer rounded-xs font-medium text-primary text-xs focus:ring-2 focus:ring-ring focus:ring-offset-1 group-data-[nav-target=true]/footnote-ref:bg-(--color-highlight)',
              (selected && focused) || isSelectionInsideAtom
                ? 'ring-2 ring-ring ring-offset-1'
                : null
            )}
            onClick={(event) => {
              event.preventDefault();
              event.stopPropagation();
              if (
                readOnly &&
                isResolved &&
                footnoteNavigation.focusDefinition({ ref })
              ) {
                focusFootnoteControl(
                  editor,
                  footnoteApi.definition({ ref })?.[1]
                );
              }
            }}
            onMouseDown={(event) => {
              if (readOnly) return;
 
              if (event.metaKey || event.ctrlKey) {
                event.preventDefault();
                event.stopPropagation();
                if (isResolved) {
                  footnoteNavigation.focusDefinition({ ref });
 
                  return;
                }
 
                footnoteUpdate.createDefinition({ ref });
              }
            }}
          >
            [{ref}]
          </button>
        </HoverCardTrigger>
        {previewText ? (
          <HoverCardContent className="w-80">
            <div className="space-y-1">
              <div className="text-sm leading-relaxed text-muted-foreground">
                {previewText}
              </div>
            </div>
          </HoverCardContent>
        ) : ref && (isResolved || !readOnly) ? (
          <HoverCardContent className="w-80">
            <div className="space-y-2">
              {isResolved ? (
                <div className="text-sm leading-relaxed">
                  No preview available.
                </div>
              ) : (
                <Button
                  type="button"
                  variant="outline"
                  size="sm"
                  className="h-6 rounded-xs px-2 text-[11px]"
                  onMouseDown={(event) => {
                    event.preventDefault();
                    event.stopPropagation();
                    footnoteUpdate.createDefinition({ ref });
                    setHoverOpen(false);
                  }}
                >
                  Create definition for [^{ref}]
                </Button>
              )}
            </div>
          </HoverCardContent>
        ) : null}
      </HoverCard>
    </EditorElement>
  );
}
 
export function FootnoteDefinitionElement(
  props: EditorElementProps<typeof FootnoteDefinitionPlugin>
) {
  const { element } = props;
  const path = usePath();
  const editor = useEditor();
  const readOnly = useEditorReadOnly();
  const {
    api: footnoteNavigation,
    read: footnoteApi,
    update: footnoteUpdate,
  } = editor.plugin(FootnotePlugin);
  const ref = element.ref ?? '';
  const definitionState = useEditorSelector(() => {
    const isDuplicateDefinition =
      !!path && !!footnoteApi.isDuplicateDefinition?.({ path });
    const referenceItems =
      !isDuplicateDefinition && ref
        ? footnoteApi.references({ ref }).map((entry, index) => ({
            index,
            label: getReferenceContextLabel(editor, entry[1], index),
          }))
        : [];
 
    return {
      duplicateReplacementRef: isDuplicateDefinition
        ? footnoteApi.nextRef?.()
        : undefined,
      isDuplicateDefinition,
      path,
      referenceItems,
    };
  });
  const isDuplicateDefinition = !!definitionState?.isDuplicateDefinition;
  const duplicateReplacementRef = definitionState?.duplicateReplacementRef;
  const [referencePickerOpen, setReferencePickerOpen] = React.useState(false);
  const referenceItems = definitionState?.referenceItems ?? [];
  const hasMultipleReferences = referenceItems.length > 1;
  const focusReference = (index = 0) => {
    if (footnoteNavigation.focusReference({ ref, index }) && readOnly) {
      focusFootnoteControl(editor, footnoteApi.references({ ref })[index]?.[1]);
    }
  };
 
  return (
    <EditorElement
      {...props}
      className={cn(
        'mt-1.5 flex items-start gap-1.5 data-[nav-target=true]:rounded-md data-[nav-target=true]:bg-(--color-highlight)',
        isDuplicateDefinition &&
          'rounded-md border border-amber-500/30 bg-amber-500/5 px-2 py-2'
      )}
    >
      <div contentEditable={false}>
        {isDuplicateDefinition ? (
          <div className="min-w-3 text-xs text-amber-700 tabular-nums">
            {ref}
          </div>
        ) : (
          <FloatingPopover
            open={referencePickerOpen}
            onOpenChange={setReferencePickerOpen}
          >
            <FloatingPopoverAnchor
              element={
                <button
                  type="button"
                  aria-expanded={
                    hasMultipleReferences ? referencePickerOpen : undefined
                  }
                  aria-haspopup={hasMultipleReferences ? 'dialog' : undefined}
                  aria-label={`Back to reference ${ref}`}
                  className="min-w-3 cursor-pointer rounded-xs text-xs text-muted-foreground tabular-nums underline-offset-2 hover:text-foreground focus:ring-2 focus:ring-ring focus:ring-offset-1"
                  onClick={(event) => {
                    event.preventDefault();
                    event.stopPropagation();
                    if (!readOnly) return;
 
                    if (hasMultipleReferences) {
                      setReferencePickerOpen((open) => !open);
                    } else {
                      focusReference();
                    }
                  }}
                  onMouseDown={(event) => {
                    if (readOnly) return;
 
                    event.preventDefault();
                    event.stopPropagation();
 
                    if (hasMultipleReferences) {
                      setReferencePickerOpen((open) => !open);
 
                      return;
                    }
 
                    footnoteNavigation.focusReference({ ref });
                  }}
                >
                  {ref}
                </button>
              }
            />
 
            {hasMultipleReferences && referencePickerOpen ? (
              <FloatingPopoverContent
                className="w-72 p-0"
                align="start"
                sideOffset={8}
                onFinalFocus={(event) => {
                  event.preventDefault();
                }}
                onInitialFocus={(event) => {
                  if (!readOnly) event.preventDefault();
                }}
              >
                <Command tabIndex={readOnly ? 0 : undefined}>
                  <CommandList>
                    <CommandGroup>
                      {referenceItems.map(
                        (item: { index: number; label: string }) => (
                          <CommandItem
                            key={`${ref}-${item.index}`}
                            className="cursor-pointer gap-2"
                            onMouseDown={(event) => {
                              event.preventDefault();
                            }}
                            onSelect={() => {
                              setReferencePickerOpen(false);
                              focusReference(item.index);
                            }}
                          >
                            <span className="font-mono text-xs text-muted-foreground">
                              {item.index + 1}
                            </span>
                            <span className="truncate">{item.label}</span>
                          </CommandItem>
                        )
                      )}
                    </CommandGroup>
                  </CommandList>
                </Command>
              </FloatingPopoverContent>
            ) : null}
          </FloatingPopover>
        )}
      </div>
      <div className="min-w-0 flex-1">
        {isDuplicateDefinition ? (
          <div
            contentEditable={false}
            className="mb-2 flex flex-wrap items-center gap-2"
          >
            {!readOnly && duplicateReplacementRef && path ? (
              <Button
                type="button"
                variant="outline"
                size="sm"
                className="h-6 rounded-xs border-amber-500/40 px-2 text-[11px] text-amber-700 hover:bg-amber-500/10 hover:text-amber-800"
                onClick={(event) => {
                  event.preventDefault();
                  event.stopPropagation();
                }}
                onMouseDown={(event) => {
                  event.preventDefault();
                  event.stopPropagation();
                  footnoteUpdate.normalizeDuplicateDefinition({
                    ref: duplicateReplacementRef,
                    path,
                  });
                }}
              >
                Renumber to [^{duplicateReplacementRef}]
              </Button>
            ) : null}
          </div>
        ) : null}
        {props.children}
      </div>
    </EditorElement>
  );
}
 
export function FootnoteCombobox({ editableRef }: EditableSiblingProps) {
  return (
    <InlineCombobox
      editableRef={editableRef}
      filter={false}
      plugin={FootnotePlugin}
    >
      <FootnoteComboboxContent />
    </InlineCombobox>
  );
}
 
function FootnoteComboboxContent() {
  const { read: footnoteApi } = useEditor().plugin(FootnotePlugin);
  const query = useInlineComboboxQuery().trim();
  const refs = footnoteApi.refs?.() ?? [];
  const nextRef = footnoteApi.nextRef?.() ?? '1';
  const numericQuery = NUMERIC_FOOTNOTE_QUERY.test(query) ? query : '';
  const proposedRef = numericQuery || nextRef;
  const showCreateOption = !refs.includes(proposedRef);
 
  const filteredRefs = refs.filter((ref: string) => {
    if (!query) return true;
 
    const preview = footnoteApi.definitionText?.({ ref }) ?? '';
 
    return (
      ref.includes(query) || preview.toLowerCase().includes(query.toLowerCase())
    );
  });
 
  return (
    <InlineComboboxContent className="my-1.5 w-72">
      {showCreateOption || filteredRefs.length > 0 ? null : (
        <InlineComboboxEmpty>No footnotes</InlineComboboxEmpty>
      )}
 
      <InlineComboboxGroup>
        {showCreateOption && (!query || numericQuery) ? (
          <InlineComboboxItem
            value={`new-${proposedRef}`}
            onSelect={(tx) => {
              if (
                numericQuery &&
                tx.plugin(FootnotePlugin).definition({ ref: numericQuery })
              ) {
                return false;
              }
 
              tx.plugin(FootnotePlugin).insert({
                focusDefinition: false,
                ...(numericQuery ? { ref: numericQuery } : {}),
              });
              return undefined;
            }}
          >
            <span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
              <span className="font-mono text-muted-foreground">
                [^{proposedRef}]
              </span>
              <span className="truncate">: New footnote...</span>
            </span>
          </InlineComboboxItem>
        ) : null}
 
        {filteredRefs.map((ref: string) => (
          <InlineComboboxItem
            key={ref}
            value={`footnote-${ref}`}
            onSelect={(tx) => {
              tx.plugin(FootnotePlugin).insert({ focusDefinition: false, ref });
            }}
          >
            <span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
              <span className="font-mono text-muted-foreground">[^{ref}]</span>
              <span className="truncate">
                :{' '}
                {getFootnotePreviewLabel(footnoteApi.definitionText?.({ ref }))}
              </span>
            </span>
          </InlineComboboxItem>
        ))}
      </InlineComboboxGroup>
    </InlineComboboxContent>
  );
}
 
export const FootnoteKit = [
  FootnotePlugin.configure({
    component: FootnoteReferenceElement,
    slots: { afterEditable: FootnoteCombobox },
  }),
  FootnoteDefinitionPlugin.configure({ component: FootnoteDefinitionElement }),
];
'use client';
 
import { PathApi, type Path } from 'platejs';
import {
  FootnotePlugin,
  FootnoteDefinitionPlugin,
} from 'platejs/footnote/react';
import {
  type EditableSiblingProps,
  type Editor,
  type EditorElementProps,
  EditorElement,
  useEditor,
  useEditorFocused,
  useEditorReadOnly,
  useEditorSelector,
  useElementSelected,
  usePath,
} from 'platejs/react';
import * as React from 'react';
 
import { Button } 

































































































































































































































































































































































































































































































import { MarkdownPlugin } from 'platejs/markdown';
import remarkEmoji from 'remark-emoji';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
 
export const MarkdownKit = [
  MarkdownPlugin.configure({
    initialState: {
      remarkPlugins: [remarkMath, remarkGfm, remarkEmoji],
    },
  }),
];











Add kit

import { createEditor } from 'platejs/react';
import { FootnoteKit } from '@/components/editor/footnote';
import { MarkdownKit } from '@/components/editor/markdown';
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    ...FootnoteKit,
    ...MarkdownKit,
  ],
});
import { createEditor } from 'platejs/react';









Manual usage

Installation

pnpm add platejs marked remark-gfm remark-parse remark-stringify unified
pnpm add platejs marked remark-gfm remark-parse remark-stringify unified

Add plugins

Add the inline reference and block definition. Pair the footnote plugins with MarkdownPlugin and remark-gfm so [^1] round-trips correctly.

import { FootnoteDefinitionPlugin, FootnotePlugin } from 'platejs/footnote/react';
import { MarkdownPlugin } from 'platejs/markdown';
import { createEditor } from 'platejs/react';
import remarkGfm from 'remark-gfm';
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    FootnotePlugin,
    FootnoteDefinitionPlugin,
    MarkdownPlugin.configure({
      initialState: {
        remarkPlugins: [remarkGfm],
      },
    }),
  ],
});

Mount FootnoteCombobox in the plugin's afterEditable slot to open the picker when the reader types [^:

FootnotePlugin.configure({ slots: { afterEditable: FootnoteCombobox } });
FootnotePlugin.configure({ slots: { afterEditable: FootnoteCombobox } });

Insert a footnote

Call tx.footnote.insert at the current selection. It inserts the reference, creates a matching definition at the end of the document, and moves the caret into the definition body so the reader can start writing:

editor.update((tx) => tx.footnote.insert());
editor.update((tx) => tx.footnote.insert());

When the selection is expanded, the expanded fragment seeds the definition body so you can select text and "footnote-ify" it in one shot.

Pass focusDefinition: false when the reference should stay inline (for example, inside a larger template):

editor.update((tx) => tx.footnote.insert({ focusDefinition: false }));
editor.update((tx) => tx.footnote.insert({ focusDefinition: false }));

Pass ref to reuse an existing ref; the transform skips creating a duplicate definition when one already exists.

Repair an unresolved reference

When a reference points at a ref with no definition (e.g. pasted from elsewhere), use tx.footnote.createDefinition to create just the definition — without inserting another reference:

editor.update((tx) => tx.footnote.createDefinition({ ref: '3' }));
editor.update((tx) => tx.footnote.createDefinition({ ref: '3' }));

Pass focus: false when you want to leave the caret where it was:

editor.update((tx) =>
  tx.footnote.createDefinition({ focus: false, ref: '3' })
);
editor.update((tx) =>
  tx.footnote.createDefinition({ focus: false, ref: '3' })
);

Navigate between reference and definition

editor.api.footnote.focusDefinition and editor.api.footnote.focusReference scroll the target into view and flash it through Navigation Feedback. In an editable view they also move the selection to the target and focus the view. In a read-only view the selection and focus stay where they are. Call them from the mounted editor returned by useEditor():

editor.api.footnote.focusDefinition({ ref: '3' });
editor.api.footnote.focusReference({ ref: '3' });
editor.api.footnote.focusDefinition({ ref: '3' });
editor.api.footnote.focusReference({ ref: '3' });

When several references share a definition, pass index to choose a reference in document order:

editor.api.footnote.focusReference({ ref: '3', index: 1 });
editor.api.footnote.focusReference({ ref: '3', index: 1 });

Both commands return true after navigating, and false for an unresolved target or an unmounted editor. For selection-only changes inside a transaction, use tx.footnote.selectDefinition or tx.footnote.selectReference; each returns the resolved { targetPath, point } target or null.

With navigationFeedback: false, footnote navigation still scrolls to the target, and selects and focuses it in an editable view, without a highlight.

In a read-only view, the FootnoteKit components jump on a plain click or Enter. A reference jumps to its note. A note's back link jumps to its reference, or opens a picker of its citations when several references share the note; the arrow keys move through the picker and Enter jumps. After a jump, focus moves to the target's own jump control, so Enter jumps back.

Handle duplicate definitions

When definitions share a ref, the first definition in document order stays canonical; later ones are flagged as duplicates. Renumber a later duplicate with:

editor.update((tx) =>
  tx.footnote.normalizeDuplicateDefinition({
    path: duplicatePath,
  })
);
editor.update((tx) =>
  tx.footnote.normalizeDuplicateDefinition({
    path: duplicatePath,
  })
);

The transform returns the newly assigned ref string on success, or false when the path isn't a duplicate definition or the requested ref is already taken. Pass ref to target a specific free ref instead of the next available one.

Paste notes

A pasted definition lands at the end of the document, after every block already there and before the empty paragraph TrailingBlockPlugin keeps. Its references and the rest of the paste land at the caret, in a table cell, a quote or a list item as in a paragraph, and the caret ends after the pasted text. One undo removes the whole paste. A definition saved inside another block stays there.

Words copied from inside a note paste as words, without the note. A copy that covers a whole note keeps it, and a dragged note lands where it is dropped.

Round-trip Markdown

With remark-gfm configured, references serialize as [^1] and definitions as [^1]: text. Refs that Markdown would read as one label, such as A and a, are written as distinct labels. A reference whose definition is missing from the output reads back as text and reports a markdown-unsupported-node warning.

Export and paste HTML

serializeHtml writes each reference as a link to its note, and each note with a back link to its first reference:

<sup><a data-editor-footnote-ref="1" href="#fn-1" id="fnref-1">1</a></sup>
<div data-editor-footnote-definition="1" id="fn-1">
  <p>Source text</p>
  <a data-editor-footnote-chrome="" href="#fnref-1">↩</a>
</div>




The first reference to a ref gets the id fnref-<ref>, and the nth gets fnref-<ref>:<n>. The note gets fn-<ref>, and a later duplicate gets fn-<ref>:<n> with no back link. Each id percent-encodes the ref. Pasting the HTML back gives the same references and definitions without the back link.

Footnotes copied from a page GitHub rendered, such as a README or a comment, paste as references and root definitions with the numbers GitHub shows. A citation pasted without its note becomes an unresolved reference.

Static and Word output

The BaseFootnoteKit static components draw each reference as a link to its note and give each canonical note a back link to its first reference, with the ids editor.read.footnote.anchor returns. A reference without a note links to the id its note would have. Word export writes the label and the note text without links.

Customize rendering

Swap in your own React components with component:

import { FootnoteDefinitionPlugin, FootnotePlugin } from 'platejs/footnote/react';
import { createEditor } from 'platejs/react';
 
const editor = createEditor({
  plugins: [
    FootnotePlugin.configure({ component: MyFootnoteReference }),
    FootnoteDefinitionPlugin.configure({ component: MyFootnoteDefinition }),
  ],
});
import { FootnoteDefinitionPlugin, FootnotePlugin } from 'platejs/footnote/react';
import { createEditor } 






The package owns node semantics, ref allocation, and navigation helpers. App-level surfaces — hover previews, the [^ combobox, slash-command entries, toolbar buttons — are built on top of the transforms and API methods below.

Plugins

FootnotePlugin

Inline void node rendered as <sup>. Owns the [^ trigger policy, ref registry, navigation transforms, and query API. Its state is a ComboboxState.

Options

    Text that opens the footnote picker. Completion replaces the whole [^ and query, so claim[^1 works after a word.

    • Default: '[^'

    Only trigger when the previous character matches. null allows any character.

    • Default: null

    Extra predicate gating the picker. Return false to keep a typed [^ as text.

FootnoteDefinitionPlugin

Block node for footnote definitions. Lives at the bottom of the document and carries the ref + body content.

Reads

All read methods hang off editor.read.footnote and evaluate the active snapshot directly.

editor.read.footnote.definition

Get the first definition entry in document order for a ref.

Parameters

    Footnote ref.

Returns

    Canonical definition entry, or undefined when nothing matches.

editor.read.footnote.definitions

Get every definition entry that shares a ref, in document order. When duplicates exist, the first entry is canonical; later entries are duplicates.

Parameters

    Footnote ref.

Returns

    Definition entries in document order.

editor.read.footnote.definitionText

Get the plain-text content of the canonical definition. Ideal for hover previews — reads straight from live definition nodes, no copied state.

Parameters

    Footnote ref.

Returns

    Definition text, or undefined when no definition exists.

editor.read.footnote.references

Get every reference entry that points at a ref, in document order.

Parameters

    Footnote ref.

Returns

    Reference entries in document order.

editor.read.footnote.refs

List every ref that has at least one definition, in document order.

Returns

    Defined refs.

editor.read.footnote.nextRef

Compute the next free numeric ref. Used by tx.footnote.insert when the caller doesn't supply one.

Returns

    Next free ref (e.g. '1', '2').

editor.read.footnote.isResolved

Check whether a ref has at least one definition.

Parameters

    Footnote ref.

Returns

    true when at least one definition exists for the ref.

editor.read.footnote.anchor

Get the fragment id of the footnote reference or definition at a path, and the id it links to. Static components and the HTML encoders use the same ids.

Parameters

    Path of a footnote reference or definition.

Returns

    { id, target? }, or undefined when no footnote is at path. A reference targets its note's id, even when the note is missing. A canonical definition targets its first reference, and a later duplicate has no target.

editor.read.footnote.isDuplicateDefinition

Check whether a given definition path is a later duplicate (not the canonical one).

Parameters

    Path of the definition to check.

Returns

    true when the node at path is a footnote definition past the canonical one.

Updates

editor.update.footnote.insert

Insert a footnote reference at the current selection, create a matching definition if one doesn't already exist, and focus the definition body.

When the selection is expanded, the expanded fragment seeds the new definition body so you can convert selected prose into a footnote in one call.

Parameters

    Standard reference insertion options such as at and select.

editor.update.footnote.createDefinition

Create the missing definition for an existing ref without inserting another reference. Returns the path of the definition — the newly created one, or the existing one when the ref already resolves.

Parameters

    Ref to create a definition for.

    Focus the definition body after creation.

    • Default: true

Returns

    Path of the resolved definition.

editor.api.footnote.focusDefinition

Scroll the canonical definition into view and flash it through Navigation Feedback. In an editable view, also move the selection into the definition body and focus the view.

Parameters

    Footnote ref.

Returns

    false when the definition is missing or the calling view is unmounted; true after navigation, in an editable or a read-only view.

editor.api.footnote.focusReference

Scroll the matching reference into view and flash it through Navigation Feedback. In an editable view, also move the selection beside the reference and focus the view.

Parameters

    Footnote ref.

    Pick a specific reference when the definition is pointed at by several. Indexed by document order.

    • Default: 0

Returns

    false when the reference is missing or the calling view is unmounted; true after navigation, in an editable or a read-only view.

editor.update.footnote.normalizeDuplicateDefinition

Renumber a later duplicate definition so the canonical definition stays intact. Pass the path of the duplicate; optionally pass a specific ref to target, otherwise the transform picks editor.read.footnote.nextRef().

Parameters

    Path of the duplicate definition to renumber.

    Target ref. Must be free. Defaults to editor.read.footnote.nextRef().

Returns

    The assigned ref on success, or false when the path isn't a duplicate definition or the target ref is already taken.

Related docs

  • Markdown
  • Navigation Feedback
  • Editor Methods
from
'@/components/ui/button'
;
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import { cn } from '@/lib/utils';
import {
FloatingPopover,
FloatingPopoverAnchor,
FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import {
InlineCombobox,
InlineComboboxContent,
InlineComboboxEmpty,
InlineComboboxGroup,
InlineComboboxItem,
useInlineComboboxQuery,
} from '@/components/editor/inline-combobox';
const NUMERIC_FOOTNOTE_QUERY = /^\d+$/;
const focusFootnoteControl = (editor: Editor, path?: Path) => {
const key = path && editor.key(path);
if (key) {
editor.api.dom
.resolveDOMNode(key)
?.querySelector<HTMLButtonElement>('button')
?.focus({ preventScroll: true });
}
};
const getFootnotePreviewLabel = (text?: string) => {
const normalized = text?.replace(/\s+/g, ' ').trim();
if (!normalized) return 'Empty footnote';
return normalized.length > 48
? `${normalized.slice(0, 45).trimEnd()}...`
: normalized;
};
const getReferenceContextLabel = (
editor: Editor,
path: Path,
index: number
) => {
const parentEntry = editor.read.nodes.parent(path);
const fallback = `Reference ${index + 1}`;
if (!parentEntry) return fallback;
const text = editor.read.text.string(parentEntry[1]);
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) return fallback;
return normalized.length > 56
? `${normalized.slice(0, 53).trimEnd()}...`
: normalized;
};
export function FootnoteReferenceElement(
props: EditorElementProps<typeof FootnotePlugin>
) {
const { element } = props;
const path = usePath();
const editor = useEditor();
const readOnly = useEditorReadOnly();
const {
api: footnoteNavigation,
read: footnoteApi,
update: footnoteUpdate,
} = editor.plugin(FootnotePlugin);
const ref = element.ref ?? '';
const [hoverOpen, setHoverOpen] = React.useState(false);
const focused = useEditorFocused();
const fallbackResolved =
ref && footnoteApi ? footnoteApi.isResolved({ ref }) : false;
const fallbackPreviewText =
ref && footnoteApi ? footnoteApi.definitionText({ ref }) : undefined;
const livePreview = useEditorSelector(() => {
if (!hoverOpen || !ref) return null;
return {
isResolved: footnoteApi.isResolved({ ref }),
previewText: footnoteApi.definitionText({ ref }),
};
});
const isResolved = livePreview?.isResolved ?? fallbackResolved;
const previewText = livePreview?.previewText ?? fallbackPreviewText;
const selected = useElementSelected({ mode: 'text' });
const isSelectionInsideAtom = useEditorSelector((currentEditor) => {
const selection = currentEditor.read.selection();
if (!path || !selection) return false;
return (
PathApi.equals(selection.anchor.path, path.concat([0])) &&
PathApi.equals(selection.focus.path, path.concat([0])) &&
selection.anchor.offset === selection.focus.offset
);
});
return (
<EditorElement
{...props}
as="sup"
className="group/footnote-ref mx-0.5 align-super"
attributes={{
...props.attributes,
contentEditable: false,
draggable: true,
}}
>
{props.children}
<HoverCard open={hoverOpen} onOpenChange={setHoverOpen}>
<HoverCardTrigger asChild>
<button
type="button"
className={cn(
'cursor-pointer rounded-xs font-medium text-primary text-xs focus:ring-2 focus:ring-ring focus:ring-offset-1 group-data-[nav-target=true]/footnote-ref:bg-(--color-highlight)',
(selected && focused) || isSelectionInsideAtom
? 'ring-2 ring-ring ring-offset-1'
: null
)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (
readOnly &&
isResolved &&
footnoteNavigation.focusDefinition({ ref })
) {
focusFootnoteControl(
editor,
footnoteApi.definition({ ref })?.[1]
);
}
}}
onMouseDown={(event) => {
if (readOnly) return;
if (event.metaKey || event.ctrlKey) {
event.preventDefault();
event.stopPropagation();
if (isResolved) {
footnoteNavigation.focusDefinition({ ref });
return;
}
footnoteUpdate.createDefinition({ ref });
}
}}
>
[{ref}]
</button>
</HoverCardTrigger>
{previewText ? (
<HoverCardContent className="w-80">
<div className="space-y-1">
<div className="text-sm leading-relaxed text-muted-foreground">
{previewText}
</div>
</div>
</HoverCardContent>
) : ref && (isResolved || !readOnly) ? (
<HoverCardContent className="w-80">
<div className="space-y-2">
{isResolved ? (
<div className="text-sm leading-relaxed">
No preview available.
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="h-6 rounded-xs px-2 text-[11px]"
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
footnoteUpdate.createDefinition({ ref });
setHoverOpen(false);
}}
>
Create definition for [^{ref}]
</Button>
)}
</div>
</HoverCardContent>
) : null}
</HoverCard>
</EditorElement>
);
}
export function FootnoteDefinitionElement(
props: EditorElementProps<typeof FootnoteDefinitionPlugin>
) {
const { element } = props;
const path = usePath();
const editor = useEditor();
const readOnly = useEditorReadOnly();
const {
api: footnoteNavigation,
read: footnoteApi,
update: footnoteUpdate,
} = editor.plugin(FootnotePlugin);
const ref = element.ref ?? '';
const definitionState = useEditorSelector(() => {
const isDuplicateDefinition =
!!path && !!footnoteApi.isDuplicateDefinition?.({ path });
const referenceItems =
!isDuplicateDefinition && ref
? footnoteApi.references({ ref }).map((entry, index) => ({
index,
label: getReferenceContextLabel(editor, entry[1], index),
}))
: [];
return {
duplicateReplacementRef: isDuplicateDefinition
? footnoteApi.nextRef?.()
: undefined,
isDuplicateDefinition,
path,
referenceItems,
};
});
const isDuplicateDefinition = !!definitionState?.isDuplicateDefinition;
const duplicateReplacementRef = definitionState?.duplicateReplacementRef;
const [referencePickerOpen, setReferencePickerOpen] = React.useState(false);
const referenceItems = definitionState?.referenceItems ?? [];
const hasMultipleReferences = referenceItems.length > 1;
const focusReference = (index = 0) => {
if (footnoteNavigation.focusReference({ ref, index }) && readOnly) {
focusFootnoteControl(editor, footnoteApi.references({ ref })[index]?.[1]);
}
};
return (
<EditorElement
{...props}
className={cn(
'mt-1.5 flex items-start gap-1.5 data-[nav-target=true]:rounded-md data-[nav-target=true]:bg-(--color-highlight)',
isDuplicateDefinition &&
'rounded-md border border-amber-500/30 bg-amber-500/5 px-2 py-2'
)}
>
<div contentEditable={false}>
{isDuplicateDefinition ? (
<div className="min-w-3 text-xs text-amber-700 tabular-nums">
{ref}
</div>
) : (
<FloatingPopover
open={referencePickerOpen}
onOpenChange={setReferencePickerOpen}
>
<FloatingPopoverAnchor
element={
<button
type="button"
aria-expanded={
hasMultipleReferences ? referencePickerOpen : undefined
}
aria-haspopup={hasMultipleReferences ? 'dialog' : undefined}
aria-label={`Back to reference ${ref}`}
className="min-w-3 cursor-pointer rounded-xs text-xs text-muted-foreground tabular-nums underline-offset-2 hover:text-foreground focus:ring-2 focus:ring-ring focus:ring-offset-1"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (!readOnly) return;
if (hasMultipleReferences) {
setReferencePickerOpen((open) => !open);
} else {
focusReference();
}
}}
onMouseDown={(event) => {
if (readOnly) return;
event.preventDefault();
event.stopPropagation();
if (hasMultipleReferences) {
setReferencePickerOpen((open) => !open);
return;
}
footnoteNavigation.focusReference({ ref });
}}
>
{ref}
</button>
}
/>
{hasMultipleReferences && referencePickerOpen ? (
<FloatingPopoverContent
className="w-72 p-0"
align="start"
sideOffset={8}
onFinalFocus={(event) => {
event.preventDefault();
}}
onInitialFocus={(event) => {
if (!readOnly) event.preventDefault();
}}
>
<Command tabIndex={readOnly ? 0 : undefined}>
<CommandList>
<CommandGroup>
{referenceItems.map(
(item: { index: number; label: string }) => (
<CommandItem
key={`${ref}-${item.index}`}
className="cursor-pointer gap-2"
onMouseDown={(event) => {
event.preventDefault();
}}
onSelect={() => {
setReferencePickerOpen(false);
focusReference(item.index);
}}
>
<span className="font-mono text-xs text-muted-foreground">
{item.index + 1}
</span>
<span className="truncate">{item.label}</span>
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</FloatingPopoverContent>
) : null}
</FloatingPopover>
)}
</div>
<div className="min-w-0 flex-1">
{isDuplicateDefinition ? (
<div
contentEditable={false}
className="mb-2 flex flex-wrap items-center gap-2"
>
{!readOnly && duplicateReplacementRef && path ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-6 rounded-xs border-amber-500/40 px-2 text-[11px] text-amber-700 hover:bg-amber-500/10 hover:text-amber-800"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
footnoteUpdate.normalizeDuplicateDefinition({
ref: duplicateReplacementRef,
path,
});
}}
>
Renumber to [^{duplicateReplacementRef}]
</Button>
) : null}
</div>
) : null}
{props.children}
</div>
</EditorElement>
);
}
export function FootnoteCombobox({ editableRef }: EditableSiblingProps) {
return (
<InlineCombobox
editableRef={editableRef}
filter={false}
plugin={FootnotePlugin}
>
<FootnoteComboboxContent />
</InlineCombobox>
);
}
function FootnoteComboboxContent() {
const { read: footnoteApi } = useEditor().plugin(FootnotePlugin);
const query = useInlineComboboxQuery().trim();
const refs = footnoteApi.refs?.() ?? [];
const nextRef = footnoteApi.nextRef?.() ?? '1';
const numericQuery = NUMERIC_FOOTNOTE_QUERY.test(query) ? query : '';
const proposedRef = numericQuery || nextRef;
const showCreateOption = !refs.includes(proposedRef);
const filteredRefs = refs.filter((ref: string) => {
if (!query) return true;
const preview = footnoteApi.definitionText?.({ ref }) ?? '';
return (
ref.includes(query) || preview.toLowerCase().includes(query.toLowerCase())
);
});
return (
<InlineComboboxContent className="my-1.5 w-72">
{showCreateOption || filteredRefs.length > 0 ? null : (
<InlineComboboxEmpty>No footnotes</InlineComboboxEmpty>
)}
<InlineComboboxGroup>
{showCreateOption && (!query || numericQuery) ? (
<InlineComboboxItem
value={`new-${proposedRef}`}
onSelect={(tx) => {
if (
numericQuery &&
tx.plugin(FootnotePlugin).definition({ ref: numericQuery })
) {
return false;
}
tx.plugin(FootnotePlugin).insert({
focusDefinition: false,
...(numericQuery ? { ref: numericQuery } : {}),
});
return undefined;
}}
>
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
<span className="font-mono text-muted-foreground">
[^{proposedRef}]
</span>
<span className="truncate">: New footnote...</span>
</span>
</InlineComboboxItem>
) : null}
{filteredRefs.map((ref: string) => (
<InlineComboboxItem
key={ref}
value={`footnote-${ref}`}
onSelect={(tx) => {
tx.plugin(FootnotePlugin).insert({ focusDefinition: false, ref });
}}
>
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
<span className="font-mono text-muted-foreground">[^{ref}]</span>
<span className="truncate">
:{' '}
{getFootnotePreviewLabel(footnoteApi.definitionText?.({ ref }))}
</span>
</span>
</InlineComboboxItem>
))}
</InlineComboboxGroup>
</InlineComboboxContent>
);
}
export const FootnoteKit = [
FootnotePlugin.configure({
component: FootnoteReferenceElement,
slots: { afterEditable: FootnoteCombobox },
}),
FootnoteDefinitionPlugin.configure({ component: FootnoteDefinitionElement }),
];
import { MarkdownPlugin } from 'platejs/markdown';
import remarkEmoji from 'remark-emoji';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
export const MarkdownKit = [
MarkdownPlugin.configure({
initialState: {
remarkPlugins: [remarkMath, remarkGfm, remarkEmoji],
},
}),
];
import { FootnoteKit } from '@/components/editor/footnote';
import { MarkdownKit } from '@/components/editor/markdown';
const editor = createEditor({
plugins: [
// ...otherPlugins,
...FootnoteKit,
...MarkdownKit,
],
});
import { FootnoteDefinitionPlugin, FootnotePlugin } from 'platejs/footnote/react';
import { MarkdownPlugin } from 'platejs/markdown';
import { createEditor } from 'platejs/react';
import remarkGfm from 'remark-gfm';
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    FootnotePlugin,
    FootnoteDefinitionPlugin,
    MarkdownPlugin.configure({
      initialState: {
        remarkPlugins: [remarkGfm],
      },
    }),
  ],
});
<
sup
><
a
data-editor-footnote-ref
=
"1"
href
=
"#fn-1"
id
=
"fnref-1"
>1</
a
></
sup
>
<div data-editor-footnote-definition="1" id="fn-1">
<p>Source text</p>
<a data-editor-footnote-chrome="" href="#fnref-1">↩</a>
</div>
from
'platejs/react'
;
const editor = createEditor({
plugins: [
FootnotePlugin.configure({ component: MyFootnoteReference }),
FootnoteDefinitionPlugin.configure({ component: MyFootnoteDefinition }),
],
});