React or Gatsby Table of Contents
April 15, 2020 | 7 min read
I needed a floating (sticky) table of contents in a sidebar for this Gatsby
blog.
I’ve found this article about building sticky responsive ToC (table of contents) and I’ve based my work on that (thank you, Janosh!).
How it works
We get page headings from markdown engine or by querying headings by document.querySelectorAll
.
We need to get for every heading the following data:
- value, text
- id to link to it
- depth of nesting
- vertical offset from top (relative to
offsetParent
node):htmlElement.offsetTop
property
We listen for scroll events on a page and compare the current scroll position window.scrollY
with every heading’s offsetTop
.
If the current scroll position is after a heading i
offset and before the heading i+1
offset - we mark the heading i
as active.
Implementation
The full implementation is here. I will explain the most important details of the implementation in the following sections.
How to use it
Table of contents with React
The implementation can query needed headings from DOM itself. In the minimal configuration we need only content container selector. The component will parse headings only in this container.
import Toc from "components/toc"
const BlogPostTemplate = ({ data, pageContext, location }) => {
// ...
const toc = <Toc containerSelector="#content section" />
// ...
}
Table of contents with Gatsby
The ToC will work faster if we prebuild headings and don’t query DOM during page loading.
If we use Gatsby with markdown we can query remark
engine for headings: it will query headings during site compilation rather than during page loading.
But remark
doesn’t build or return heading ids. First, we can build ids by using a plugin gatsby-remark-autolink-headers. And we can reconstruct heading ids by using the same algorithm it uses - just call github-slugger
.
It’s not the clean way but it’s safe: if our ids will differ from ids generated by the plugin we will get a build-time error.
The error is generated inside the Toc
component during server-side rendering if any heading wasn’t found in the DOM.
import Toc from "components/toc"
import slugs from "github-slugger"
const preprocessHeading = h => {
const cleanValue = h.value.replace(/<(\/)?[^>]+>/g, "").replace(/\s{2,}/g, " ")
return {
depth: h.depth,
value: cleanValue,
id: slugs.slug(cleanValue),
}
}
const BlogPostTemplate = ({ data, pageContext, location }) => {
// ...
const headings = data.markdownRemark.headings.filter(h => h.depth >= 2 && h.depth <= 4).map(preprocessHeading) const toc = <Toc prebuiltHeadings={headings} containerSelector="#content section" /> // ...
}
export const pageQuery = graphql`
query BlogPostBySlug($slug: String!) {
// ...
markdownRemark(fields: { slug: { eq: $slug } }) {
// ...
headings { depth value } }
}
`
Also, we can configure needed heading levels, title, etc:
interface IProps {
containerSelector: string // Selector for a content container
levels?: number[] // Needed heading levels, by default [2, 3, 4]
prebuiltHeadings?: IHeadingData[] // Already extracted page headings to speed up
title?: string // Title, default is "Contents"
throttleTimeMs?: number // Scroll handler throttle time, default is 300
// Note: offsetToBecomeActive must not be zero because at least in my chrome browser
// element.scrollTo() sets window.scrollY = element.offsetTop - 1
// and some routers use this function to scroll to window.location.hash.
// The default value is 30 (px).
offsetToBecomeActive?: number
}
Performance
Let’s talk about implementation details leading to high performance.
Headings extraction
If we don’t provide headings in prebuiltHeadings
the component they will be selected inside the component:
const container = document.querySelector(this.props.containerSelector) as HTMLElement
const levels = this.props.levels || [2, 3, 4]
const headingSelector = levels.map(level => `h${level}`).join(`, `)
const htmlNodes: HTMLElement[] = Array.from(container.querySelectorAll(headingSelector))
headings = htmlNodes.map((node, i) => ({
value: node.innerText,
depth: Number(node.nodeName[1]),
id: node.id,
htmlNode: node,
}))
But this DOM querying and data fetching is expensive: in my Chrome browser for this page ToC headings are built in 100-200ms. If we provide prebuilt headings - the same page ToC headings are built in ~0.5ms.
Lazy DOM access
DOM access can be slow: e.g. offsetTop
property can cause reflow.
Therefore we do lazy access to such data. Heading nodes fetch an HTML element and it’s offsetTop
only when needed by checking a cache version.
export default class HeadingNode {
// ...
lazyLoad(curCacheVersion: number) {
if (curCacheVersion === this.offsetCacheVersion) {
return
}
if (!this.htmlNode) {
this.htmlNode = document.getElementById(this.id) if (!this.htmlNode) {
throw Error(`no heading with id "${this.id}"`)
}
}
this.cachedOffsetTop = this.htmlNode.offsetTop this.offsetCacheVersion = curCacheVersion
}
}
A cache version is incremented when DOM could have changed, e.g. lazy images were loaded or block was collapsed. We detect DOM changes by using mutation observer if it’s available. Otherwise we conservatively increment cache version on every scroll (it’s slower). The simplified version of the component code:
export default class Toc extends React.Component<IProps, IState> {
// ...
private setupEventListeners() {
let handleScroll: any
if (typeof window === "undefined" || !window.MutationObserver) {
handleScroll = () => {
this.state.headingTree.markOffsetCacheStale() this.handleScrollImpl()
}
} else {
handleScroll = this.handleScrollImpl.bind(this)
this.domObserver = new MutationObserver(mutations => {
this.state.headingTree.markOffsetCacheStale() })
const container = document.querySelector(this.props.containerSelector)
this.domObserver.observe(container, {
attributes: true,
childList: true,
subtree: true,
characterData: true,
})
}
this.handleScrollThrottled = throttle(handleScroll, this.props.throttleTimeMs || 300)
window.addEventListener(`scroll`, this.handleScrollThrottled)
window.addEventListener(`resize`, () => {
this.state.headingTree.markOffsetCacheStale()
})
}
}
The heading tree just increments a version in markOffsetCacheStale
:
export class HeadingTree {
// ...
markOffsetCacheStale() { this.offsetCacheVersion++ }}
Scroll handler
On every scroll event we look for the currently active heading:
export default class Toc extends React.Component<IProps, IState> {
// ...
private findActiveNode(): HeadingNode | null {
if (!this.state.headingTree) {
return null
}
const offsetToBecomeActive = this.props.offsetToBecomeActive || 30
const curScrollPos = window.scrollY + offsetToBecomeActive
let activeNode = null
let lastNode = null
this.state.headingTree.traverseInPreorder((h: HeadingNode) => { if (curScrollPos > h.cachedOffsetTop) { lastNode = h return TraverseResult.Continue } activeNode = lastNode return TraverseResult.Stop })
if (activeNode === null && lastNode !== null && this.state.container) {
// Mark last heading active only if we didn't scroll after the end of the container.
if (window.scrollY <= this.state.container.offsetTop + this.state.container.offsetHeight) {
return lastNode
}
}
return activeNode
}
}
But we throttle a scroll handler to run it no more than once per throttleTimeMs
(300ms by default).
No unnecessary state changes
We find an active heading and all it’s parents inside scroll handler. But on a typical scroll active heading doesn’t change. We don’t change React
state if an active section didn’t change and don’t trigger extra rendering:
export default class Toc extends React.Component<IProps, IState> {
// ...
private handleScrollImpl() {
const activeNode = this.findActiveNode()
if (activeNode !== this.state.activeNode) { const activeParents = this.buildActiveParents(activeNode)
this.setState({ activeNode, activeParents })
}
}
}
Mobile version performance
On small screens we render our table of contents in a special way as you see in a GIF. ToC window can be closed by clicking on the closing icon or by clicking outside of the window. To close the window on outside click we need to listen for click events. To save CPU we add click event listener only when a window is opening and remove it on the window closing:
export default class Toc extends React.Component<IProps, IState> {
// ...
private handleOpen() {
if (!this.clickEventListenerWasAdded) {
document.addEventListener("mousedown", this.handleClickOutside) this.clickEventListenerWasAdded = true
}
this.setState({ open: true })
}
private handleClose() {
if (this.clickEventListenerWasAdded) {
document.removeEventListener("mousedown", this.handleClickOutside) this.clickEventListenerWasAdded = false
}
this.setState({ open: false })
}
}
Restrictions
Our table of contents component doesn’t support heading changes after a page creation: it can be useful during article writing with hot-reload. Also, one more mobile CPU optimization wasn’t performed: don’t set up a scroll handler until a table of contents window is open on small screens.