A footnote is unusual among editor features: it is one idea stored in two places that are far apart in the document and must always agree. A superscript marker sits mid-sentence on page two; the note it refers to sits in a list at the end. Break the correspondence and the document is worse than if the feature had never existed, because the reader now has a citation pointing at the wrong source.
Everything about implementing footnotes follows from deciding what, exactly, is allowed to be the truth.
One source of truth: document order
The tempting model is a footnotes array in plugin state: each entry has an id, a number and some text, and the DOM is rendered from it. This falls apart immediately, because the user does not edit your array. They edit the document — and they do it with tools you do not control. They select three paragraphs and hit delete. They paste a section from another document. They drag a block upward. They hit undo.
Any state you keep beside the document is a second copy that those operations do not update. So keep none. The markers present in the DOM, in the order they appear, are the entire truth:
const markers = editable.querySelectorAll("sup.fn-ref[data-fn-id]");querySelectorAllreturns document order, which is exactly the order footnotes are numbered in. Everything else — which notes exist, what order they sit in, what number each end displays — is derived from that list on every mutation. There is no state to drift because there is no state.
The reconcile pass is short, and it is the whole feature:
function sync() {
const refs = markersInDocumentOrder();
if (!refs.length) { removeNotesSection(); return; } // no orphan heading
const seen = new Set();
refs.forEach((ref, i) => {
let id = ref.dataset.fnId;
if (seen.has(id)) id = rekey(ref); // pasted duplicate
seen.add(id);
ref.textContent = String(i + 1); // marker number
const note = findNote(id) || createNote(id);
note.dataset.fnNumber = String(i + 1);
list.appendChild(note); // reorder to match
});
removeNotesWhoseMarkerIsGone(seen);
}list.appendChild(note) is doing more work than it looks like. Appending a node that is already in the list movesit. Walking the markers in order and appending each note therefore re-sorts the entire notes list into document order as a side effect — inserts, reorders and all, in one line.
The case that catches most implementations
Copy a sentence containing a marker and paste it elsewhere. You now have two markers in the document carrying the same id, both pointing at one note.
Nothing about this is exotic — it is ordinary editing — but it breaks the one-to-one assumption every other part of the feature rests on. Deleting either copy would delete the shared note and orphan the other. That is what the seen set above is for: the second marker with a given id gets re-keyed to a fresh one, and the reconcile pass then notices it has no note and creates an empty one for the user to fill in.
The other lifecycle rules fall out of the same pass:
- Delete a marker → its note is no longer reachable from any marker, so it is removed. No orphan notes.
- Delete the last marker → the whole section goes, rather than leaving an empty “Footnotes” heading in the saved HTML.
- Paste a marker from another document → it has no note here, so one is created for it.
Write the numbers as text — here
We have argued the opposite case on this site. For legal clause numbering, writing numbers into the text is the wrong answer and CSS counters() is the right one: the markup stays clean, and the numbering cannot desync because the browser derives it at paint time.
Footnotes invert that trade-off, and it is worth being precise about why, because “we used counters over there” is a reasonable objection.
Clause numbers live their whole life inside a styled document. Footnote numbers do not. A footnote exists to be followed by a reader who may be nowhere near your editor — reading the published page, a PDF export, an RSS item, an email, or the HTML pasted into a wiki that strips your stylesheet. A CSS counter renders nothing at all in any of those. The superscript is simply gone, and the note at the bottom has lost its anchor.
So the number is written as literal text at both ends, and the cost — renumbering on every mutation — is one we were paying anyway, because the notes list has to be reordered regardless. The saved HTML stands alone:
<p>The clause was struck out<sup class="fn-ref" data-fn-id="fn3k1a0"
data-fn-number="1" id="fnref-fn3k1a0" contenteditable="false">1</sup>.</p>
<section class="footnotes">
<ol>
<li class="fn-note" data-fn-id="fn3k1a0" id="fn-fn3k1a0">Ibid., p. 42<a
class="fn-back" href="#fnref-fn3k1a0" contenteditable="false">↩</a></li>
</ol>
</section>The general rule is worth extracting: presentation-time derivation is right for anything that stays inside the document, and wrong for anything whose job is to survive leaving it.
Markers must be atomic
A marker is a rendered number that the user must never edit, because the next reconcile pass will overwrite it anyway. Leave it editable and the caret will eventually land between the 1 and the 2 of footnote 12, and a keystroke will produce a marker reading 1x2 until something rewrites it.
contenteditable="false" on the <sup> makes the browser treat it as a single unit: arrow keys step over it, backspace deletes the whole thing, and the caret cannot get inside. Deleting the marker is exactly how you delete a footnote, which is what a user expects.
The empty inline element is a caret trap
This one is worth a warning of its own, because it produced a bug that looked impossible.
Our first version gave each note a designated text container, so that reading a note’s text was unambiguous:
<li class="fn-note" data-fn-id="...">
<span class="fn-text"></span> <!-- user types here -->
<a class="fn-back" ...>↩</a>
</li>After inserting a footnote we placed the caret inside that empty span so the user could just start typing. They typed. The text appeared in the note, correctly, on screen. And the API reported the note as empty.
The cause is that an empty inline element cannot hold a caret. There is no text position inside it — it has no content to have a position within — so a range anchored at offset 0 of that span resolves to a position in the parent. The first characters typed land in the <li>, immediately before the still-empty span:
<li class="fn-note">Ibid., p. 42<span class="fn-text"></span><a ...>↩</a></li>Visually indistinguishable. Structurally, every read that trusted .fn-text returned an empty string.
Two fixes, and it is worth doing both:
- Drop the wrapper. The note text lives directly in the
<li>, which is a block and holds a caret reliably. Simpler markup, and the trap is gone rather than worked around. - Read tolerantly. Extract note text as the whole
<li>minus the backlink, not as one designated child. Users type wherever they like, and documents saved by older versions still carry the old wrapper.
The general lesson generalises past footnotes: any time you create an empty inline element and expect the user to type into it, you have built this bug. Seed it with content, or use a block, or do not create it.
How to know it works
Insertion is the case that always works. The cases worth automating are the ones that involve the document changing underneath the feature:
- Insert a footnote above an existing one. Both markers and both notes must renumber.
- Delete the first marker. The rest shift down; its note disappears.
- Move a paragraph containing a marker to the top. Numbering follows position, and the notes list re-sorts.
- Duplicate a marker. Ids must diverge; no two markers may share a note.
- Save, load into a fresh editor, and keep editing. Renumbering must still work on content it did not create.
That last one is the one people skip, and it is the one that catches state kept outside the document — because on a freshly loaded document there is no such state, and anything depending on it quietly does nothing.
RichTextEditor includes footnotes in the base licence. CKEditor 5 documents footnotes as a premium feature available on its Essential, Professional and Custom plans; TinyMCE’s Footnotes plugin is “only available for paid TinyMCE subscriptions”. See the authoring tools documentation or the live demo.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.