diff --git a/quartz/components/Sidenotes.tsx b/quartz/components/Sidenotes.tsx
new file mode 100644
index 000000000..e8a927a9a
--- /dev/null
+++ b/quartz/components/Sidenotes.tsx
@@ -0,0 +1,16 @@
+import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
+// @ts-ignore
+import script from "./scripts/sidenotes.inline"
+import style from "./styles/sidenotes.scss"
+import { classNames } from "../util/lang"
+
+export default (() => {
+ const Sidenotes: QuartzComponent = ({ displayClass }: QuartzComponentProps) => (
+
+ )
+
+ Sidenotes.css = style
+ Sidenotes.afterDOMLoaded = script
+
+ return Sidenotes
+}) satisfies QuartzComponentConstructor
diff --git a/quartz/components/index.ts b/quartz/components/index.ts
index 5b197941c..e03e12609 100644
--- a/quartz/components/index.ts
+++ b/quartz/components/index.ts
@@ -20,6 +20,7 @@ import MobileOnly from "./MobileOnly"
import RecentNotes from "./RecentNotes"
import Breadcrumbs from "./Breadcrumbs"
import Comments from "./Comments"
+import Sidenotes from "./Sidenotes"
export {
ArticleTitle,
@@ -44,4 +45,5 @@ export {
NotFound,
Breadcrumbs,
Comments,
+ Sidenotes,
}
diff --git a/quartz/components/scripts/sidenotes.inline.ts b/quartz/components/scripts/sidenotes.inline.ts
new file mode 100644
index 000000000..b52c943fd
--- /dev/null
+++ b/quartz/components/scripts/sidenotes.inline.ts
@@ -0,0 +1,141 @@
+import { removeAllChildren } from "./util"
+
+const ARTICLE_CONTENT_SELECTOR = ".center"
+const FOOTNOTE_SECTION_SELECTOR = "section[data-footnotes] > ol"
+const INDIVIDUAL_FOOTNOTE_SELECTOR = "li[id^='user-content-fn-']"
+
+// Computes an offset such that setting `top` on elemToAlign will put it
+// in vertical alignment with targetAlignment.
+function computeOffsetForAlignment(elemToAlign: HTMLElement, targetAlignment: HTMLElement) {
+ const offsetParentTop = elemToAlign.offsetParent!.getBoundingClientRect().top
+ return targetAlignment.getBoundingClientRect().top - offsetParentTop
+}
+
+// Clamp value between min and max
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(value, max))
+}
+
+function isInViewport(element: HTMLElement, buffer: number = 100) {
+ const rect = element.getBoundingClientRect()
+ return (
+ rect.top >= -buffer &&
+ rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) + buffer
+ )
+}
+
+// Get bounds for the sidenote positioning
+function getSidenoteBounds(
+ sideContainer: HTMLElement,
+ sidenote: HTMLElement,
+): { min: number; max: number } {
+ const containerRect = sideContainer.getBoundingClientRect()
+ const sidenoteRect = sidenote.getBoundingClientRect()
+
+ return {
+ min: 0,
+ max: containerRect.height - sidenoteRect.height,
+ }
+}
+
+function updateSidenotes(
+ articleContent: HTMLElement,
+ sideContainer: HTMLElement,
+ footnoteElements: NodeListOf,
+) {
+ footnoteElements.forEach((sidenote) => {
+ const sideId = sidenote.id.replace("sidebar-", "")
+ const intextLink = articleContent.querySelector(`a[href="#${sideId}"]`) as HTMLElement
+ if (!intextLink) return
+
+ // Calculate ideal position
+ let referencePosition = computeOffsetForAlignment(sidenote, intextLink)
+
+ // Get bounds for this sidenote
+ const bounds = getSidenoteBounds(sideContainer, sidenote)
+
+ // Clamp the position within bounds
+ referencePosition = clamp(referencePosition, bounds.min, bounds.max)
+
+ // Apply position
+ sidenote.style.top = `${referencePosition}px`
+
+ // Update visibility state
+ if (isInViewport(intextLink)) {
+ sidenote.classList.add("in-view")
+ intextLink.classList.add("active")
+ } else {
+ sidenote.classList.remove("in-view")
+ intextLink.classList.remove("active")
+ }
+ })
+}
+
+function debounce(fn: Function, delay: number) {
+ let timeoutId: ReturnType
+ return (...args: any[]) => {
+ clearTimeout(timeoutId)
+ timeoutId = setTimeout(() => fn(...args), delay)
+ }
+}
+
+document.addEventListener("nav", () => {
+ const articleContent = document.querySelector(ARTICLE_CONTENT_SELECTOR) as HTMLElement
+ const footnoteSection = document.querySelector(FOOTNOTE_SECTION_SELECTOR)
+ if (!footnoteSection || !articleContent) return
+
+ const sideContainer = document.querySelector(".sidenotes") as HTMLElement
+ if (!sideContainer) return
+
+ removeAllChildren(sideContainer)
+
+ // Set container height to match article content
+ const articleRect = articleContent.getBoundingClientRect()
+ sideContainer.style.height = `${articleRect.height}px`
+ sideContainer.style.top = `0px`
+
+ const ol = document.createElement("ol")
+ sideContainer.appendChild(ol)
+
+ const footnotes = footnoteSection.querySelectorAll(
+ INDIVIDUAL_FOOTNOTE_SELECTOR,
+ ) as NodeListOf
+
+ footnotes.forEach((footnote) => {
+ const footnoteId = footnote.id
+ const intextLink = articleContent.querySelector(`a[href="#${footnoteId}"]`) as HTMLElement
+ if (!intextLink) return
+
+ const sidenote = document.createElement("li")
+ sidenote.classList.add("sidenote-element")
+ sidenote.style.position = "absolute"
+ sidenote.id = `sidebar-${footnoteId}`
+ const cloned = footnote.cloneNode(true) as HTMLElement
+ sidenote.append(...cloned.children)
+ ol.appendChild(sidenote)
+ })
+
+ // Get all sidenotes for updates
+ const sidenotes = sideContainer.querySelectorAll(".sidenote-element") as NodeListOf
+
+ // Initial position update
+ updateSidenotes(articleContent, sideContainer, sidenotes)
+
+ // Update on scroll with debouncing
+ const debouncedUpdate = debounce(
+ () => updateSidenotes(articleContent, sideContainer, sidenotes),
+ 16, // ~60fps
+ )
+
+ // Add scroll listener
+ document.addEventListener("scroll", debouncedUpdate, { passive: true })
+
+ // Add resize listener
+ window.addEventListener("resize", debouncedUpdate, { passive: true })
+
+ // Cleanup
+ window.addCleanup(() => {
+ document.removeEventListener("scroll", debouncedUpdate)
+ window.removeEventListener("resize", debouncedUpdate)
+ })
+})
diff --git a/quartz/components/styles/sidenotes.scss b/quartz/components/styles/sidenotes.scss
new file mode 100644
index 000000000..0998d03ec
--- /dev/null
+++ b/quartz/components/styles/sidenotes.scss
@@ -0,0 +1,15 @@
+.sidenotes {
+ & > ol {
+ padding-inline-start: 0;
+ }
+
+ & .sidenote-element {
+ transition: opacity 0.2s ease;
+ opacity: 0;
+ margin-bottom: 1rem;
+
+ &.in-view {
+ opacity: 1;
+ }
+ }
+}