Skip to content

Automatically scroll to the active page in table of contents #2425

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

5 changes: 5 additions & 0 deletions .changeset/stupid-guests-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': patch
---

Automatically scroll to active page in table of contents
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { tcls } from '@/lib/tailwind';

import { PagesList } from './PagesList';
import { Trademark } from './Trademark';
import { TOCScrollContainerProvider } from './useScrollToActiveTOCItem';

export function TableOfContents(props: {
space: Space;
Expand Down Expand Up @@ -55,7 +56,7 @@ export function TableOfContents(props: {
)}
>
{header ? header : null}
<div
<TOCScrollContainerProvider
className={tcls(
withHeaderOffset ? 'pt-4' : ['pt-4', 'lg:pt-0'],
'hidden',
Expand Down Expand Up @@ -87,7 +88,7 @@ export function TableOfContents(props: {
{customization.trademark.enabled ? (
<Trademark space={space} customization={customization} />
) : null}
</div>
</TOCScrollContainerProvider>
</aside>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React from 'react';

import { tcls } from '@/lib/tailwind';

import { useScrollToActiveTOCItem } from './useScrollToActiveTOCItem';
import { Link } from '../primitives';

const show = {
Expand Down Expand Up @@ -46,6 +47,7 @@ export function ToggleableLinkItem(props: {

const [scope, animate] = useAnimate();
const [isVisible, setIsVisible] = React.useState(hasActiveDescendant);
const linkRef = React.createRef<HTMLAnchorElement>();

// Update the visibility of the children, if we are navigating to a descendant.
React.useEffect(() => {
Expand Down Expand Up @@ -90,12 +92,15 @@ export function ToggleableLinkItem(props: {
const mountedRef = React.useRef(false);
React.useEffect(() => {
mountedRef.current = true;
}, []);
});

useScrollToActiveTOCItem({ linkRef, isActive });

return (
<div>
<Link
href={href}
ref={linkRef}
aria-selected={isActive}
className={tcls(
'group/toclink',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use client';

import React from 'react';

const TOCScrollContainerContext = React.createContext<React.RefObject<HTMLDivElement> | null>(null);

export function TOCScrollContainerProvider(
props: React.PropsWithChildren<{
className: React.HTMLAttributes<HTMLDivElement>['className'];
}>,
) {
const { className, children } = props;
const scrollContainerRef = React.createRef<HTMLDivElement>();

return (
<TOCScrollContainerContext.Provider value={scrollContainerRef}>
<div ref={scrollContainerRef} className={className}>
{children}
</div>
</TOCScrollContainerContext.Provider>
);
}

// Offset to scroll the table of contents item by.
const TOC_ITEM_OFFSET = 200;

/**
* Scrolls the table of contents container to the page item when it becomes active,
* but only if the item is outside the viewable area of the container.
*/
export function useScrollToActiveTOCItem(tocItem: {
isActive: boolean;
linkRef: React.RefObject<HTMLAnchorElement>;
}) {
const { isActive, linkRef } = tocItem;

const scrollContainerRef = React.useContext(TOCScrollContainerContext);
React.useLayoutEffect(() => {
if (isActive && linkRef.current && scrollContainerRef?.current) {
const tocItem = linkRef.current;
const tocContainer = scrollContainerRef.current;

if (tocContainer) {
const tocItemTop = tocItem.offsetTop;
const containerTop = tocContainer.scrollTop;
const containerBottom = containerTop + tocContainer.clientHeight;

// Only scroll if the TOC item is outside the viewable area of the container
if (
tocItemTop < containerTop + TOC_ITEM_OFFSET ||
tocItemTop > containerBottom - TOC_ITEM_OFFSET
) {
tocContainer.scrollTo({
top: tocItemTop - TOC_ITEM_OFFSET,
});
}
}
}
}, [isActive, linkRef, scrollContainerRef]);
}
Loading