Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions app/components/ScrollToTop.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 })

Expand All @@ -38,9 +38,9 @@ onMounted(() => {
<button
v-if="isActive && supportsScrollStateQueries"
type="button"
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"
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"
:aria-label="$t('common.scroll_to_top')"
@click="scrollToTop"
@click="scrollToTop()"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@click="scrollToTop()"
@click="()=>scrollToTop()"

>
<span class="i-carbon:arrow-up w-5 h-5" aria-hidden="true" />
</button>
Expand All @@ -58,9 +58,9 @@ onMounted(() => {
<button
v-if="isActive && isMounted && isVisible"
type="button"
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"
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"
:aria-label="$t('common.scroll_to_top')"
@click="scrollToTop"
@click="scrollToTop()"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@click="scrollToTop()"
@click="()=>scrollToTop()"

>
<span class="i-carbon:arrow-up w-5 h-5" aria-hidden="true" />
</button>
Expand Down
96 changes: 96 additions & 0 deletions app/composables/useScrollToTop.ts
Original file line number Diff line number Diff line change
@@ -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 }),
]

@OrbisK OrbisK Feb 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should not add these inside of scrollTop(). This will lead to a memory leak, becuase the event listerners are not cleaned up after every call.
You should move them to the setup level.


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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should prefer the tryOnScopeDispose method from Vueuse. This is because the component might be called in another scope, than the component (for example as a sharedComposable).


return {
scrollToTop,
cancel,
}
}
Loading