Skip to content

Dynamic TOC height #3233

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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slow-lizards-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Make TOC height dynamic based on visible header and footer elements
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function AnnouncementBanner(props: {
const style = BANNER_STYLES[announcement.style];

return (
<div className="announcement-banner theme-bold:bg-header-background pt-4 pb-2">
<div id="announcement-banner" className="theme-bold:bg-header-background pt-4 pb-2">
<div className="scroll-nojump">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
Expand Down
1 change: 1 addition & 0 deletions packages/gitbook/src/components/Footer/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function Footer(props: { context: GitBookSiteContext }) {

return (
<footer
id="site-footer"
className={tcls(
'border-tint-subtle border-t',
// If the footer only contains a mode toggle, we only show it on smaller screens
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export async function CustomizationRootLayout(props: {
lang={customization.internationalization.locale}
className={tcls(
customization.header.preset === CustomizationHeaderPreset.None
? 'site-header-none'
? null
: 'scroll-pt-[76px]', // Take the sticky header in consideration for the scrolling
customization.styling.corners === CustomizationCorners.Straight
? ' straight-corners'
Expand Down
2 changes: 1 addition & 1 deletion packages/gitbook/src/components/RootLayout/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,6 @@ html.dark {
color-scheme: dark light;
}

html.announcement-hidden .announcement-banner {
html.announcement-hidden #announcement-banner {
@apply hidden;
}
165 changes: 88 additions & 77 deletions packages/gitbook/src/components/TableOfContents/TableOfContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { tcls } from '@/lib/tailwind';

import { PagesList } from './PagesList';
import { TOCScrollContainer } from './TOCScroller';
import { TableOfContentsScript } from './TableOfContentsScript';
import { Trademark } from './Trademark';

export function TableOfContents(props: {
Expand All @@ -17,97 +18,107 @@ export function TableOfContents(props: {
const { space, customization, pages } = context;

return (
<aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar.
data-testid="table-of-contents"
className={tcls(
'group',
'text-sm',
<>
<aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar.
data-testid="table-of-contents"
id="table-of-contents"
className={tcls(
'group',
'text-sm',

'grow-0',
'shrink-0',
'basis-full',
'lg:basis-72',
'page-no-toc:lg:basis-56',
'grow-0',
'shrink-0',
'basis-full',
'lg:basis-72',
'page-no-toc:lg:basis-56',

'relative',
'z-[1]',
'lg:sticky',
// Without header
'lg:top-0',
'lg:h-screen',
'relative',
'z-[1]',
'lg:sticky',

// With header
'site-header:lg:top-16',
'site-header:lg:h-[calc(100vh_-_4rem)]',
// Server-side static positioning
'lg:top-0',
'lg:h-screen',
'announcement:lg:h-[calc(100vh-4.25rem)]',

// With header and sections
'site-header-sections:lg:top-[6.75rem]',
'site-header-sections:lg:h-[calc(100vh_-_6.75rem)]',
'site-header:lg:top-16',
'site-header:lg:h-[calc(100vh-4rem)]',
'announcement:site-header:lg:h-[calc(100vh-4rem-4.25rem)]',

'pt-6',
'pb-4',
'sidebar-filled:lg:pr-6',
'page-no-toc:lg:pr-0',
'site-header-sections:lg:top-[6.75rem]',
'site-header-sections:lg:h-[calc(100vh-6.75rem)]',
'announcement:site-header-sections:lg:h-[calc(100vh-6.75rem-4.25rem)]',

'hidden',
'navigation-open:!flex',
'lg:flex',
'page-no-toc:lg:hidden',
'page-no-toc:xl:flex',
'site-header-none:page-no-toc:lg:flex',
'flex-col',
'gap-4',
// Client-side dynamic positioning (CSS vars applied by script)
'[html[style*="--toc-top-offset"]_&]:lg:!top-[var(--toc-top-offset)]',
'[html[style*="--toc-height"]_&]:lg:!h-[var(--toc-height)]',

'navigation-open:border-b',
'border-tint-subtle'
)}
>
{header && header}
<div // The actual sidebar, either shown with a filled bg or transparent.
className={tcls(
'lg:-ms-5',
'relative flex flex-grow flex-col overflow-hidden border-tint-subtle',
'pt-6',
'pb-4',
'sidebar-filled:lg:pr-6',
'page-no-toc:lg:pr-0',

'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'[html.sidebar-filled.theme-gradient_&]:border',
'page-no-toc:!bg-transparent',
'hidden',
'navigation-open:!flex',
'lg:flex',
'page-no-toc:lg:hidden',
'page-no-toc:xl:flex',
'site-header-none:page-no-toc:lg:flex',
'flex-col',
'gap-4',

'sidebar-filled:rounded-xl',
'straight-corners:rounded-none'
'navigation-open:border-b',
'border-tint-subtle'
)}
>
{innerHeader && <div className="px-5 *:my-4">{innerHeader}</div>}
<TOCScrollContainer // The scrollview inside the sidebar
{header && header}
<div // The actual sidebar, either shown with a filled bg or transparent.
className={tcls(
'flex flex-grow flex-col p-2',
customization.trademark.enabled && 'lg:pb-20',
'lg:gutter-stable overflow-y-auto',
'[&::-webkit-scrollbar]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:bg-transparent',
'group-hover:[&::-webkit-scrollbar]:bg-tint-subtle',
'group-hover:[&::-webkit-scrollbar-thumb]:bg-tint-7',
'group-hover:[&::-webkit-scrollbar-thumb:hover]:bg-tint-8'
'lg:-ms-5',
'relative flex flex-grow flex-col overflow-hidden border-tint-subtle',

'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'[html.sidebar-filled.theme-gradient_&]:border',
'page-no-toc:!bg-transparent',

'sidebar-filled:rounded-xl',
'straight-corners:rounded-none'
)}
>
<PagesList
rootPages={pages}
pages={pages}
context={context}
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
/>
{customization.trademark.enabled ? (
<Trademark
space={space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Sidebar}
{innerHeader && <div className="px-5 *:my-4">{innerHeader}</div>}
<TOCScrollContainer // The scrollview inside the sidebar
className={tcls(
'flex flex-grow flex-col p-2',
customization.trademark.enabled && 'lg:pb-20',
'lg:gutter-stable overflow-y-auto',
'[&::-webkit-scrollbar]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:bg-transparent',
'group-hover:[&::-webkit-scrollbar]:bg-tint-subtle',
'group-hover:[&::-webkit-scrollbar-thumb]:bg-tint-7',
'group-hover:[&::-webkit-scrollbar-thumb:hover]:bg-tint-8'
)}
>
<PagesList
rootPages={pages}
pages={pages}
context={context}
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
/>
) : null}
</TOCScrollContainer>
</div>
</aside>
{customization.trademark.enabled ? (
<Trademark
space={space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Sidebar}
/>
) : null}
</TOCScrollContainer>
</div>
</aside>
<TableOfContentsScript />
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client';

import { useEffect } from 'react';

/**
* Adjusts TableOfContents height based on visible elements
*/
export function TableOfContentsScript() {
useEffect(() => {
const root = document.documentElement;

// Calculate and set TOC dimensions
const updateTocLayout = () => {
// Get key elements
const header = document.getElementById('site-header');
const banner = document.getElementById('announcement-banner');
const footer = document.getElementById('site-footer');

// Set sticky top position based on header
const headerHeight = header?.offsetHeight ?? 0;
root.style.setProperty('--toc-top-offset', `${headerHeight}px`);

// Start with full viewport height minus header
let height = window.innerHeight - headerHeight;

// Subtract visible banner (if any)
if (banner && banner?.computedStyleMap().get('display') !== 'none') {
const bannerRect = banner.getBoundingClientRect();
if (bannerRect.height > 0 && bannerRect.bottom > 0) {
height -= Math.min(bannerRect.height, bannerRect.bottom);
}
}

// Subtract visible footer (if any)
if (footer) {
const footerRect = footer.getBoundingClientRect();
if (footerRect.top < window.innerHeight) {
height -= Math.min(footerRect.height, window.innerHeight - footerRect.top);
}
}

// Update height
root.style.setProperty('--toc-height', `${height}px`);
};

// Initial update
updateTocLayout();

// Let the browser handle scroll throttling naturally
window.addEventListener('scroll', updateTocLayout, { passive: true });
window.addEventListener('resize', updateTocLayout, { passive: true });

// Use MutationObserver for DOM changes
const observer = new MutationObserver(() => {
requestAnimationFrame(updateTocLayout);
});

// Only observe what matters
observer.observe(document.documentElement, {
subtree: true,
attributes: true,
attributeFilter: ['style', 'class'],
});

return () => {
observer.disconnect();
window.removeEventListener('scroll', updateTocLayout);
window.removeEventListener('resize', updateTocLayout);
};
}, []);

return null;
}
5 changes: 4 additions & 1 deletion packages/gitbook/src/components/TableOfContents/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export * from './TableOfContents';
export { TableOfContents } from './TableOfContents';
export { PagesList } from './PagesList';
export { TOCScrollContainer } from './TOCScroller';
export { Trademark } from './Trademark';
6 changes: 5 additions & 1 deletion packages/gitbook/tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,12 +458,16 @@ const config: Config = {
/**
* Variant when a header is displayed.
*/
addVariant('site-header-none', 'html.site-header-none &');
addVariant('site-header-none', 'body:not(:has(#site-header:not(.mobile-only))) &');
addVariant('site-header', 'body:has(#site-header:not(.mobile-only)) &');
addVariant('site-header-sections', [
'body:has(#site-header:not(.mobile-only) #sections) &',
'body:has(.page-no-toc):has(#site-header:not(.mobile-only) #variants) &',
]);
addVariant(
'announcement',
'html:not(.announcement-hidden):has(#announcement-banner) &'
);

const customisationVariants = {
// Sidebar styles
Expand Down