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.
[^ opens a reference picker from the default UI kit.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 Editor,
type EditorElementProps,
EditorElement,
useEditor,
useEditorFocused,
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 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 {
api: footnoteNavigation,
read: footnoteApi,
update: footnoteUpdate,
} = useEditor().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();
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();
}}
onMouseDown={(event) => {
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 ? (
<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 {
api: footnoteNavigation,
read: footnoteApi,
update: footnoteUpdate,
} = useEditor().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;
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();
}}
onMouseDown={(event) => {
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) => {
event.preventDefault();
}}
>
<Command>
<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);
footnoteNavigation.focusReference({
ref,
index: 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"
>
{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,
}: {
editableRef: React.RefObject<HTMLDivElement | null>;
}) {
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 Editor,
type EditorElementProps,
EditorElement,
useEditor,
useEditorFocused,
useEditorSelector,
useElementSelected,
usePath,
} from 'platejs/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
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 { 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';
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 } });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.
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' })
);editor.api.footnote.focusDefinition and editor.api.footnote.focusReference
move the selection, focus the calling view, scroll the target into view, and
flash it through Navigation Feedback. 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 false for an unresolved target, a read-only view, 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 selects, focuses,
and scrolls to the target without a highlight.
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.
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.
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.
Inline void node rendered as <sup>. Owns the [^ trigger policy, ref registry, navigation transforms, and query API. Its state is a ComboboxState.
Text that opens the footnote picker. Completion replaces the whole [^ and query, so claim[^1 works after a word.
'[^'Only trigger when the previous character matches. null allows any character.
nullExtra predicate gating the picker. Return false to keep a typed [^ as text.
Block node for footnote definitions. Lives at the bottom of the document and carries the ref + body content.
All read methods hang off editor.read.footnote and evaluate the active
snapshot directly.
Get the first definition entry in document order for a ref.
Get every definition entry that shares a ref, in document order. When duplicates exist, the first entry is canonical; later entries are duplicates.
Get the plain-text content of the canonical definition. Ideal for hover previews — reads straight from live definition nodes, no copied state.
Get every reference entry that points at a ref, in document order.
List every ref that has at least one definition, in document order.
Compute the next free numeric ref. Used by tx.footnote.insert when the caller doesn't supply one.
Check whether a ref has at least one definition.
Get every definition after the first one for a ref, in document order.
List every ref that has more than one definition.
Check whether a ref has more than one definition.
Check whether a given definition path is a later duplicate (not the canonical one).
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.
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.
Jump the selection into the canonical definition body, scroll it into view, and flash it through Navigation Feedback.
Jump the selection to the matching reference, scroll it into view, and flash it through Navigation Feedback.
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().
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],
},
}),
],
});