Retour au catalogue

Blog Interactive TOC

Article de blog avec table des matieres interactive en sidebar et surlignage au scroll.

blogmedium Both Responsive a11y
minimaleditorialuniversalsaaseducationsplit
Theme

How to build a sticky table of contents with scroll tracking in React

A sticky interactive TOC in React uses a sidebar nav that stays fixed while the content scrolls. Each section registers with Framer Motion's onViewportEnter callback, which sets the active ID in local state and updates the left-border highlight on the matching TOC item.

  • Stack: React 19 + Framer Motion 11 + Lucide React, ~120 lines, zero extra dependencies.
  • Scroll detection uses Framer Motion's onViewportEnter with a -80px margin, not IntersectionObserver manually.
  • Accessible: the nav carries aria-label="Table des matieres" and TOC items are real <button> elements.
  • Responsive caveat: the sticky sidebar collapses on small screens; ship a mobile drawer or top TOC variant for touch.
  • Theming via CSS custom properties (--color-accent, --color-border), no hardcoded colors.

Blog Interactive TOC is a two-column article layout with a sticky left sidebar that tracks the reader's position through the content. As each section enters the viewport, its TOC entry highlights with an accent left-border, giving readers an instant sense of where they are in a long-form piece, the kind of navigation detail that separates polished documentation from a plain scrolling page.

Anatomy

The component has three visual zones. At the top, a full-width article header shows the category tag, title, author, date, and read time using Lucide icons. Below it, a CSS grid splits the view into a 220px sticky nav column and a fluid content column. The nav lists section titles as buttons with a shared left border; the active item gets a colored left-border highlight. Each content section is a motion.div with its own staggered fade-in and an onViewportEnter hook.

How it works

The scroll-spy mechanism avoids IntersectionObserver boilerplate by delegating to Framer Motion's viewport tracking. Each content section is wrapped in a motion.div with onViewportEnter={() => setActiveId(s.id)} and a -80px viewport margin. When more than 80px of a section enters the visible area, setActiveId fires and the state update instantly re-renders the TOC with the new active highlight. Clicking a TOC button also calls setActiveId directly, keeping keyboard navigation in sync with scroll position.

How to build it in React

  1. Define the data shape and set up state

    Create a Section interface with id, title, and content. Accept a sections array as a prop and initialize activeId with useState, defaulting to the first section's ID. This single piece of state drives both the TOC highlight and the scroll-spy logic.

    const [activeId, setActiveId] = useState(sections[0]?.id ?? "");
  2. Build the sticky sidebar nav

    Render a motion.nav with position:sticky and top:2rem. Map over sections to produce a button for each entry. Drive the left-border color and font-weight from activeId, active items get --color-accent and weight 600, inactive ones get --color-foreground-muted and weight 400. Apply a CSS transition on color and border-color for a smooth swap.

    borderLeftColor: activeId === s.id ? "var(--color-accent)" : "transparent",
    fontWeight: activeId === s.id ? 600 : 400,
  3. Attach onViewportEnter to content sections

    Wrap each article section in a motion.div and pass onViewportEnter={() => setActiveId(s.id)}. Set the viewport margin to -80px so the highlight fires slightly before the section top reaches the very top of the screen. Stagger the initial fade-in with delay: i * 0.05 for a cascade effect on first load.

    <motion.div
      onViewportEnter={() => setActiveId(s.id)}
      viewport={{ once: true, margin: "-80px" }}
      initial={{ opacity: 0, y: 16 }}
      whileInView={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.5, delay: i * 0.05, ease: EASE }}
    >
  4. Wire click navigation and handle responsive layout

    Each TOC button's onClick calls setActiveId directly so keyboard users and mouse users stay in sync without scroll events. For mobile, collapse the sidebar into a top-of-page accordion or drawer, the sticky 220px column is too narrow for small screens and should be hidden below a breakpoint with a CSS media query or Tailwind responsive prefix.

When to use it

Use this layout for long-form content where readers need orientation: technical documentation, in-depth tutorials, case studies, or editorial features with four or more named sections. Avoid it for short posts (under three sections) where the sidebar adds visual noise without navigation value, and for marketing landing pages where a sticky CTA sidebar outperforms a TOC.

Used by

  • Stripe Docs, Sticky right-side TOC with active-section highlighting across all API reference and guide pages.
  • MDN Web Docs, Persistent sidebar TOC that tracks scroll position and marks the current heading for long technical articles.
  • Vercel Docs, Two-column layout with a floating TOC that highlights sections on scroll throughout its deployment guides.

FAQ

Why use Framer Motion's onViewportEnter instead of IntersectionObserver?

onViewportEnter is already available on any motion.div you add for animations, so there is no extra observer setup, no ref wiring, and no cleanup needed. If Framer Motion is already in your project, this is four characters of added API surface.

How do I handle real in-page anchor scrolling instead of just the visual highlight?

Replace the button's onClick with document.getElementById(s.id)?.scrollIntoView({ behavior: 'smooth' }) and keep setActiveId only in onViewportEnter. This way the TOC drives real scroll and the highlight stays in sync through the viewport callback.

Does the sticky sidebar work with Next.js App Router layouts?

Yes, but make sure the scroll container is the document window, not a nested overflow:auto div. App Router wraps pages in a root layout, if that layout has overflow:hidden or overflow:auto, position:sticky loses its reference and stops working.

How do I make the TOC accessible for keyboard users?

The current implementation already uses semantic button elements, so they are focusable and activatable via Enter or Space with no extra work. Add aria-current="true" to the active item so screen readers announce which section is selected when focus moves through the list.

"use client";

import { useState } from "react";
import { motion } from "framer-motion";
import { Clock, User, Tag } from "lucide-react";

interface ArticleMeta {
  author: string;
  date: string;
  readTime: string;
  category: string;
}

interface Section {
  id: string;
  title: string;
  content: string;
}

interface BlogInteractiveTocProps {
  articleTitle?: string;
  articleMeta?: ArticleMeta;

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reviews

React Sticky TOC with Scroll Spy, Code + Tutorial