Skip to content

Commit b72d825

Browse files
committed
feat: implement smooth scroll for TOC and prevent highlight jitter
1 parent 2da4f7f commit b72d825

File tree

2 files changed

+56
-3
lines changed

2 files changed

+56
-3
lines changed

src/components/Layout/Toc.tsx

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,56 @@ import cx from 'classnames';
1313
import {useTocHighlight} from './useTocHighlight';
1414
import type {Toc} from '../MDX/TocContext';
1515

16+
let animationFrameId: number;
17+
18+
const smoothScrollTo = (targetId: string) => {
19+
const element = document.getElementById(targetId);
20+
if (!element) return;
21+
22+
(window as any).__isAutoScrolling = true;
23+
24+
const headerOffset = 84;
25+
const startPosition = window.scrollY;
26+
const elementPosition = element.getBoundingClientRect().top;
27+
const targetPosition = startPosition + elementPosition - headerOffset;
28+
const distance = targetPosition - startPosition;
29+
const duration = 300;
30+
let startTime: number | null = null;
31+
32+
const easeInOutCubic = (t: number): number => {
33+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
34+
};
35+
36+
const animation = (currentTime: number) => {
37+
if (startTime === null) startTime = currentTime;
38+
const timeElapsed = currentTime - startTime;
39+
const progress = Math.min(timeElapsed / duration, 1);
40+
const easedProgress = easeInOutCubic(progress);
41+
42+
window.scrollTo(0, startPosition + distance * easedProgress);
43+
44+
if (progress < 1) {
45+
animationFrameId = requestAnimationFrame(animation);
46+
} else {
47+
(window as any).__isAutoScrolling = false;
48+
}
49+
};
50+
51+
cancelAnimationFrame(animationFrameId);
52+
animationFrameId = requestAnimationFrame(animation);
53+
};
54+
55+
const getAnchorIdFromUrl = (url: string) => {
56+
return url.startsWith('#') ? url.substring(1) : url;
57+
};
58+
1659
export function Toc({headings}: {headings: Toc}) {
1760
const {currentIndex} = useTocHighlight();
1861
// TODO: We currently have a mismatch between the headings in the document
1962
// and the headings we find in MarkdownPage (i.e. we don't find Recap or Challenges).
2063
// Select the max TOC item we have here for now, but remove this after the fix.
2164
const selectedIndex = Math.min(currentIndex, headings.length - 1);
65+
2266
return (
2367
<nav role="navigation" className="pt-20 sticky top-0 end-0">
2468
{headings.length > 0 && (
@@ -37,11 +81,12 @@ export function Toc({headings}: {headings: Toc}) {
3781
if (!h.url && process.env.NODE_ENV === 'development') {
3882
console.error('Heading does not have URL');
3983
}
84+
const anchorId = getAnchorIdFromUrl(h.url);
4085
return (
4186
<li
4287
key={`heading-${h.url}-${i}`}
4388
className={cx(
44-
'text-sm px-2 rounded-s-xl',
89+
'text-sm px-2 rounded-s-xl transition-colors duration-200 ease-in-out',
4590
selectedIndex === i
4691
? 'bg-highlight dark:bg-highlight-dark'
4792
: null,
@@ -55,9 +100,14 @@ export function Toc({headings}: {headings: Toc}) {
55100
selectedIndex === i
56101
? 'text-link dark:text-link-dark font-bold'
57102
: 'text-secondary dark:text-secondary-dark',
58-
'block hover:text-link dark:hover:text-link-dark leading-normal py-2'
103+
'block hover:text-link dark:hover:text-link-dark leading-normal py-2 transition-colors duration-200 ease-in-out'
59104
)}
60-
href={h.url}>
105+
href={h.url}
106+
onClick={(e) => {
107+
e.preventDefault();
108+
smoothScrollTo(anchorId);
109+
window.history.pushState(null, '', `#${anchorId}`);
110+
}}>
61111
{h.text}
62112
</a>
63113
</li>

src/components/Layout/useTocHighlight.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export function useTocHighlight() {
6060
}
6161

6262
function throttledUpdateActiveLink() {
63+
if ((window as any).__isAutoScrolling) {
64+
return;
65+
}
6366
if (timeoutRef.current === null) {
6467
timeoutRef.current = window.setTimeout(() => {
6568
timeoutRef.current = null;

0 commit comments

Comments
 (0)