Skip to content

Commit 57bb146

Browse files
authored
Dynamic TOC height (#3233)
1 parent 4b67fe5 commit 57bb146

File tree

9 files changed

+179
-82
lines changed

9 files changed

+179
-82
lines changed

.changeset/slow-lizards-obey.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": patch
3+
---
4+
5+
Make TOC height dynamic based on visible header and footer elements

packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function AnnouncementBanner(props: {
2525
const style = BANNER_STYLES[announcement.style];
2626

2727
return (
28-
<div className="announcement-banner theme-bold:bg-header-background pt-4 pb-2">
28+
<div id="announcement-banner" className="theme-bold:bg-header-background pt-4 pb-2">
2929
<div className="scroll-nojump">
3030
<div className={tcls('relative', CONTAINER_STYLE)}>
3131
<Tag

packages/gitbook/src/components/Footer/Footer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
2525

2626
return (
2727
<footer
28+
id="site-footer"
2829
className={tcls(
2930
'border-tint-subtle border-t',
3031
// If the footer only contains a mode toggle, we only show it on smaller screens

packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export async function CustomizationRootLayout(props: {
7575
lang={customization.internationalization.locale}
7676
className={tcls(
7777
customization.header.preset === CustomizationHeaderPreset.None
78-
? 'site-header-none'
78+
? null
7979
: 'scroll-pt-[76px]', // Take the sticky header in consideration for the scrolling
8080
customization.styling.corners === CustomizationCorners.Straight
8181
? ' straight-corners'

packages/gitbook/src/components/RootLayout/globals.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,6 @@ html.dark {
161161
color-scheme: dark light;
162162
}
163163

164-
html.announcement-hidden .announcement-banner {
164+
html.announcement-hidden #announcement-banner {
165165
@apply hidden;
166166
}

packages/gitbook/src/components/TableOfContents/TableOfContents.tsx

Lines changed: 88 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { tcls } from '@/lib/tailwind';
66

77
import { PagesList } from './PagesList';
88
import { TOCScrollContainer } from './TOCScroller';
9+
import { TableOfContentsScript } from './TableOfContentsScript';
910
import { Trademark } from './Trademark';
1011

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

1920
return (
20-
<aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar.
21-
data-testid="table-of-contents"
22-
className={tcls(
23-
'group',
24-
'text-sm',
21+
<>
22+
<aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar.
23+
data-testid="table-of-contents"
24+
id="table-of-contents"
25+
className={tcls(
26+
'group',
27+
'text-sm',
2528

26-
'grow-0',
27-
'shrink-0',
28-
'basis-full',
29-
'lg:basis-72',
30-
'page-no-toc:lg:basis-56',
29+
'grow-0',
30+
'shrink-0',
31+
'basis-full',
32+
'lg:basis-72',
33+
'page-no-toc:lg:basis-56',
3134

32-
'relative',
33-
'z-[1]',
34-
'lg:sticky',
35-
// Without header
36-
'lg:top-0',
37-
'lg:h-screen',
35+
'relative',
36+
'z-[1]',
37+
'lg:sticky',
3838

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

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

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

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

61-
'navigation-open:border-b',
62-
'border-tint-subtle'
63-
)}
64-
>
65-
{header && header}
66-
<div // The actual sidebar, either shown with a filled bg or transparent.
67-
className={tcls(
68-
'lg:-ms-5',
69-
'relative flex flex-grow flex-col overflow-hidden border-tint-subtle',
56+
'pt-6',
57+
'pb-4',
58+
'sidebar-filled:lg:pr-6',
59+
'page-no-toc:lg:pr-0',
7060

71-
'sidebar-filled:bg-tint-subtle',
72-
'theme-muted:bg-tint-subtle',
73-
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
74-
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
75-
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
76-
'[html.sidebar-filled.theme-gradient_&]:border',
77-
'page-no-toc:!bg-transparent',
61+
'hidden',
62+
'navigation-open:!flex',
63+
'lg:flex',
64+
'page-no-toc:lg:hidden',
65+
'page-no-toc:xl:flex',
66+
'site-header-none:page-no-toc:lg:flex',
67+
'flex-col',
68+
'gap-4',
7869

79-
'sidebar-filled:rounded-xl',
80-
'straight-corners:rounded-none'
70+
'navigation-open:border-b',
71+
'border-tint-subtle'
8172
)}
8273
>
83-
{innerHeader && <div className="px-5 *:my-4">{innerHeader}</div>}
84-
<TOCScrollContainer // The scrollview inside the sidebar
74+
{header && header}
75+
<div // The actual sidebar, either shown with a filled bg or transparent.
8576
className={tcls(
86-
'flex flex-grow flex-col p-2',
87-
customization.trademark.enabled && 'lg:pb-20',
88-
'lg:gutter-stable overflow-y-auto',
89-
'[&::-webkit-scrollbar]:bg-transparent',
90-
'[&::-webkit-scrollbar-thumb]:bg-transparent',
91-
'group-hover:[&::-webkit-scrollbar]:bg-tint-subtle',
92-
'group-hover:[&::-webkit-scrollbar-thumb]:bg-tint-7',
93-
'group-hover:[&::-webkit-scrollbar-thumb:hover]:bg-tint-8'
77+
'lg:-ms-5',
78+
'relative flex flex-grow flex-col overflow-hidden border-tint-subtle',
79+
80+
'sidebar-filled:bg-tint-subtle',
81+
'theme-muted:bg-tint-subtle',
82+
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
83+
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
84+
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
85+
'[html.sidebar-filled.theme-gradient_&]:border',
86+
'page-no-toc:!bg-transparent',
87+
88+
'sidebar-filled:rounded-xl',
89+
'straight-corners:rounded-none'
9490
)}
9591
>
96-
<PagesList
97-
rootPages={pages}
98-
pages={pages}
99-
context={context}
100-
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
101-
/>
102-
{customization.trademark.enabled ? (
103-
<Trademark
104-
space={space}
105-
customization={customization}
106-
placement={SiteInsightsTrademarkPlacement.Sidebar}
92+
{innerHeader && <div className="px-5 *:my-4">{innerHeader}</div>}
93+
<TOCScrollContainer // The scrollview inside the sidebar
94+
className={tcls(
95+
'flex flex-grow flex-col p-2',
96+
customization.trademark.enabled && 'lg:pb-20',
97+
'lg:gutter-stable overflow-y-auto',
98+
'[&::-webkit-scrollbar]:bg-transparent',
99+
'[&::-webkit-scrollbar-thumb]:bg-transparent',
100+
'group-hover:[&::-webkit-scrollbar]:bg-tint-subtle',
101+
'group-hover:[&::-webkit-scrollbar-thumb]:bg-tint-7',
102+
'group-hover:[&::-webkit-scrollbar-thumb:hover]:bg-tint-8'
103+
)}
104+
>
105+
<PagesList
106+
rootPages={pages}
107+
pages={pages}
108+
context={context}
109+
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
107110
/>
108-
) : null}
109-
</TOCScrollContainer>
110-
</div>
111-
</aside>
111+
{customization.trademark.enabled ? (
112+
<Trademark
113+
space={space}
114+
customization={customization}
115+
placement={SiteInsightsTrademarkPlacement.Sidebar}
116+
/>
117+
) : null}
118+
</TOCScrollContainer>
119+
</div>
120+
</aside>
121+
<TableOfContentsScript />
122+
</>
112123
);
113124
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
5+
/**
6+
* Adjusts TableOfContents height based on visible elements
7+
*/
8+
export function TableOfContentsScript() {
9+
useEffect(() => {
10+
const root = document.documentElement;
11+
12+
// Calculate and set TOC dimensions
13+
const updateTocLayout = () => {
14+
// Get key elements
15+
const header = document.getElementById('site-header');
16+
const banner = document.getElementById('announcement-banner');
17+
const footer = document.getElementById('site-footer');
18+
19+
// Set sticky top position based on header
20+
const headerHeight = header?.offsetHeight ?? 0;
21+
root.style.setProperty('--toc-top-offset', `${headerHeight}px`);
22+
23+
// Start with full viewport height minus header
24+
let height = window.innerHeight - headerHeight;
25+
26+
// Subtract visible banner (if any)
27+
if (banner && window.getComputedStyle(banner).display !== 'none') {
28+
const bannerRect = banner.getBoundingClientRect();
29+
if (bannerRect.height > 0 && bannerRect.bottom > 0) {
30+
height -= Math.min(bannerRect.height, bannerRect.bottom);
31+
}
32+
}
33+
34+
// Subtract visible footer (if any)
35+
if (footer) {
36+
const footerRect = footer.getBoundingClientRect();
37+
if (footerRect.top < window.innerHeight) {
38+
height -= Math.min(footerRect.height, window.innerHeight - footerRect.top);
39+
}
40+
}
41+
42+
// Update height
43+
root.style.setProperty('--toc-height', `${height}px`);
44+
};
45+
46+
// Initial update
47+
updateTocLayout();
48+
49+
// Let the browser handle scroll throttling naturally
50+
window.addEventListener('scroll', updateTocLayout, { passive: true });
51+
window.addEventListener('resize', updateTocLayout, { passive: true });
52+
53+
// Use MutationObserver for DOM changes
54+
const observer = new MutationObserver(() => {
55+
requestAnimationFrame(updateTocLayout);
56+
});
57+
58+
// Only observe what matters
59+
observer.observe(document.documentElement, {
60+
subtree: true,
61+
attributes: true,
62+
attributeFilter: ['style', 'class'],
63+
});
64+
65+
return () => {
66+
observer.disconnect();
67+
window.removeEventListener('scroll', updateTocLayout);
68+
window.removeEventListener('resize', updateTocLayout);
69+
};
70+
}, []);
71+
72+
return null;
73+
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
export * from './TableOfContents';
1+
export { TableOfContents } from './TableOfContents';
2+
export { PagesList } from './PagesList';
3+
export { TOCScrollContainer } from './TOCScroller';
4+
export { Trademark } from './Trademark';

packages/gitbook/tailwind.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,12 +458,16 @@ const config: Config = {
458458
/**
459459
* Variant when a header is displayed.
460460
*/
461-
addVariant('site-header-none', 'html.site-header-none &');
461+
addVariant('site-header-none', 'body:not(:has(#site-header:not(.mobile-only))) &');
462462
addVariant('site-header', 'body:has(#site-header:not(.mobile-only)) &');
463463
addVariant('site-header-sections', [
464464
'body:has(#site-header:not(.mobile-only) #sections) &',
465465
'body:has(.page-no-toc):has(#site-header:not(.mobile-only) #variants) &',
466466
]);
467+
addVariant(
468+
'announcement',
469+
'html:not(.announcement-hidden):has(#announcement-banner) &'
470+
);
467471

468472
const customisationVariants = {
469473
// Sidebar styles

0 commit comments

Comments
 (0)