Skip to content

Commit aad5b12

Browse files
committed
feat: improve scroll-to-top behavior
1 parent c37a321 commit aad5b12

2 files changed

Lines changed: 104 additions & 8 deletions

File tree

app/components/ScrollToTop.client.vue

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ const excludedRoutes = new Set(['index', 'code'])
66
77
const isActive = computed(() => !excludedRoutes.has(route.name as string))
88
9+
const SCROLL_TO_TOP_DURATION = 500
10+
911
const isMounted = useMounted()
1012
const isVisible = shallowRef(false)
1113
const scrollThreshold = 300
@@ -16,15 +18,13 @@ const { isSupported: supportsScrollStateQueries } = useCssSupports(
1618
)
1719
1820
function onScroll() {
19-
if (!supportsScrollStateQueries.value) {
21+
if (supportsScrollStateQueries.value) {
2022
return
2123
}
2224
isVisible.value = window.scrollY > scrollThreshold
2325
}
2426
25-
function scrollToTop() {
26-
window.scrollTo({ top: 0, behavior: 'smooth' })
27-
}
27+
const { scrollToTop } = useScrollToTop({ duration: SCROLL_TO_TOP_DURATION })
2828
2929
useEventListener('scroll', onScroll, { passive: true })
3030
@@ -38,9 +38,9 @@ onMounted(() => {
3838
<button
3939
v-if="isActive && supportsScrollStateQueries"
4040
type="button"
41-
class="scroll-to-top-css fixed bottom-4 inset-ie-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg md:hidden flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
41+
class="scroll-to-top-css fixed bottom-4 inset-ie-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
4242
:aria-label="$t('common.scroll_to_top')"
43-
@click="scrollToTop"
43+
@click="scrollToTop()"
4444
>
4545
<span class="i-carbon:arrow-up w-5 h-5" aria-hidden="true" />
4646
</button>
@@ -58,9 +58,9 @@ onMounted(() => {
5858
<button
5959
v-if="isActive && isMounted && isVisible"
6060
type="button"
61-
class="fixed bottom-4 inset-ie-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg md:hidden flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
61+
class="fixed bottom-4 inset-ie-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
6262
:aria-label="$t('common.scroll_to_top')"
63-
@click="scrollToTop"
63+
@click="scrollToTop()"
6464
>
6565
<span class="i-carbon:arrow-up w-5 h-5" aria-hidden="true" />
6666
</button>

app/composables/useScrollToTop.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
interface UseScrollToTopOptions {
2+
/**
3+
* Duration of the scroll animation in milliseconds.
4+
*/
5+
duration?: number
6+
}
7+
8+
/**
9+
* Scroll to the top of the page with a smooth animation.
10+
* @param options - Configuration options for the scroll animation.
11+
* @returns An object containing the scrollToTop function and a cancel function.
12+
*/
13+
export function useScrollToTop(options: UseScrollToTopOptions) {
14+
const { duration = 500 } = options
15+
16+
// Check if prefers-reduced-motion is enabled
17+
const preferReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
18+
19+
// Easing function for the scroll animation
20+
const easeOutQuad = (t: number) => t * (2 - t)
21+
22+
/**
23+
* Active requestAnimationFrame id for the current auto-scroll animation
24+
*/
25+
let rafId: number | null = null
26+
/**
27+
* Disposer for temporary interaction listeners attached during auto-scroll
28+
*/
29+
let stopInteractionListeners: (() => void) | null = null
30+
31+
function cleanupInteractionListeners() {
32+
if (stopInteractionListeners) {
33+
stopInteractionListeners()
34+
stopInteractionListeners = null
35+
}
36+
}
37+
38+
/**
39+
* Stop any in-flight auto-scroll before starting a new one.
40+
*/
41+
function cancel() {
42+
if (rafId !== null) {
43+
cancelAnimationFrame(rafId)
44+
rafId = null
45+
}
46+
47+
cleanupInteractionListeners()
48+
}
49+
50+
function scrollToTop() {
51+
cancel()
52+
53+
if (preferReducedMotion.value) {
54+
window.scrollTo({ top: 0, behavior: 'instant' })
55+
return
56+
}
57+
58+
const start = window.scrollY
59+
if (start <= 0) return
60+
61+
const startTime = performance.now()
62+
const change = -start
63+
64+
const cleanup = [
65+
useEventListener(window, 'wheel', cancel, { passive: true }),
66+
useEventListener(window, 'touchstart', cancel, { passive: true }),
67+
useEventListener(window, 'mousedown', cancel, { passive: true }),
68+
]
69+
70+
stopInteractionListeners = () => cleanup.forEach(stop => stop())
71+
72+
// Start the frame-by-frame scroll animation.
73+
function animate() {
74+
const elapsed = performance.now() - startTime
75+
const t = Math.min(elapsed / duration, 1)
76+
const y = start + change * easeOutQuad(t)
77+
78+
window.scrollTo({ top: y })
79+
80+
if (t < 1) {
81+
rafId = requestAnimationFrame(animate)
82+
} else {
83+
cancel()
84+
}
85+
}
86+
87+
rafId = requestAnimationFrame(animate)
88+
}
89+
90+
onBeforeUnmount(cancel)
91+
92+
return {
93+
scrollToTop,
94+
cancel,
95+
}
96+
}

0 commit comments

Comments
 (0)