diff --git a/packages/docusaurus-theme-classic/src/theme/BackToTopButton/index.tsx b/packages/docusaurus-theme-classic/src/theme/BackToTopButton/index.tsx
index a495f884b3..feba4f36ea 100644
--- a/packages/docusaurus-theme-classic/src/theme/BackToTopButton/index.tsx
+++ b/packages/docusaurus-theme-classic/src/theme/BackToTopButton/index.tsx
@@ -9,10 +9,9 @@ import React, {useRef, useState} from 'react';
import clsx from 'clsx';
import {useLocation} from '@docusaurus/router';
import {translate} from '@docusaurus/Translate';
-import useScrollPosition from '@theme/hooks/useScrollPosition';
import styles from './styles.module.css';
-import {ThemeClassNames} from '@docusaurus/theme-common';
+import {ThemeClassNames, useScrollPosition} from '@docusaurus/theme-common';
const threshold = 300;
diff --git a/packages/docusaurus-theme-classic/src/theme/DocSidebar/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocSidebar/index.tsx
index a517f6d08c..281b3df330 100644
--- a/packages/docusaurus-theme-classic/src/theme/DocSidebar/index.tsx
+++ b/packages/docusaurus-theme-classic/src/theme/DocSidebar/index.tsx
@@ -13,9 +13,9 @@ import {
MobileSecondaryMenuFiller,
MobileSecondaryMenuComponent,
ThemeClassNames,
+ useScrollPosition,
} from '@docusaurus/theme-common';
import useWindowSize from '@theme/hooks/useWindowSize';
-import useScrollPosition from '@theme/hooks/useScrollPosition';
import Logo from '@theme/Logo';
import IconArrow from '@theme/IconArrow';
import {translate} from '@docusaurus/Translate';
diff --git a/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx b/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx
index 7e98dacb2e..34cb7a2f15 100644
--- a/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx
+++ b/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx
@@ -12,6 +12,7 @@ import {
AnnouncementBarProvider,
DocsPreferredVersionContextProvider,
MobileSecondaryMenuProvider,
+ ScrollControllerProvider,
} from '@docusaurus/theme-common';
import type {Props} from '@theme/LayoutProviders';
@@ -20,11 +21,13 @@ export default function LayoutProviders({children}: Props): JSX.Element {
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx
index 0c35907f4f..9c58869c4b 100644
--- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx
+++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx
@@ -8,6 +8,7 @@
import React, {useState, cloneElement, Children, ReactElement} from 'react';
import useIsBrowser from '@docusaurus/useIsBrowser';
import useUserPreferencesContext from '@theme/hooks/useUserPreferencesContext';
+import {useScrollPositionBlocker} from '@docusaurus/theme-common';
import type {Props} from '@theme/Tabs';
import type {Props as TabItemProps} from '@theme/TabItem';
@@ -15,13 +16,6 @@ import clsx from 'clsx';
import styles from './styles.module.css';
-function isInViewport(element: HTMLElement): boolean {
- const {top, left, bottom, right} = element.getBoundingClientRect();
- const {innerHeight, innerWidth} = window;
-
- return top >= 0 && right <= innerWidth && bottom <= innerHeight && left >= 0;
-}
-
function TabsComponent(props: Props): JSX.Element {
const {
lazy,
@@ -50,6 +44,8 @@ function TabsComponent(props: Props): JSX.Element {
const {tabGroupChoices, setTabGroupChoices} = useUserPreferencesContext();
const [selectedValue, setSelectedValue] = useState(defaultValue);
const tabRefs: (HTMLLIElement | null)[] = [];
+ const {blockElementScrollPositionUntilNextRender} =
+ useScrollPositionBlocker();
if (groupId != null) {
const relevantTabGroupChoice = tabGroupChoices[groupId];
@@ -65,31 +61,17 @@ function TabsComponent(props: Props): JSX.Element {
const handleTabChange = (
event: React.FocusEvent | React.MouseEvent,
) => {
- const selectedTab = event.currentTarget;
- const selectedTabIndex = tabRefs.indexOf(selectedTab);
- const selectedTabValue = values[selectedTabIndex].value;
+ const newTab = event.currentTarget;
+ const newTabIndex = tabRefs.indexOf(newTab);
+ const newTabValue = values[newTabIndex].value;
- setSelectedValue(selectedTabValue);
+ if (newTabValue !== selectedValue) {
+ blockElementScrollPositionUntilNextRender(newTab);
+ setSelectedValue(newTabValue);
- if (groupId != null) {
- setTabGroupChoices(groupId, selectedTabValue);
-
- setTimeout(() => {
- if (isInViewport(selectedTab)) {
- return;
- }
-
- selectedTab.scrollIntoView({
- block: 'center',
- behavior: 'smooth',
- });
-
- selectedTab.classList.add(styles.tabItemActive);
- setTimeout(
- () => selectedTab.classList.remove(styles.tabItemActive),
- 2000,
- );
- }, 150);
+ if (groupId != null) {
+ setTabGroupChoices(groupId, newTabValue);
+ }
}
};
diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Tabs/styles.module.css
index f2e834d3aa..5f2274d558 100644
--- a/packages/docusaurus-theme-classic/src/theme/Tabs/styles.module.css
+++ b/packages/docusaurus-theme-classic/src/theme/Tabs/styles.module.css
@@ -8,16 +8,3 @@
.tabItem {
margin-top: 0 !important;
}
-
-.tabItemActive {
- animation: blink 0.5s ease-in-out 5;
-}
-
-@keyframes blink {
- 0% {
- background-color: var(--ifm-hover-overlay);
- }
- 100% {
- background-color: rgba(0, 0, 0, 0);
- }
-}
diff --git a/packages/docusaurus-theme-classic/src/theme/hooks/useHideableNavbar.ts b/packages/docusaurus-theme-classic/src/theme/hooks/useHideableNavbar.ts
index 94b14927f8..954549524f 100644
--- a/packages/docusaurus-theme-classic/src/theme/hooks/useHideableNavbar.ts
+++ b/packages/docusaurus-theme-classic/src/theme/hooks/useHideableNavbar.ts
@@ -7,8 +7,7 @@
import {useState, useCallback, useEffect, useRef} from 'react';
import {useLocation} from '@docusaurus/router';
-import useScrollPosition from '@theme/hooks/useScrollPosition';
-import {useLocationChange} from '@docusaurus/theme-common';
+import {useLocationChange, useScrollPosition} from '@docusaurus/theme-common';
import type {useHideableNavbarReturns} from '@theme/hooks/useHideableNavbar';
const useHideableNavbar = (hideOnScroll: boolean): useHideableNavbarReturns => {
diff --git a/packages/docusaurus-theme-classic/src/theme/hooks/useScrollPosition.ts b/packages/docusaurus-theme-classic/src/theme/hooks/useScrollPosition.ts
deleted file mode 100644
index fba41cb8e0..0000000000
--- a/packages/docusaurus-theme-classic/src/theme/hooks/useScrollPosition.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * Copyright (c) Facebook, Inc. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- */
-
-import {useEffect, useRef} from 'react';
-import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
-import type {ScrollPosition} from '@theme/hooks/useScrollPosition';
-
-const getScrollPosition = (): ScrollPosition | null => {
- return ExecutionEnvironment.canUseDOM
- ? {
- scrollX: window.pageXOffset,
- scrollY: window.pageYOffset,
- }
- : null;
-};
-
-const useScrollPosition = (
- effect: (
- position: ScrollPosition,
- lastPosition: ScrollPosition | null,
- ) => void,
- deps = [],
-): void => {
- const lastPositionRef = useRef(getScrollPosition());
-
- const handleScroll = () => {
- const currentPosition = getScrollPosition()!;
-
- if (effect) {
- effect(currentPosition, lastPositionRef.current);
- }
-
- lastPositionRef.current = currentPosition;
- };
-
- useEffect(() => {
- const opts: AddEventListenerOptions & EventListenerOptions = {
- passive: true,
- };
-
- handleScroll();
- window.addEventListener('scroll', handleScroll, opts);
-
- return () => window.removeEventListener('scroll', handleScroll, opts);
- }, deps);
-};
-
-export default useScrollPosition;
diff --git a/packages/docusaurus-theme-classic/src/types.d.ts b/packages/docusaurus-theme-classic/src/types.d.ts
index d7940a5867..a59ecc3791 100644
--- a/packages/docusaurus-theme-classic/src/types.d.ts
+++ b/packages/docusaurus-theme-classic/src/types.d.ts
@@ -219,19 +219,6 @@ declare module '@theme/hooks/usePrismTheme' {
export default usePrismTheme;
}
-declare module '@theme/hooks/useScrollPosition' {
- export type ScrollPosition = {scrollX: number; scrollY: number};
-
- const useScrollPosition: (
- effect: (
- position: ScrollPosition,
- lastPosition: ScrollPosition | null,
- ) => void,
- deps?: unknown[],
- ) => void;
- export default useScrollPosition;
-}
-
declare module '@theme/hooks/useTabGroupChoice' {
export type useTabGroupChoiceReturns = {
readonly tabGroupChoices: {readonly [groupId: string]: string};
diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts
index 7ce4453dd5..03f349a9a0 100644
--- a/packages/docusaurus-theme-common/src/index.ts
+++ b/packages/docusaurus-theme-common/src/index.ts
@@ -79,3 +79,10 @@ export {default as useTOCHighlight} from './utils/useTOCHighlight';
export type {TOCHighlightConfig} from './utils/useTOCHighlight';
export {useTOCFilter} from './utils/tocUtils';
+
+export {
+ ScrollControllerProvider,
+ useScrollController,
+ useScrollPosition,
+ useScrollPositionBlocker,
+} from './utils/scrollUtils';
diff --git a/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx
new file mode 100644
index 0000000000..9c54f405ae
--- /dev/null
+++ b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx
@@ -0,0 +1,230 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, {
+ createContext,
+ ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+} from 'react';
+import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
+
+/**
+ * We need a way to update the scroll position while ignoring scroll events
+ * without affecting Navbar/BackToTop visibility
+ *
+ * This API permits to temporarily disable/ignore scroll events
+ * Motivated by https://github.com/facebook/docusaurus/pull/5618
+ */
+type ScrollController = {
+ /**
+ * A boolean ref tracking whether scroll events are enabled
+ */
+ scrollEventsEnabledRef: React.MutableRefObject;
+ /**
+ * Enables scroll events in `useScrollPosition`
+ */
+ enableScrollEvents: () => void;
+ /**
+ * Disables scroll events in `useScrollPosition`
+ */
+ disableScrollEvents: () => void;
+};
+
+function useScrollControllerContextValue(): ScrollController {
+ const scrollEventsEnabledRef = useRef(true);
+
+ return useMemo(
+ () => ({
+ scrollEventsEnabledRef,
+ enableScrollEvents: () => {
+ scrollEventsEnabledRef.current = true;
+ },
+ disableScrollEvents: () => {
+ scrollEventsEnabledRef.current = false;
+ },
+ }),
+ [],
+ );
+}
+
+const ScrollMonitorContext = createContext(
+ undefined,
+);
+
+export function ScrollControllerProvider({
+ children,
+}: {
+ children: ReactNode;
+}): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useScrollController(): ScrollController {
+ const context = useContext(ScrollMonitorContext);
+ if (context == null) {
+ throw new Error(
+ '"useScrollController" is used but no context provider was found in the React tree.',
+ );
+ }
+ return context;
+}
+
+const getScrollPosition = (): ScrollPosition | null => {
+ return ExecutionEnvironment.canUseDOM
+ ? {
+ scrollX: window.pageXOffset,
+ scrollY: window.pageYOffset,
+ }
+ : null;
+};
+
+type ScrollPosition = {scrollX: number; scrollY: number};
+
+export function useScrollPosition(
+ effect: (
+ position: ScrollPosition,
+ lastPosition: ScrollPosition | null,
+ ) => void,
+ deps: unknown[] = [],
+): void {
+ const {scrollEventsEnabledRef} = useScrollController();
+ const lastPositionRef = useRef(getScrollPosition());
+
+ const handleScroll = () => {
+ if (!scrollEventsEnabledRef.current) {
+ return;
+ }
+ const currentPosition = getScrollPosition()!;
+
+ if (effect) {
+ effect(currentPosition, lastPositionRef.current);
+ }
+
+ lastPositionRef.current = currentPosition;
+ };
+
+ useEffect(() => {
+ const opts: AddEventListenerOptions & EventListenerOptions = {
+ passive: true,
+ };
+
+ handleScroll();
+ window.addEventListener('scroll', handleScroll, opts);
+
+ return () => window.removeEventListener('scroll', handleScroll, opts);
+ }, deps);
+}
+
+type UseScrollPositionSaver = {
+ /**
+ * Measure the top of an element, and store the details
+ */
+ save: (elem: HTMLElement) => void;
+ /**
+ * Restore the page position to keep the stored element's position from
+ * the top of the viewport, and remove the stored details
+ */
+ restore: () => {restored: boolean};
+};
+
+function useScrollPositionSaver(): UseScrollPositionSaver {
+ const lastElementRef = useRef<{elem: HTMLElement | null; top: number}>({
+ elem: null,
+ top: 0,
+ });
+
+ const save = useCallback((elem: HTMLElement) => {
+ lastElementRef.current = {
+ elem,
+ top: elem.getBoundingClientRect().top,
+ };
+ }, []);
+
+ const restore = useCallback(() => {
+ const {
+ current: {elem, top},
+ } = lastElementRef;
+ if (!elem) {
+ return {restored: false};
+ }
+ const newTop = elem.getBoundingClientRect().top;
+ const heightDiff = newTop - top;
+ if (heightDiff) {
+ window.scrollBy({left: 0, top: heightDiff});
+ }
+ lastElementRef.current = {elem: null, top: 0};
+
+ return {restored: heightDiff !== 0};
+ }, []);
+
+ return useMemo(() => ({save, restore}), []);
+}
+
+type UseScrollPositionBlockerReturn = {
+ blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void;
+};
+
+/**
+ * This hook permits to "block" the scroll position of a dom element
+ * The idea is that we should be able to update DOM content above this element
+ * but the screen position of this element should not change
+ *
+ * Feature motivated by the Tabs groups:
+ * clicking on a tab may affect tabs of the same group upper in the tree
+ * Yet to avoid a bad UX, the clicked tab must remain under the user mouse!
+ * See GIF here: https://github.com/facebook/docusaurus/pull/5618
+ */
+export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn {
+ const scrollController = useScrollController();
+ const scrollPositionSaver = useScrollPositionSaver();
+
+ const nextLayoutEffectCallbackRef = useRef<(() => void) | undefined>(
+ undefined,
+ );
+
+ const blockElementScrollPositionUntilNextRender = useCallback(
+ (el: HTMLElement) => {
+ scrollPositionSaver.save(el);
+ scrollController.disableScrollEvents();
+ nextLayoutEffectCallbackRef.current = () => {
+ const {restored} = scrollPositionSaver.restore();
+ nextLayoutEffectCallbackRef.current = undefined;
+
+ // Restoring the former scroll position will trigger a scroll event
+ // We need to wait for next scroll event to happen
+ // before enabling again the scrollController events
+ if (restored) {
+ const handleScrollRestoreEvent = () => {
+ scrollController.enableScrollEvents();
+ window.removeEventListener('scroll', handleScrollRestoreEvent);
+ };
+ window.addEventListener('scroll', handleScrollRestoreEvent);
+ } else {
+ scrollController.enableScrollEvents();
+ }
+ };
+ },
+ [scrollController],
+ );
+
+ useLayoutEffect(() => {
+ nextLayoutEffectCallbackRef.current?.();
+ });
+
+ return {
+ blockElementScrollPositionUntilNextRender,
+ };
+}