From be46f5da2e17e66e11d38a66514561bf515a030d Mon Sep 17 00:00:00 2001 From: Hikaru Ikuta Date: Mon, 20 Oct 2025 16:09:31 +0900 Subject: [PATCH 1/5] fix(VAppBar): prevent navbar pop-up when reaching page bottom Fixes #20352 --- .../src/components/VAppBar/VAppBar.tsx | 34 +++++++--- packages/vuetify/src/composables/scroll.ts | 62 +++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/packages/vuetify/src/components/VAppBar/VAppBar.tsx b/packages/vuetify/src/components/VAppBar/VAppBar.tsx index c22a75363bc..79d99d837e2 100644 --- a/packages/vuetify/src/components/VAppBar/VAppBar.tsx +++ b/packages/vuetify/src/components/VAppBar/VAppBar.tsx @@ -83,6 +83,9 @@ export const VAppBar = genericComponent()({ scrollThreshold, isScrollingUp, scrollRatio, + isAtBottom, + reachedBottomWhileScrollingDown, + hasEnoughScrollableSpace, } = useScroll(props, { canScroll }) const canHide = toRef(() => ( @@ -120,15 +123,32 @@ export const VAppBar = genericComponent()({ useToggleScope(() => !!props.scrollBehavior, () => { watchEffect(() => { - if (canHide.value) { - if (scrollBehavior.value.inverted) { - isActive.value = currentScroll.value > scrollThreshold.value - } else { - isActive.value = isScrollingUp.value || (currentScroll.value < scrollThreshold.value) - } - } else { + if (!canHide.value) { isActive.value = true + return } + + if (scrollBehavior.value.inverted) { + isActive.value = currentScroll.value > scrollThreshold.value + return + } + + // If there's not enough scrollable space, don't apply scroll-hide behavior at all + // This prevents flickering/bouncing animations on short pages + if (!hasEnoughScrollableSpace.value) { + isActive.value = true + return + } + + // Prevent navbar from showing when we reached bottom while scrolling down + // This handles the case where scroll momentum causes to hit bottom during hide transition + if (reachedBottomWhileScrollingDown.value) { + isActive.value = false + return + } + + // Normal behavior: show when scrolling up (and not at bottom) or above threshold + isActive.value = (isScrollingUp.value && !isAtBottom.value) || (currentScroll.value < scrollThreshold.value) }) }) diff --git a/packages/vuetify/src/composables/scroll.ts b/packages/vuetify/src/composables/scroll.ts index 179a3cbe294..6adc24391ca 100644 --- a/packages/vuetify/src/composables/scroll.ts +++ b/packages/vuetify/src/composables/scroll.ts @@ -51,6 +51,9 @@ export function useScroll ( const currentThreshold = shallowRef(0) const isScrollActive = shallowRef(false) const isScrollingUp = shallowRef(false) + const isAtBottom = shallowRef(false) + const reachedBottomWhileScrollingDown = shallowRef(false) + const hasEnoughScrollableSpace = shallowRef(true) const scrollThreshold = computed(() => { return Number(props.scrollThreshold) @@ -64,6 +67,12 @@ export function useScroll ( return clamp(((scrollThreshold.value - currentScroll.value) / scrollThreshold.value) || 0) }) + const getScrollMetrics = (targetEl: Element | Window) => { + const clientHeight = ('window' in targetEl) ? window.innerHeight : targetEl.clientHeight + const scrollHeight = ('window' in targetEl) ? document.documentElement.scrollHeight : targetEl.scrollHeight + return { clientHeight, scrollHeight } + } + const onScroll = () => { const targetEl = target.value @@ -75,11 +84,41 @@ export function useScroll ( const currentScrollHeight = targetEl instanceof Window ? document.documentElement.scrollHeight : targetEl.scrollHeight if (previousScrollHeight !== currentScrollHeight) { previousScrollHeight = currentScrollHeight + // Recalculate scrollable space when content height changes + checkScrollableSpace() return } isScrollingUp.value = currentScroll.value < previousScroll currentThreshold.value = Math.abs(currentScroll.value - scrollThreshold.value) + + // Detect if at bottom of page + const { clientHeight, scrollHeight } = getScrollMetrics(targetEl) + const atBottom = currentScroll.value + clientHeight >= scrollHeight - 5 + + // Track when bottom is reached during downward scroll + // Only set flag if ALL conditions are met: + // 1. Scrolled past threshold (navbar is hiding) + // 2. Page has enough scrollable space for scroll-hide + // This prevents activation on short pages or edge cases + if (!isScrollingUp.value && atBottom && + currentScroll.value >= scrollThreshold.value && + hasEnoughScrollableSpace.value) { + reachedBottomWhileScrollingDown.value = true + } + + // Reset the flag when: + // 1. Scrolling up away from bottom + // 2. Scroll position jumped significantly (e.g., navigation, scroll restoration) + // 3. Scroll is at the very top (page navigation resets to top) + const scrollJumped = Math.abs(currentScroll.value - previousScroll) > 100 + const atTop = currentScroll.value <= 5 + if ((isScrollingUp.value && !atBottom) || (scrollJumped && currentScroll.value < scrollThreshold.value) || atTop) { + reachedBottomWhileScrollingDown.value = false + } + + // Update state + isAtBottom.value = atBottom } watch(isScrollingUp, () => { @@ -90,6 +129,20 @@ export function useScroll ( savedScroll.value = 0 }) + const checkScrollableSpace = () => { + const targetEl = target.value + if (!targetEl) return + + const { clientHeight, scrollHeight } = getScrollMetrics(targetEl) + const maxScrollableDistance = scrollHeight - clientHeight + + // Only enable scroll-hide if there's significantly more scrollable space than the threshold + // Use 1.5x threshold AND at least 150px to ensure smooth behavior and avoid edge cases + // where the page barely scrolls past the threshold before hitting bottom + const minScrollableDistance = Math.max(scrollThreshold.value * 1.5, 150) + hasEnoughScrollableSpace.value = maxScrollableDistance > minScrollableDistance + } + onMounted(() => { watch(() => props.scrollTarget, scrollTarget => { const newTarget = scrollTarget ? document.querySelector(scrollTarget) : window @@ -104,6 +157,12 @@ export function useScroll ( target.value?.removeEventListener('scroll', onScroll) target.value = newTarget target.value.addEventListener('scroll', onScroll, { passive: true }) + + // Check scrollable space immediately when target is set + // Need to use nextTick to ensure DOM is ready + Promise.resolve().then(() => { + checkScrollableSpace() + }) }, { immediate: true }) }) @@ -127,5 +186,8 @@ export function useScroll ( // later (2 chars chlng) isScrollingUp, savedScroll, + isAtBottom, + reachedBottomWhileScrollingDown, + hasEnoughScrollableSpace, } } From 51425b10461ae0d60fca3e9a379bb929793a96ba Mon Sep 17 00:00:00 2001 From: Hikaru Ikuta Date: Mon, 20 Oct 2025 16:36:56 +0900 Subject: [PATCH 2/5] Fix lint --- packages/vuetify/src/composables/scroll.ts | 28 +++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/vuetify/src/composables/scroll.ts b/packages/vuetify/src/composables/scroll.ts index 6adc24391ca..2cc974dd25c 100644 --- a/packages/vuetify/src/composables/scroll.ts +++ b/packages/vuetify/src/composables/scroll.ts @@ -73,6 +73,20 @@ export function useScroll ( return { clientHeight, scrollHeight } } + const checkScrollableSpace = () => { + const targetEl = target.value + if (!targetEl) return + + const { clientHeight, scrollHeight } = getScrollMetrics(targetEl) + const maxScrollableDistance = scrollHeight - clientHeight + + // Only enable scroll-hide if there's significantly more scrollable space than the threshold + // Use 1.5x threshold AND at least 150px to ensure smooth behavior and avoid edge cases + // where the page barely scrolls past the threshold before hitting bottom + const minScrollableDistance = Math.max(scrollThreshold.value * 1.5, 150) + hasEnoughScrollableSpace.value = maxScrollableDistance > minScrollableDistance + } + const onScroll = () => { const targetEl = target.value @@ -129,20 +143,6 @@ export function useScroll ( savedScroll.value = 0 }) - const checkScrollableSpace = () => { - const targetEl = target.value - if (!targetEl) return - - const { clientHeight, scrollHeight } = getScrollMetrics(targetEl) - const maxScrollableDistance = scrollHeight - clientHeight - - // Only enable scroll-hide if there's significantly more scrollable space than the threshold - // Use 1.5x threshold AND at least 150px to ensure smooth behavior and avoid edge cases - // where the page barely scrolls past the threshold before hitting bottom - const minScrollableDistance = Math.max(scrollThreshold.value * 1.5, 150) - hasEnoughScrollableSpace.value = maxScrollableDistance > minScrollableDistance - } - onMounted(() => { watch(() => props.scrollTarget, scrollTarget => { const newTarget = scrollTarget ? document.querySelector(scrollTarget) : window From 2e12fb8dad6d37408a4ee5a52bfcf252a77dab74 Mon Sep 17 00:00:00 2001 From: Hikaru Ikuta Date: Mon, 20 Oct 2025 16:57:45 +0900 Subject: [PATCH 3/5] Support dynamic content --- packages/vuetify/src/composables/scroll.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/vuetify/src/composables/scroll.ts b/packages/vuetify/src/composables/scroll.ts index 2cc974dd25c..65bc7c1776b 100644 --- a/packages/vuetify/src/composables/scroll.ts +++ b/packages/vuetify/src/composables/scroll.ts @@ -80,10 +80,9 @@ export function useScroll ( const { clientHeight, scrollHeight } = getScrollMetrics(targetEl) const maxScrollableDistance = scrollHeight - clientHeight - // Only enable scroll-hide if there's significantly more scrollable space than the threshold - // Use 1.5x threshold AND at least 150px to ensure smooth behavior and avoid edge cases - // where the page barely scrolls past the threshold before hitting bottom - const minScrollableDistance = Math.max(scrollThreshold.value * 1.5, 150) + // Only enable scroll-hide if there's enough scrollable space + // Require scrollable distance to be at least threshold + 50px to ensure smooth behavior + const minScrollableDistance = scrollThreshold.value + 50 hasEnoughScrollableSpace.value = maxScrollableDistance > minScrollableDistance } @@ -97,9 +96,12 @@ export function useScroll ( const currentScrollHeight = targetEl instanceof Window ? document.documentElement.scrollHeight : targetEl.scrollHeight if (previousScrollHeight !== currentScrollHeight) { + // If page is growing (content loading), recalculate scrollable space + // If page is shrinking (likely due to navbar animation), don't recalculate + if (currentScrollHeight > previousScrollHeight) { + checkScrollableSpace() + } previousScrollHeight = currentScrollHeight - // Recalculate scrollable space when content height changes - checkScrollableSpace() return } @@ -122,12 +124,13 @@ export function useScroll ( } // Reset the flag when: - // 1. Scrolling up away from bottom + // 1. Scrolling up away from bottom (with tolerance for small movements) // 2. Scroll position jumped significantly (e.g., navigation, scroll restoration) // 3. Scroll is at the very top (page navigation resets to top) const scrollJumped = Math.abs(currentScroll.value - previousScroll) > 100 const atTop = currentScroll.value <= 5 - if ((isScrollingUp.value && !atBottom) || (scrollJumped && currentScroll.value < scrollThreshold.value) || atTop) { + const scrolledUpSignificantly = isScrollingUp.value && (previousScroll - currentScroll.value) > 10 + if ((scrolledUpSignificantly && !atBottom) || (scrollJumped && currentScroll.value < scrollThreshold.value) || atTop) { reachedBottomWhileScrollingDown.value = false } From 172f7d589cb45ad5ed8aee44df57856e8c5bcce2 Mon Sep 17 00:00:00 2001 From: Hikaru Ikuta Date: Mon, 20 Oct 2025 17:46:15 +0900 Subject: [PATCH 4/5] Handle window resizing when calculating scrollable amount --- packages/vuetify/src/composables/scroll.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/vuetify/src/composables/scroll.ts b/packages/vuetify/src/composables/scroll.ts index 65bc7c1776b..a861f6e8544 100644 --- a/packages/vuetify/src/composables/scroll.ts +++ b/packages/vuetify/src/composables/scroll.ts @@ -86,6 +86,10 @@ export function useScroll ( hasEnoughScrollableSpace.value = maxScrollableDistance > minScrollableDistance } + const onResize = () => { + checkScrollableSpace() + } + const onScroll = () => { const targetEl = target.value @@ -167,10 +171,14 @@ export function useScroll ( checkScrollableSpace() }) }, { immediate: true }) + + // Listen to window resize to recalculate scrollable space + window.addEventListener('resize', onResize, { passive: true }) }) onBeforeUnmount(() => { target.value?.removeEventListener('scroll', onScroll) + window.removeEventListener('resize', onResize) }) // Do we need this? If yes - seems that From 5ecd6966e844944e5e962d812e7021cc758e6e26 Mon Sep 17 00:00:00 2001 From: Hikaru Ikuta Date: Tue, 21 Oct 2025 12:39:13 +0900 Subject: [PATCH 5/5] Fix content height calculation --- .../src/components/VAppBar/VAppBar.tsx | 9 +++++++- packages/vuetify/src/composables/scroll.ts | 21 +++++++++++-------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/vuetify/src/components/VAppBar/VAppBar.tsx b/packages/vuetify/src/components/VAppBar/VAppBar.tsx index 79d99d837e2..7f415a20ffa 100644 --- a/packages/vuetify/src/components/VAppBar/VAppBar.tsx +++ b/packages/vuetify/src/components/VAppBar/VAppBar.tsx @@ -78,6 +78,13 @@ export const VAppBar = genericComponent()({ !isActive.value ) }) + + const appBarHeight = computed(() => { + const height = vToolbarRef.value?.contentHeight ?? 0 + const extensionHeight = vToolbarRef.value?.extensionHeight ?? 0 + return height + extensionHeight + }) + const { currentScroll, scrollThreshold, @@ -86,7 +93,7 @@ export const VAppBar = genericComponent()({ isAtBottom, reachedBottomWhileScrollingDown, hasEnoughScrollableSpace, - } = useScroll(props, { canScroll }) + } = useScroll(props, { canScroll, layoutSize: appBarHeight }) const canHide = toRef(() => ( scrollBehavior.value.hide || diff --git a/packages/vuetify/src/composables/scroll.ts b/packages/vuetify/src/composables/scroll.ts index a861f6e8544..4cae14bc863 100644 --- a/packages/vuetify/src/composables/scroll.ts +++ b/packages/vuetify/src/composables/scroll.ts @@ -36,13 +36,14 @@ export const makeScrollProps = propsFactory({ export interface ScrollArguments { canScroll?: Readonly> + layoutSize?: Readonly> } export function useScroll ( props: ScrollProps, args: ScrollArguments = {}, ) { - const { canScroll } = args + const { canScroll, layoutSize } = args let previousScroll = 0 let previousScrollHeight = 0 const target = ref(null) @@ -80,10 +81,14 @@ export function useScroll ( const { clientHeight, scrollHeight } = getScrollMetrics(targetEl) const maxScrollableDistance = scrollHeight - clientHeight + // When the scroll-hide element (like AppBar) hides, it causes the page to grow + // We need extra scrollable space beyond the threshold to prevent bouncing + // Add the element's height to the required minimum distance + const elementHeight = layoutSize?.value || 0 + const minRequiredDistance = scrollThreshold.value + elementHeight + // Only enable scroll-hide if there's enough scrollable space - // Require scrollable distance to be at least threshold + 50px to ensure smooth behavior - const minScrollableDistance = scrollThreshold.value + 50 - hasEnoughScrollableSpace.value = maxScrollableDistance > minScrollableDistance + hasEnoughScrollableSpace.value = maxScrollableDistance > minRequiredDistance } const onResize = () => { @@ -106,7 +111,6 @@ export function useScroll ( checkScrollableSpace() } previousScrollHeight = currentScrollHeight - return } isScrollingUp.value = currentScroll.value < previousScroll @@ -128,12 +132,12 @@ export function useScroll ( } // Reset the flag when: - // 1. Scrolling up away from bottom (with tolerance for small movements) + // 1. Scrolling up away from bottom (with small tolerance for touchpad/momentum scrolling) // 2. Scroll position jumped significantly (e.g., navigation, scroll restoration) // 3. Scroll is at the very top (page navigation resets to top) const scrollJumped = Math.abs(currentScroll.value - previousScroll) > 100 const atTop = currentScroll.value <= 5 - const scrolledUpSignificantly = isScrollingUp.value && (previousScroll - currentScroll.value) > 10 + const scrolledUpSignificantly = isScrollingUp.value && (previousScroll - currentScroll.value) > 1 if ((scrolledUpSignificantly && !atBottom) || (scrollJumped && currentScroll.value < scrollThreshold.value) || atTop) { reachedBottomWhileScrollingDown.value = false } @@ -165,8 +169,7 @@ export function useScroll ( target.value = newTarget target.value.addEventListener('scroll', onScroll, { passive: true }) - // Check scrollable space immediately when target is set - // Need to use nextTick to ensure DOM is ready + // Check scrollable space when target is set Promise.resolve().then(() => { checkScrollableSpace() })