Skip to content

Commit 4b98444

Browse files
RYGRITdanielroe
andauthored
feat: improve scroll-to-top behavior (#1453)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 2692155 commit 4b98444

File tree

3 files changed

+129
-28
lines changed

3 files changed

+129
-28
lines changed

app/components/ScrollToTop.client.vue

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,42 +3,32 @@ const route = useRoute()
33
44
// Pages where scroll-to-top should NOT be shown
55
const excludedRoutes = new Set(['index', 'code'])
6+
const isPackagePage = computed(() => route.name === 'package' || route.name === 'package-version')
67
7-
const isActive = computed(() => !excludedRoutes.has(route.name as string))
8+
const isActive = computed(() => !excludedRoutes.has(route.name as string) && !isPackagePage.value)
89
910
const isMounted = useMounted()
10-
const isVisible = shallowRef(false)
11-
const scrollThreshold = 300
11+
const { scrollToTop, isTouchDeviceClient } = useScrollToTop()
12+
13+
const { y: scrollTop } = useScroll(window)
14+
const isVisible = computed(() => {
15+
if (supportsScrollStateQueries.value) return false
16+
return scrollTop.value > SCROLL_TO_TOP_THRESHOLD
17+
})
1218
const { isSupported: supportsScrollStateQueries } = useCssSupports(
1319
'container-type',
1420
'scroll-state',
1521
{ ssrValue: false },
1622
)
17-
18-
function onScroll() {
19-
if (!supportsScrollStateQueries.value) {
20-
return
21-
}
22-
isVisible.value = window.scrollY > scrollThreshold
23-
}
24-
25-
function scrollToTop() {
26-
window.scrollTo({ top: 0, behavior: 'smooth' })
27-
}
28-
29-
useEventListener('scroll', onScroll, { passive: true })
30-
31-
onMounted(() => {
32-
onScroll()
33-
})
23+
const shouldShowButton = computed(() => isActive.value && isTouchDeviceClient.value)
3424
</script>
3525

3626
<template>
3727
<!-- When CSS scroll-state is supported, use CSS-only visibility -->
3828
<button
39-
v-if="isActive && supportsScrollStateQueries"
29+
v-if="shouldShowButton && supportsScrollStateQueries"
4030
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"
31+
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"
4232
:aria-label="$t('common.scroll_to_top')"
4333
@click="scrollToTop"
4434
>
@@ -56,11 +46,11 @@ onMounted(() => {
5646
leave-to-class="opacity-0 translate-y-2"
5747
>
5848
<button
59-
v-if="isActive && isMounted && isVisible"
49+
v-if="shouldShowButton && isMounted && isVisible"
6050
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"
51+
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"
6252
:aria-label="$t('common.scroll_to_top')"
63-
@click="scrollToTop"
53+
@click="() => scrollToTop()"
6454
>
6555
<span class="i-lucide:arrow-up w-5 h-5" aria-hidden="true" />
6656
</button>

app/composables/useScrollToTop.ts

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

app/pages/package/[[org]]/[name].vue

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,13 @@ const { copied: copiedVersion, copy: copyVersion } = useClipboard({
242242
copiedDuring: 2000,
243243
})
244244
245+
const { scrollToTop, isTouchDeviceClient } = useScrollToTop()
246+
247+
const { y: scrollY } = useScroll(window)
248+
const showScrollToTop = computed(
249+
() => isTouchDeviceClient.value && scrollY.value > SCROLL_TO_TOP_THRESHOLD,
250+
)
251+
245252
// Fetch dependency analysis (lazy, client-side)
246253
// This is the same composable used by PackageVulnerabilityTree and PackageDeprecatedTree
247254
const { data: vulnTree, status: vulnTreeStatus } = useDependencyAnalysis(
@@ -777,26 +784,37 @@ const showSkeleton = shallowRef(false)
777784
:to="docsLink"
778785
aria-keyshortcuts="d"
779786
classicon="i-lucide:file-text"
787+
:title="$t('package.links.docs')"
780788
>
781-
{{ $t('package.links.docs') }}
789+
<span class="max-sm:sr-only">{{ $t('package.links.docs') }}</span>
782790
</LinkBase>
783791
<LinkBase
784792
v-if="codeLink"
785793
variant="button-secondary"
786794
:to="codeLink"
787795
aria-keyshortcuts="."
788796
classicon="i-lucide:code"
797+
:title="$t('package.links.code')"
789798
>
790-
{{ $t('package.links.code') }}
799+
<span class="max-sm:sr-only">{{ $t('package.links.code') }}</span>
791800
</LinkBase>
792801
<LinkBase
793802
variant="button-secondary"
794803
:to="{ name: 'compare', query: { packages: pkg.name } }"
795804
aria-keyshortcuts="c"
796805
classicon="i-lucide:git-compare"
806+
:title="$t('package.links.compare')"
797807
>
798-
{{ $t('package.links.compare') }}
808+
<span class="max-sm:sr-only">{{ $t('package.links.compare') }}</span>
799809
</LinkBase>
810+
<ButtonBase
811+
v-if="showScrollToTop"
812+
variant="secondary"
813+
:title="$t('common.scroll_to_top')"
814+
:aria-label="$t('common.scroll_to_top')"
815+
@click="() => scrollToTop()"
816+
classicon="i-lucide:arrow-up"
817+
/>
800818
</ButtonGroup>
801819

802820
<!-- Package metrics -->

0 commit comments

Comments
 (0)