Inline footnotes with html templates

# December 17, 2023

I couldn’t write without footnotes. Or at least - I couldn't write enjoyably without them. They let you sneak in anecdotes, additional context, and maybe even a joke or two. They're the love of my writing life.

For that reason, I wanted to get them closer to the content itself. By default Markdown and Markdown parsers will render footnotes at the end of the article. For instance:

| This is a blog post    |
| with a footenote. [^1] |
| Hear me roar.          |

[^1] footnote

My goal was to instead have them look more like:

| This is a blog post    |
| with a footenote. [^1] |  | footnote |
| Hear me roar.          |

Okay. Where's the tech design here?

  1. Footnotes should display next to their reference tags.
  2. They should resize and change position dynamically if the text wraps or box model changes.
  3. If the window is too small, they should remain at the end.

The end product looks like this. If they don't show up inline, you might need to resize your browser.1

The only way to accomplish this is with a bit of Javascript.2 Since this site uses vanilla html and Javascript only, we can add a small <script> to the page that accomplishes exactly that.

First we style our post contents to have two grid columns: one for the content and one for the footnotes.

<div class="grid grid-cols-12">
    <div class="col-span-12 lg:col-span-8 px-4" id="content-raw">
    </div>
    <div class="hidden lg:block lg:col-span-4 relative" id="footnotes-inline">
    </div>
</div>

We use media queries to show the footnotes on lg screens and above, and hide them otherwise. We then style the inline footnote as we want. This is where the <template> tag comes in. Templates aren't rendered into the DOM and need to be instantiated in Javascript before use.

<div class="hidden lg:block lg:col-span-4 relative" id="footnotes-inline">
    <template id="footnote-template">
    <div class="text-slate-400 prose border-l-4 pl-2">
    {CONTENT}
    </div>
    </template>
</div>

So let's actually write this thing. Our approach will be to scan the post for all instances of footnotes and then calculate where it falls on the page. These particular class names mirror what the python Markdown package outputs into html.

const contentRaw = document.getElementById("content-raw");
const footnotes = document.getElementById("footnotes-inline");
const footnoteTemplate = document.getElementById("footnote-template");

document.querySelectorAll('.footnote-ref').forEach(callout => {
    // Based on the a tag link, get the actual footnote
    const footnoteId = callout.getAttribute("href").replace("#", "");
    const footnoteOriginal = document.getElementById(footnoteId);

    // Get the position relative to the parent container and scroll
    const calloutRect = callout.getBoundingClientRect();
    const parentRect = contentRaw.getBoundingClientRect();
    const top = calloutRect.top - parentRect.top;

    // New footnote div
    let footnote = document.createElement("div");
    footnote.style.position = "absolute";
    footnote.style.top = top + "px";
    footnote.innerHTML = footnoteTemplate.innerHTML.replace("{CONTENT}", footnoteOriginal.innerHTML);

    footnotes.appendChild(footnote);

    // Find where this has rendered to present a cap on the y-axis for subsequent notes
    const footnoteRect = footnote.getBoundingClientRect();
    const footnoteTop = footnoteRect.top - parentRect.top;
    const footnoteBottom = footnoteTop + footnoteRect.height;
});

This should mostly work already. But there are a few gotchas:

  • This will only position the inline footnote once at load time. If you resize the page or have dynamic elements that change the article as users read it, the footnotes will lose their anchor. They'll be lost, floating in space alone.
  • If two footnote references are close to one another in the original article, they might overlap when we render them on the sidebar.

We fix this by updating the footnotes whenever the page changes size. You could use the legacy window.onresize callback for doing this, but there are settings in which the content has changed even when the window has not. The ResizeObserver is purpose built for this case. We install the hook for our content to get notifications anytime the actual content causes a reflow. We also add some minmax logic to ensure that footnotes aren't allowed to overlap one another.

const contentRaw = document.getElementById("content-raw");
const footnotes = document.getElementById("footnotes-inline");
const footnoteTemplate = document.getElementById("footnote-template");

const footnoteSpacePadding = 20;

function updateFootnotes() {
    let maxY = 0;

    // Clear existing footnotes to avoid duplicates
    footnotes.innerHTML = '';

    document.querySelectorAll('.footnote-ref').forEach(callout => {
        const footnoteId = callout.getAttribute("href").replace("#", "");
        const footnoteOriginal = document.getElementById(footnoteId);

        const calloutRect = callout.getBoundingClientRect();
        const parentRect = contentRaw.getBoundingClientRect();
        const top = Math.max(maxY + footnoteSpacePadding, calloutRect.top - parentRect.top);

        let footnote = document.createElement("div");
        footnote.style.position = "absolute";
        footnote.style.top = top + "px";
        footnote.innerHTML = footnoteTemplate.innerHTML.replace("{CONTENT}", footnoteOriginal.innerHTML);

        footnotes.appendChild(footnote);

        const footnoteRect = footnote.getBoundingClientRect();
        const footnoteTop = footnoteRect.top - parentRect.top;
        const footnoteBottom = footnoteTop + footnoteRect.height;
        maxY = footnoteBottom;
    });
}

// Create a new ResizeObserver
const resizeObserver = new ResizeObserver(entries => {
    for (let entry of entries) {
        // Check if the contentRaw is being observed and resized
        if (entry.target === contentRaw) {
            updateFootnotes();
        }
    }
});

// Start observing the contentRaw element
resizeObserver.observe(contentRaw);

// Call the function on initial load
updateFootnotes();

And that's it. Inline footnotes with vanilla html. Coming soon to a personal blog near you.


  1. And if they don't show up at all, well, there I can't really help you. 

  2. If you have a fully unresponsive site where words are locked into their line width regardless of the overall browser setting, you can pre-calculate the offsets on the server side. Otherwise if you have different configuration for mobile and desktop, or just otherwise want to allow some word wrapping to happen client side, it will always come back to JS. 

Related tags:
#programming #webapp
Typehinting from day-zero
Static typehinted languages can make us lazy about adding types at the right time. We have all the context when we start a new project, but as we increase complexity and focus on other things that context wains. Rewards compound from typing on day zero.
Debugging chrome extensions with system-level logging
Extensions are basically mini web applications these days, just with access to a `chrome` global variable that can interact with some browser-level functionality. Aside from that - it's all familiar. That extends to the debugging experience. Since extensions run in the regular V8 Chrome runtime, Chrome exposes the same debugging tools that you're used to on the web.
Network routing interaction on MacOS
There are a series of resolution layers governing DNS, IP, and port routing on OSX. Included are notes on the different routing utilities supported locally, specifically using /etc/hosts, ifconfig, pfctl, and /etc/resolver.

Hi, I'm Pierce

I write mostly about engineering, machine learning, and company building. If you want to get updated about longer essays, subscribe here.

I hate spam so I keep these infrequent - once or twice a month, maximum.