Skip to content

Commit 5d72b35

Browse files
authored
Improve animation of section tabs (#2553)
1 parent ff50ac2 commit 5d72b35

File tree

5 files changed

+60
-56
lines changed

5 files changed

+60
-56
lines changed

.changeset/khaki-badgers-knock.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+
Smoother tab transition for sections

packages/gitbook/src/app/(site)/fetch.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface PageIdParams {
2121
pageId: string;
2222
}
2323

24-
type SectionsList = { list: SiteSection[]; section: SiteSection };
24+
export type SectionsList = { list: SiteSection[]; section: SiteSection; index: number };
2525

2626
/**
2727
* Fetch all the data needed to render the content layout.
@@ -70,7 +70,7 @@ export async function fetchContentData() {
7070
function parseSiteSectionsList(siteSectionId: string, sections: SiteSection[]) {
7171
const section = sections.find((section) => section.id === siteSectionId);
7272
assert(sectionIsDefined(section), 'A section must be defined when there are multiple sections');
73-
return { list: sections, section } satisfies SectionsList;
73+
return { list: sections, section, index: sections.indexOf(section) } satisfies SectionsList;
7474
}
7575

7676
function sectionIsDefined(section?: SiteSection): section is NonNullable<SiteSection> {

packages/gitbook/src/components/Header/Header.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
import {
2-
CustomizationSettings,
3-
Site,
4-
SiteCustomizationSettings,
5-
SiteSection,
6-
Space,
7-
} from '@gitbook/api';
1+
import { CustomizationSettings, Site, SiteCustomizationSettings, Space } from '@gitbook/api';
82
import { CustomizationHeaderPreset } from '@gitbook/api';
93
import { Suspense } from 'react';
104

5+
import type { SectionsList } from '@/app/(site)/fetch';
116
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
127
import { t, getSpaceLanguage } from '@/intl/server';
138
import { ContentRefContext } from '@/lib/references';
@@ -27,7 +22,7 @@ export function Header(props: {
2722
space: Space;
2823
site: Site | null;
2924
spaces: Space[];
30-
sections: { list: SiteSection[]; section: SiteSection } | null;
25+
sections: SectionsList | null;
3126
context: ContentRefContext;
3227
customization: CustomizationSettings | SiteCustomizationSettings;
3328
withTopHeader?: boolean;
@@ -153,7 +148,7 @@ export function Header(props: {
153148
)}
154149
>
155150
<div className={tcls(CONTAINER_STYLE)}>
156-
<SiteSectionTabs sections={sections.list} section={sections.section} />
151+
<SiteSectionTabs {...sections} />
157152
</div>
158153
</div>
159154
) : null}

packages/gitbook/src/components/SiteSectionTabs/SiteSectionTabs.tsx

Lines changed: 47 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ import { tcls } from '@/lib/tailwind';
88
import { Button, Link } from '../primitives';
99

1010
/**
11-
* A set of tabs representing site sections for multi-section sites
11+
* A set of navigational tabs representing site sections for multi-section sites
1212
*/
13-
export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteSection }) {
14-
const { sections, section: currentSection } = props;
13+
export function SiteSectionTabs(props: {
14+
list: SiteSection[];
15+
section: SiteSection;
16+
index: number;
17+
}) {
18+
const { list: sections, section: currentSection, index: currentIndex } = props;
1519

1620
const tabs = sections.map((section) => ({
1721
id: section.id,
@@ -22,34 +26,33 @@ export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteS
2226
const currentTabRef = React.useRef<HTMLAnchorElement>(null);
2327
const navRef = React.useRef<HTMLDivElement>(null);
2428

25-
const [currentIndex, setCurrentIndex] = React.useState(
26-
sections.findIndex((section) => section.id === currentSection?.id),
27-
);
2829
const [tabDimensions, setTabDimensions] = React.useState<{
2930
left: number;
3031
width: number;
3132
} | null>(null);
3233

33-
React.useEffect(() => {
34+
const updateTabDimensions = React.useCallback(() => {
3435
if (currentTabRef.current && navRef.current) {
3536
const rect = currentTabRef.current.getBoundingClientRect();
3637
const navRect = navRef.current.getBoundingClientRect();
3738
setTabDimensions({ left: rect.left - navRect.left, width: rect.width });
3839
}
39-
}, [currentIndex]);
40+
}, []);
41+
42+
React.useEffect(() => {
43+
updateTabDimensions();
44+
}, [currentIndex, updateTabDimensions]);
4045

4146
React.useLayoutEffect(() => {
42-
function onResize() {
43-
if (currentTabRef.current && navRef.current) {
44-
const rect = currentTabRef.current.getBoundingClientRect();
45-
const navRect = navRef.current.getBoundingClientRect();
46-
setTabDimensions({ left: rect.left - navRect.left, width: rect.width });
47-
}
48-
}
49-
window.addEventListener('resize', onResize);
50-
() => window.removeEventListener('resize', onResize);
51-
}, []);
47+
window.addEventListener('load', updateTabDimensions);
48+
window.addEventListener('resize', updateTabDimensions);
49+
() => {
50+
window.removeEventListener('resize', updateTabDimensions);
51+
window.removeEventListener('load', updateTabDimensions);
52+
};
53+
}, [updateTabDimensions]);
5254

55+
const opacity = Boolean(tabDimensions) ? 1 : 0.0;
5356
const scale = (tabDimensions?.width ?? 0) * 0.01;
5457
const startPos = `${tabDimensions?.left ?? 0}px`;
5558

@@ -62,6 +65,7 @@ export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteS
6265
className="flex flex-nowrap items-center max-w-screen mb-px"
6366
style={
6467
{
68+
'--tab-opacity': `${opacity}`,
6569
'--tab-scale': `${scale}`,
6670
'--tab-start': `${startPos}`,
6771
} as React.CSSProperties
@@ -80,9 +84,12 @@ export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteS
8084
'after:absolute',
8185
'after:-bottom-px',
8286
'after:left-0',
87+
'after:opacity-[--tab-opacity]',
8388
'after:scale-x-[--tab-scale]',
84-
'after:transition-transform',
89+
'after:[transition:_opacity_150ms_25ms,transform_150ms]',
90+
'after:motion-reduce:transition-none',
8591
'after:translate-x-[var(--tab-start)]',
92+
'after:will-change-transform',
8693
'after:h-0.5',
8794
'after:w-[100px]',
8895
'after:bg-primary',
@@ -97,7 +104,6 @@ export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteS
97104
label={tab.label}
98105
href={tab.path}
99106
ref={currentIndex === index ? currentTabRef : null}
100-
onClick={() => setCurrentIndex(index)}
101107
/>
102108
))}
103109
</div>
@@ -109,29 +115,27 @@ export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteS
109115
/**
110116
* The tab item - a link to a site section
111117
*/
112-
const Tab = React.forwardRef<
113-
HTMLSpanElement,
114-
{ active: boolean; href: string; label: string; onClick?: () => void }
115-
>(function Tab(props, ref) {
116-
const { active, href, label, onClick } = props;
117-
return (
118-
<Link
119-
className={tcls(
120-
'px-3 py-1 my-2 rounded straight-corners:rounded-none transition-colors',
121-
active && 'text-primary dark:text-primary-400',
122-
!active &&
123-
'text-dark/8 hover:bg-dark/1 hover:text-dark/9 dark:text-light/8 dark:hover:bg-light/2 dark:hover:text-light/9',
124-
)}
125-
role="tab"
126-
href={href}
127-
onClick={onClick}
128-
>
129-
<span ref={ref} className={tcls('inline-flex w-full truncate')}>
130-
{label}
131-
</span>
132-
</Link>
133-
);
134-
});
118+
const Tab = React.forwardRef<HTMLSpanElement, { active: boolean; href: string; label: string }>(
119+
function Tab(props, ref) {
120+
const { active, href, label } = props;
121+
return (
122+
<Link
123+
className={tcls(
124+
'px-3 py-1 my-2 rounded straight-corners:rounded-none transition-colors',
125+
active && 'text-primary dark:text-primary-400',
126+
!active &&
127+
'text-dark/8 hover:bg-dark/1 hover:text-dark/9 dark:text-light/8 dark:hover:bg-light/2 dark:hover:text-light/9',
128+
)}
129+
role="tab"
130+
href={href}
131+
>
132+
<span ref={ref} className={tcls('inline-flex w-full truncate')}>
133+
{label}
134+
</span>
135+
</Link>
136+
);
137+
},
138+
);
135139

136140
/**
137141
* Dropdown trigger for when there are too many sections to show them all

packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import {
66
RevisionPageGroup,
77
Site,
88
SiteCustomizationSettings,
9-
SiteSection,
109
Space,
1110
} from '@gitbook/api';
1211
import React from 'react';
1312

13+
import { SectionsList } from '@/app/(site)/fetch';
1414
import { Footer } from '@/components/Footer';
1515
import { CompactHeader, Header } from '@/components/Header';
1616
import { CONTAINER_STYLE } from '@/components/layout';
@@ -31,7 +31,7 @@ export function SpaceLayout(props: {
3131
contentTarget: ContentTarget;
3232
space: Space;
3333
site: Site | null;
34-
sections: { list: SiteSection[]; section: SiteSection } | null;
34+
sections: SectionsList | null;
3535
spaces: Space[];
3636
customization: CustomizationSettings | SiteCustomizationSettings;
3737
pages: Revision['pages'];

0 commit comments

Comments
 (0)