Skip to content

Instantly share code, notes, and snippets.

@noxify
Last active April 30, 2026 11:15
Show Gist options
  • Select an option

  • Save noxify/c72782bc61df644d4a32ed82e0956838 to your computer and use it in GitHub Desktop.

Select an option

Save noxify/c72782bc61df644d4a32ed82e0956838 to your computer and use it in GitHub Desktop.
renoun toc
// components/toc/index.ts
export {
TableOfContents,
TableOfContentsScript,
type TableOfContentsSection,
type TableOfContentsProps,
type TableOfContentsComponents,
} from "./TableOfContents"
// app/[...slug]/layout.tsx
import { TableOfContentsScript } from "@/components/toc"
export default async function DocsSlugLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ slug: string[] }>
}) {
return (
<div className="relative w-full" >
{/*
Here we call TOC script which was removed from TOC register component.
This solved the react error and it seems it also fixed the `aria-current` issue
which wasn't updated on scroll in the original implementation.
*/}
<TableOfContentsScript />
{children}
</div>
)
}
// components/toc/Register.tsx
"use client"
import { useLayoutEffect } from "react"
/**
* A component that registers heading ids with the `TableOfContentsScript`.
* @internal
*/
export function Register({ ids }: { ids: string[] }) {
useLayoutEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
window.__TableOfContents__?.register(ids)
}, [ids])
return null
}
// components/toc/script.ts
declare global {
interface Window {
__TableOfContents__: {
register: (ids: string[]) => void
}
}
}
/**
* Script to manage active target state in the table of contents.
* @internal
*/
export default function ({
activationRatio = 0.333,
}: {
/** A number between `0` and `1` representing which portion of the viewport height from top the target becomes active. */
activationRatio?: number
}): void {
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
const smoothScrollBehavior: ScrollBehavior = prefersReducedMotion ? "auto" : "smooth"
const OVERFLOW_REGEX = /(auto|scroll)/
const viewportCache = new WeakMap<HTMLElement, HTMLElement>()
let previousActiveLink: HTMLAnchorElement | null = null
let previousLastSectionInView = false
let isScrollingIntoView = false
let lastScrollY = 0
let rafId = 0
let dispose: (() => void) | null = null
function cancelFrame(): void {
if (rafId) cancelAnimationFrame(rafId)
rafId = 0
}
function getLink(id: string): HTMLAnchorElement | null {
return document.querySelector<HTMLAnchorElement>(`:is(ol, ul) a[href="#${id}"]`)
}
function getClosestViewport(node: HTMLElement): HTMLElement {
const cached = viewportCache.get(node)
if (cached) {
return cached
}
let current: ParentNode | null = node.parentNode
while (current) {
if (current === document.body) {
return document.body
}
if (current instanceof HTMLElement) {
const { overflow, overflowX, overflowY } = getComputedStyle(current)
if (OVERFLOW_REGEX.test(overflow + overflowX + overflowY)) {
viewportCache.set(node, current)
return current
}
}
current = current.parentNode
}
viewportCache.set(node, document.body)
return document.body
}
function setActiveLink(target: HTMLElement): void {
isScrollingIntoView = true
target.scrollIntoView({ behavior: smoothScrollBehavior, block: "start" })
const nextActiveLink = getLink(target.id)
if (nextActiveLink) {
nextActiveLink.setAttribute("aria-current", "location")
history.pushState(null, "", "#" + target.id)
if (previousActiveLink && previousActiveLink !== nextActiveLink) {
previousActiveLink.removeAttribute("aria-current")
}
previousActiveLink = nextActiveLink
}
if ("onscrollend" in window) {
window.addEventListener(
"scrollend",
() => {
isScrollingIntoView = false
},
{ passive: true, once: true },
)
} else {
cancelFrame()
let still = 0
function step(): void {
const y = window.scrollY
if (Math.abs(y - lastScrollY) < 1) {
if (++still > 4) {
isScrollingIntoView = false
cancelFrame()
return
}
} else {
still = 0
}
lastScrollY = y
rafId = requestAnimationFrame(step)
}
rafId = requestAnimationFrame(step)
}
}
document.addEventListener("click", (event) => {
if (!(event.target instanceof HTMLAnchorElement)) return
const href = event.target.href
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!href?.includes("#")) return
const id = href.slice(href.indexOf("#") + 1)
const section = document.getElementById(id)
if (!section) return
event.preventDefault()
setActiveLink(section)
})
window.__TableOfContents__ = {
register: (targetIds: string[]) => {
dispose?.()
const targetElements = targetIds
.map((id) => document.getElementById(id))
.filter(Boolean) as HTMLElement[]
const linkFor = new Map<HTMLElement, HTMLAnchorElement | null>(
targetElements.map((target) => [target, getLink(target.id)]),
)
const lastIndex = targetElements.length - 1
const lastTarget = targetElements[lastIndex]
const lastLink = lastTarget ? linkFor.get(lastTarget) : null
let scrollTimeout: number | null = null
const scrollDelay = 100
function update(): void {
if (isScrollingIntoView) return
const vh = window.innerHeight || document.documentElement.clientHeight
const vw = window.innerWidth || document.documentElement.clientWidth
const offsetTop = vh * activationRatio
let bestIndex = 0
let bestTop = -Infinity
let lastSectionInView = false
for (let index = 0; index < targetElements.length; index++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const rect = targetElements[index]!.getBoundingClientRect()
if (rect.top <= offsetTop && rect.top > bestTop) {
bestTop = rect.top
bestIndex = index
}
if (
index === lastIndex &&
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < vh &&
rect.left < vw
) {
lastSectionInView = true
}
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const targetElement = targetElements[bestIndex]!
const nextActiveLink = linkFor.get(targetElement) ?? null
if (nextActiveLink !== previousActiveLink) {
if (previousActiveLink) {
previousActiveLink.removeAttribute("aria-current")
}
if (nextActiveLink) {
nextActiveLink.setAttribute("aria-current", "location")
}
previousActiveLink = nextActiveLink
}
if (lastSectionInView) {
if (!previousLastSectionInView && bestIndex !== lastIndex && lastLink) {
if (scrollTimeout) clearTimeout(scrollTimeout)
scrollTimeout = window.setTimeout(() => {
const viewport = getClosestViewport(lastLink)
if (viewport !== document.body) {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: smoothScrollBehavior,
})
}
}, scrollDelay)
}
} else if (previousActiveLink) {
if (scrollTimeout) clearTimeout(scrollTimeout)
scrollTimeout = window.setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const viewport = getClosestViewport(previousActiveLink!)
if (viewport !== document.body) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
previousActiveLink!.scrollIntoView({
behavior: smoothScrollBehavior,
block: "nearest",
})
}
}, scrollDelay)
}
previousLastSectionInView = lastSectionInView
}
const intersectionObserver = new IntersectionObserver(update, {
root: null,
rootMargin: `-${activationRatio * 100}% 0px 0px 0px`,
threshold: [0, 1],
})
targetElements.forEach((target) => intersectionObserver.observe(target))
update()
dispose = () => {
intersectionObserver.disconnect()
cancelFrame()
isScrollingIntoView = false
previousLastSectionInView = false
if (previousActiveLink) {
previousActiveLink.removeAttribute("aria-current")
previousActiveLink = null
}
}
},
}
}
// components/toc/TableOfContents.tsx
import type { ContentSection, Section } from "renoun"
import React, { useId } from "react"
import { Script } from "renoun/components"
import NextScript from "next/script"
import tocScript from "./script"
import { Register } from "./Register"
/** A section for the table of contents (either Section or ContentSection). */
export type TableOfContentsSection = Section | ContentSection
export interface TableOfContentsComponents {
/** Root navigation element. */
Root: React.ComponentType<{
children?: React.ReactNode
"aria-labelledby"?: string
}>
/** Title heading. */
Title: React.ComponentType<{
id?: string
children?: React.ReactNode
}>
/** Ordered list of items. */
List: React.ComponentType<{
depth: number
children?: React.ReactNode
}>
/** Individual list item. */
Item: React.ComponentType<{
children?: React.ReactNode
}>
/** Anchor link to a heading. */
Link: React.ComponentType<{
children?: React.ReactNode
href: string
suppressHydrationWarning?: boolean
"aria-current"?: React.AriaAttributes["aria-current"]
}>
}
export interface TableOfContentsProps {
/** The sections to display within the table of contents. */
sections: TableOfContentsSection[]
/** Override the default component renderers. */
components?: Partial<TableOfContentsComponents>
/** Optional content rendered after the section links. */
children?: React.ReactNode
}
const defaultComponents: TableOfContentsComponents = {
Root: (props) => <nav {...props} />,
Title: ({ children = "On this page", ...props }) => <h4 {...props}>{children}</h4>,
List: (props) => <ol {...props} />,
Item: (props) => <li {...props} />,
Link: (props) => <a {...props} />,
}
/**
* Script to manage active heading state in the table of contents.
* @internal
*/
export function TableOfContentsScript_OLD({ nonce }: { nonce?: string }) {
// @ts-expect-error – renoun Script API uses a dynamic import pattern not recognized by tsc
return <Script nonce={nonce}>{import("./script.ts")}</Script>
}
// it seems that the renoun `Script` component isn't working or it's just me 😶‍🌫️
// but using the `NextScript` solved the problem finally.
// this doesn't work with other react frameworks, but as workaround it's ok
export function TableOfContentsScript({ nonce }: { nonce?: string }) {
const code = `void (${Function.prototype.toString.call(tocScript)})(${JSON.stringify({ activationRatio: 0.333 })});`
return (
<NextScript
id="renoun-toc-script"
nonce={nonce}
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: code.replace(/<\/script/gi, "<\\/script") }}
/>
)
}
/** Check if a section has a depth property (is ContentSection). */
function hasDepth(section: TableOfContentsSection): section is ContentSection {
return "depth" in section && typeof section.depth === "number"
}
/** Collect all section IDs recursively. */
function collectSectionIds(sections: TableOfContentsSection[], ids: Set<string>): void {
for (const section of sections) {
ids.add(section.id)
if (section.children) {
collectSectionIds(section.children, ids)
}
}
}
/** A table of contents that displays links to the sections in the current document. */
export function TableOfContents({ sections, components = {}, children }: TableOfContentsProps) {
const rootId = useId()
const sectionIds = new Set<string>()
const { Root, Title, List, Item, Link }: TableOfContentsComponents = {
...defaultComponents,
...components,
}
// Filter to only show sections with depth > 1 (skip h1) for ContentSection,
// or include all sections for Section (no depth property)
const filteredSections = sections.filter((section) => !hasDepth(section) || section.depth > 1)
// Collect all section IDs for scroll tracking
collectSectionIds(filteredSections, sectionIds)
function renderSections(sections: TableOfContentsSection[], depth = 0): React.ReactNode {
if (sections.length === 0) {
return null
}
return (
<List depth={depth}>
{sections.map((section) => (
<Item key={section.id}>
<Link href={`#${section.id}`} suppressHydrationWarning>
{"jsx" in section && section.jsx !== undefined ? section.jsx : section.title}
</Link>
{section.children && section.children.length > 0
? renderSections(section.children, depth + 1)
: null}
</Item>
))}
</List>
)
}
if (filteredSections.length === 0 && !children) {
return null
}
return (
<Root aria-labelledby={rootId}>
<Title id={rootId} />
{renderSections(filteredSections)}
{children}
<Register ids={Array.from(sectionIds)} />
</Root>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment