What are content tables?
A common feature on article pages is a contents table, which provides users with a general list of the article contents, as well as gives them a link to the section that is relevant to them. A feature that enhances the content table is to make it easy to identify which section in the article the user is currently on.
On my website I have done this for articles, on desktop, it is to the right, and on mobile is a sticky item at the top of the page. If you look at the contents table and scroll up and down the page you should see the headers of the current section highlighted.
My Version
In this article, the version I am showing uses Next JS, Typescript, and Portable Text from Sanity CMS with Zustand as the state management, building this feature in other frameworks and vanilla javascript is possible and would follow most of the same principles.
The Code
The code for this is broken into three pieces the state management to allow the different areas to talk to each other, the headers of the article, and the contents table.
State Management
First, we need to create a way for the headers of the article and content table to communicate with each other. As my article page is written in a react, I needed state management which I have used Zustand .
Here we are creating a store to keep the current header and a function that will update the current header.
import { create } from 'zustand'
export interface ContentStore {
currentHeader: string | null
setHeader: ({id}: {id: string}) => void
}
export const articleContentStore = create<ContentStore>((set) => ({
currentHeader: null,
setHeader: ({id}: {id: string}) => {
try {
set(() => ({currentHeader: id}))
} catch (err) {
console.log(err)
}
}
}))
These can be called inside react components by calling the store like this:
// For getting the content
const currentHeader = articleContentStore((state) => state.currentHeader)
// For setting the header
const setHeader = articleContentStore((state) => state.setHeader)
In vanilla javascript, you won't need state management and would handle this through normal DOM manipulation.
The Headers
Using sanity and Portable Text I am able to give items such as headers a custom component in react. In this component, I would get the type ie h1, h2, etc, and convert it to JSX tag so it can maintain the tag it was in the CMS. We also create an id, I have used the key that comes from sanity, however, you can also use the Slugify package to make human-readable ids.
import { PortableTextBlockComponent } from "@portabletext/react";
import Link from "next/link";
import { HeadingElement } from "./heading";
interface HeadingProps {
[key: string]: string;
}
const headingClasses: HeadingProps = {
h1: '',
h2: 'px-4 my-2 text-3xl font-bold lg:px-0 scroll-mt-12 scroll-smooth',
h3: 'px-4 my-2 text-2xl font-bold lg:px-0 scroll-mt-12',
h4: 'px-4 my-2 text-xl font-bold lg:px-0 scroll-mt-12',
}
export const Heading2: PortableTextBlockComponent = function ({ children, value }) {
const { style, _key } = value
/* Create a React component from the HTML tag */
const HeadingTag = style as keyof JSX.IntrinsicElements
/* Create an id using the key or slugify the text */
const headingId = `h${_key}`;
/* return as null if they no heading */
if (!HeadingTag) return null
/*
Here we set href to the id using a hash link
Getting the tailwind classes from the object above and passing
the id and children down to.
*/
return (
<a href={`#${headingId}`}>
<HeadingElement HeadingTag={HeadingTag} classNames={headingClasses[HeadingTag]} headingId={headingId} >
{children}
</HeadingElement >
</a>
)
}
Notice I have not used next/link, this is due to an issue currently in the next 13 app directory where hash links are not working correctly, when this is fixed I will amend the code.
Now we get to the client-side logic which finds out which header is the current one. In this, we are getting the set header function, creating a reference using the useRef hook, and linking the ref to an intersection observer hook which can be found here .
The intersection observer hook will track the position of the reference in the viewport, I have set the root margin-bottom to -60%, this means the element will be intersecting if it is in the top 40% of the screen.
"use client"
import { useIntersectionObserver } from 'hooks/use-intersection-observer'
import { useEffect, useRef } from 'react'
import { articleContentStore } from 'stores/article-contents'
interface Props {
HeadingTag: React.ElementType
classNames: string
headingId: string
children: unknown
}
export function HeadingElement({HeadingTag, classNames, headingId, children}: Props) {
// Getting the set header function
const setHeader = articleContentStore((state) => state.setHeader)
// creating a reference which will be set to the header
const ref = useRef<HTMLHeadingElement | null>(null)
// Intersection Observer to track the object reference
const entry = useIntersectionObserver(ref, {
threshold: 0.25,
freezeOnceVisible: false,
rootMargin: '0% 0% -60% 0%'
})
const isVisible = entry?.isIntersecting
useEffect(() => {
if (isVisible) {
// If is visable set the header to the id.
setHeader({id: headingId})
}
}, [isVisible])
return (
<HeadingTag id={headingId} className={classNames} ref={ref}>
{children}
</ HeadingTag>
)
}
This gives us headers that have an id that can be scrolled to, as well as track their position on the page, and update the store's current header.
The Contents Table
For the contents table, I filtered the article for just the headers, this allowed me to use portable text as same as the article but only show the headers.
This is the custom component for the individual content links, which is similar to the headers in the way we get the classes and the heading id. By fetching the current heading from the store we could compare the individual content link to the active one and then add extra styles to show the current header.
'use client'
import type { PortableTextBlockComponent } from "@portabletext/react";
import Link from "next/link";
import { articleContentStore } from "stores/article-contents";
interface HeadingClasses {
[key: string]: string;
}
// The base classes for the different types of headers
const headingClasses: HeadingClasses = {
h1: '',
h2: 'block hover:text-white cursor-pointer text-lg duration-300 lg:pl-3 transition',
h3: 'block hover:text-white cursor-pointer ml-2 text-lg duration-300 lg:pl-3 transition',
h4: 'block hover:text-white cursor-pointer ml-4 text-lg duration-300 lg:pl-3 transition',
}
export const ContentLink: PortableTextBlockComponent = function ({ value, children }) {
// getting the current header from the state store
const currentHeader = articleContentStore((state) => state.currentHeader)
// get the _key and the type of heading from sanity
const { style, _key } = value
// creating the id which links them
const headingId = `h${_key}`
// compare the current header from the store to this one
// allowing use to add a active class to it.
const isCurrent = currentHeader === headingId
return (
<a href={`#${headingId}`} className={`${headingClasses[style as string]} ${isCurrent ? 'text-white scale-105' : 'text-light'}`} >{children}</a>
)
}
The Result
This gives the user a nice indicator of where the user is currently on the page by using the position of the heading in portable text.