From aad5b12ee38b9ec0d5b22d9753447323316ace94 Mon Sep 17 00:00:00 2001 From: RYGRIT Date: Thu, 12 Feb 2026 17:37:03 +0800 Subject: [PATCH 1/2] feat: improve scroll-to-top behavior --- app/components/ScrollToTop.client.vue | 16 ++--- app/composables/useScrollToTop.ts | 96 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 app/composables/useScrollToTop.ts diff --git a/app/components/ScrollToTop.client.vue b/app/components/ScrollToTop.client.vue index 63cdbef8b..3a843dc7f 100644 --- a/app/components/ScrollToTop.client.vue +++ b/app/components/ScrollToTop.client.vue @@ -6,6 +6,8 @@ const excludedRoutes = new Set(['index', 'code']) const isActive = computed(() => !excludedRoutes.has(route.name as string)) +const SCROLL_TO_TOP_DURATION = 500 + const isMounted = useMounted() const isVisible = shallowRef(false) const scrollThreshold = 300 @@ -16,15 +18,13 @@ const { isSupported: supportsScrollStateQueries } = useCssSupports( ) function onScroll() { - if (!supportsScrollStateQueries.value) { + if (supportsScrollStateQueries.value) { return } isVisible.value = window.scrollY > scrollThreshold } -function scrollToTop() { - window.scrollTo({ top: 0, behavior: 'smooth' }) -} +const { scrollToTop } = useScrollToTop({ duration: SCROLL_TO_TOP_DURATION }) useEventListener('scroll', onScroll, { passive: true }) @@ -38,9 +38,9 @@ onMounted(() => { @@ -58,9 +58,9 @@ onMounted(() => { diff --git a/app/composables/useScrollToTop.ts b/app/composables/useScrollToTop.ts new file mode 100644 index 000000000..6cabc244d --- /dev/null +++ b/app/composables/useScrollToTop.ts @@ -0,0 +1,96 @@ +interface UseScrollToTopOptions { + /** + * Duration of the scroll animation in milliseconds. + */ + duration?: number +} + +/** + * Scroll to the top of the page with a smooth animation. + * @param options - Configuration options for the scroll animation. + * @returns An object containing the scrollToTop function and a cancel function. + */ +export function useScrollToTop(options: UseScrollToTopOptions) { + const { duration = 500 } = options + + // Check if prefers-reduced-motion is enabled + const preferReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)') + + // Easing function for the scroll animation + const easeOutQuad = (t: number) => t * (2 - t) + + /** + * Active requestAnimationFrame id for the current auto-scroll animation + */ + let rafId: number | null = null + /** + * Disposer for temporary interaction listeners attached during auto-scroll + */ + let stopInteractionListeners: (() => void) | null = null + + function cleanupInteractionListeners() { + if (stopInteractionListeners) { + stopInteractionListeners() + stopInteractionListeners = null + } + } + + /** + * Stop any in-flight auto-scroll before starting a new one. + */ + function cancel() { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + + cleanupInteractionListeners() + } + + function scrollToTop() { + cancel() + + if (preferReducedMotion.value) { + window.scrollTo({ top: 0, behavior: 'instant' }) + return + } + + const start = window.scrollY + if (start <= 0) return + + const startTime = performance.now() + const change = -start + + const cleanup = [ + useEventListener(window, 'wheel', cancel, { passive: true }), + useEventListener(window, 'touchstart', cancel, { passive: true }), + useEventListener(window, 'mousedown', cancel, { passive: true }), + ] + + stopInteractionListeners = () => cleanup.forEach(stop => stop()) + + // Start the frame-by-frame scroll animation. + function animate() { + const elapsed = performance.now() - startTime + const t = Math.min(elapsed / duration, 1) + const y = start + change * easeOutQuad(t) + + window.scrollTo({ top: y }) + + if (t < 1) { + rafId = requestAnimationFrame(animate) + } else { + cancel() + } + } + + rafId = requestAnimationFrame(animate) + } + + onBeforeUnmount(cancel) + + return { + scrollToTop, + cancel, + } +} From 8d294dfe93e22ec35ee2c6c9d9de7fbb77eebe04 Mon Sep 17 00:00:00 2001 From: rygrit Date: Fri, 13 Feb 2026 22:00:47 +0800 Subject: [PATCH 2/2] fix: use isTouchDevice methods control scrollToTop button display --- app/components/ScrollToTop.client.vue | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/components/ScrollToTop.client.vue b/app/components/ScrollToTop.client.vue index 3a843dc7f..9f0965f0d 100644 --- a/app/components/ScrollToTop.client.vue +++ b/app/components/ScrollToTop.client.vue @@ -9,6 +9,7 @@ const isActive = computed(() => !excludedRoutes.has(route.name as string)) const SCROLL_TO_TOP_DURATION = 500 const isMounted = useMounted() +const isTouchDeviceClient = shallowRef(false) const isVisible = shallowRef(false) const scrollThreshold = 300 const { isSupported: supportsScrollStateQueries } = useCssSupports( @@ -16,6 +17,7 @@ const { isSupported: supportsScrollStateQueries } = useCssSupports( 'scroll-state', { ssrValue: false }, ) +const shouldShowButton = computed(() => isActive.value && isTouchDeviceClient.value) function onScroll() { if (supportsScrollStateQueries.value) { @@ -29,6 +31,7 @@ const { scrollToTop } = useScrollToTop({ duration: SCROLL_TO_TOP_DURATION }) useEventListener('scroll', onScroll, { passive: true }) onMounted(() => { + isTouchDeviceClient.value = isTouchDevice() onScroll() }) @@ -36,7 +39,7 @@ onMounted(() => {