↩ Experiments

Timeline

July 2024

Why a Timeline?

A standard scrollbar is a great tool for indicating progress, but it lacks context. For long articles, it's difficult for a user to know where they are in relation to the content's structure. This timeline component aims to solve that by providing a persistent, visual map of the article. It serves as a dynamic table of contents, allowing users to see where the major sections are and to track their progress through them.

How It Works

The core of the experiment is a React component that uses a useEffect hook to attach a listener to the window's scroll event. As the user scrolls, this listener tracks window.scrollY and calculates which of the timeline's lines should be highlighted as "active."

useEffect(() => {
  const handleScroll = () => {
    if (isScrollingProgrammatically.current) return;
    
    const scrollY = window.scrollY;
    const newActiveLine = calculateActiveLine(scrollY);
    setActiveLine(newActiveLine);
  };

  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

The timeline itself isn't static. On page load, it measures the total "scrollable" height of the article and the position of each `ArticleHeader` (an `h2` element). It then generates the appropriate number of lines, ensuring the timeline is an accurate representation of the content's length and structure. Each line corresponds to roughly 80px of article height.

Mapping Positions

The most interesting challenge was accurately mapping the scroll position to the timeline. A naive approach of just dividing the scroll position by the line height works for the most part, but it fails at the end of the article. Because the last few paragraphs can't be scrolled to the very top of the viewport, the last few lines on the timeline would never become active.

The solution was to treat the article as two distinct parts: the main body (everything up to the last header) and the final section. The scroll-spy logic now uses one calculation for when the user is in the main body and switches to a different one for the final section. This ensures the active line smoothly follows the user all the way to the end of the page.

const calculateActiveLine = (scrollY) => {
  const lineHeight = 80;
  const lastHeaderPosition = headerPositions[headerPositions.length - 1];
  
  if (scrollY < lastHeaderPosition) {
    // Main body calculation
    return Math.floor(scrollY / lineHeight);
  } else {
    // Final section calculation
    const remainingScroll = maxScrollHeight - scrollY;
    const remainingLines = Math.ceil(remainingScroll / lineHeight);
    return totalLines - remainingLines;
  }
};

A Pesky Edge Case

Making the timeline clickable was the next step. Each line has an `onClick` handler that scrolls the user to the corresponding point in the article. It uses `window.scrollTo()` with a `smooth` behavior for a more pleasant user experience.

This, however, introduced a classic scroll-spying problem. When a user clicks a line, it triggers a "programmatic" scroll. During the animation, the scroll event is still firing, causing our scroll-spy to fight with the intended destination. The result was a flickering highlight that would often land on the wrong line. The fix was to use a useRef as a flag to temporarily disable the scroll-spy listener when a click occurs. A setTimeout then re-enables the listener after the scroll animation has had time to complete.

const isScrollingProgrammatically = useRef(false);

const handleLineClick = (lineIndex) => {
  isScrollingProgrammatically.current = true;
  
  const targetScrollY = lineIndex * 80;
  window.scrollTo({
    top: targetScrollY,
    behavior: 'smooth'
  });
  
  // Re-enable scroll spy after animation completes
  setTimeout(() => {
    isScrollingProgrammatically.current = false;
  }, 1000);
};