Skip to content

feat: improve scroll-to-top behavior#1453

Open
RYGRIT wants to merge 3 commits intonpmx-dev:mainfrom
RYGRIT:feat/scroll-to-top
Open

feat: improve scroll-to-top behavior#1453
RYGRIT wants to merge 3 commits intonpmx-dev:mainfrom
RYGRIT:feat/scroll-to-top

Conversation

@RYGRIT
Copy link
Contributor

@RYGRIT RYGRIT commented Feb 13, 2026

Fixes #1379

Improves the ScrollToTop component behavior.

  • Desktop Support: Enabled the button on desktop and fixed the visibility fallback logic for browsers without scroll-state support.
  • Better UX: Normalized scroll duration (500ms) and added support for prefers-reduced-motion.
  • Interaction: The auto-scroll animation now cancels immediately upon user interaction (wheel, touch, or click).
Desktop
scroll-to-top.mp4
Mobile
freecompress-1714SVID_20260213_094933_1.mp4

cc @graphieros @MatteoGabriele

@vercel
Copy link

vercel bot commented Feb 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs.npmx.dev Ready Ready Preview, Comment Feb 14, 2026 10:53am
npmx.dev Ready Ready Preview, Comment Feb 14, 2026 10:53am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
npmx-lunaria Ignored Ignored Feb 14, 2026 10:53am

Request Review

@codecov
Copy link

codecov bot commented Feb 13, 2026

Codecov Report

❌ Patch coverage is 2.08333% with 47 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/composables/useScrollToTop.ts 0.00% 32 Missing and 6 partials ⚠️
app/components/ScrollToTop.client.vue 10.00% 5 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

📝 Walkthrough

Walkthrough

Adds a new composable useScrollToTop(options) exporting scrollToTop and cancel (default duration 500ms), with eased, frame-driven animation, prefers-reduced-motion handling, interaction-based cancellation and on-unmount cleanup. The ScrollToTop component was refactored to use this composable, introduced isTouchDeviceClient and a shouldShowButton computed, reversed the onScroll guard to prefer CSS scroll-state when available, replaced the local scrollToTop implementation with the hook, updated template v-if and @click bindings, and adjusted classes/visibility logic for CSS and JS fallback paths.

Suggested labels

front

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly describes the changeset: enabling ScrollToTop on desktop, normalizing scroll duration to 500ms, adding prefers-reduced-motion support, and implementing interaction-based animation cancellation.
Linked Issues check ✅ Passed The PR successfully addresses both objectives from issue #1379: making the button visible on desktop/touch devices and normalizing smooth scroll timing to 500ms with JavaScript-based animation.
Out of Scope Changes check ✅ Passed All changes are directly related to the linked issue objectives: the new useScrollToTop composable provides the standardized scroll animation, and ScrollToTop.client.vue modifications integrate it with device detection.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
app/components/ScrollToTop.client.vue (1)

44-44: Consider extracting duplicated class string.

Lines 44 and 64 share nearly identical class lists. Extracting to a computed or constant would reduce duplication and simplify future styling changes.

♻️ Suggested refactor
 const { scrollToTop } = useScrollToTop({ duration: SCROLL_TO_TOP_DURATION })
+
+const buttonClasses = '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'
 
 useEventListener('scroll', onScroll, { passive: true })

Then in the template:

-    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"
+    :class="['scroll-to-top-css', buttonClasses]"
-      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"
+      :class="buttonClasses"

Also applies to: 64-64

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Comment @coderabbitai help to get the list of available commands and usage tips.

@RYGRIT RYGRIT marked this pull request as draft February 13, 2026 02:40
@RYGRIT RYGRIT marked this pull request as ready for review February 13, 2026 08:25
Copy link
Member

@danielroe danielroe left a comment

Choose a reason for hiding this comment

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

these look like good changes to the scroll composable 👌

my only question is about displaying the button on desktop. it feels like an affordance needed for mobile, but users with a keyboard will have a 'Home' key and an extra button clutters the UI

@graphieros
Copy link
Contributor

these look like good changes to the scroll composable 👌

my only question is about displaying the button on desktop. it feels like an affordance needed for mobile, but users with a keyboard will have a 'Home' key and an extra button clutters the UI

Would the button appear on tablets ?

@danielroe
Copy link
Member

it should probably be gated not by window width but by isTouchDevice
https://github.com/npmx-dev/npmx.dev/blob/8a70d6e124dc3c1560a59c76334ac64867dd07b7/app/utils/responsive.ts

@RYGRIT
Copy link
Contributor Author

RYGRIT commented Feb 13, 2026

it should probably be gated not by window width but by isTouchDevice https://github.com/npmx-dev/npmx.dev/blob/8a70d6e124dc3c1560a59c76334ac64867dd07b7/app/utils/responsive.ts

Good point. I'll switch to using isTouchDevice to avoid cluttering the desktop UI.

'Home' key

Ah, TIL about the Home/End shortcuts! Thanks for the tip.

@RYGRIT
Copy link
Contributor Author

RYGRIT commented Feb 13, 2026

I've already implemented isTouchDevice() to determine if a button is hidden, but I've found that when simulating a mobile device in the browser, I have to refresh the browser for the button to appear.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Comment on lines +12 to +20
const isTouchDeviceClient = shallowRef(false)
const isVisible = shallowRef(false)
const scrollThreshold = 300
const { isSupported: supportsScrollStateQueries } = useCssSupports(
'container-type',
'scroll-state',
{ ssrValue: false },
)
const shouldShowButton = computed(() => isActive.value && isTouchDeviceClient.value)
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 13, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Desktop visibility still blocked by touch-only gating.

shouldShowButton requires isTouchDeviceClient, so the button never appears on non-touch desktops. That conflicts with issue #1379’s requirement to show the control on desktop. If the requirement still stands, drop the touch-only gate (or make it configurable). If the requirement has changed, please update the issue/PR description to match.

🛠️ Suggested fix if desktop should be supported
-const isTouchDeviceClient = shallowRef(false)
 const isVisible = shallowRef(false)
@@
-const shouldShowButton = computed(() => isActive.value && isTouchDeviceClient.value)
+const shouldShowButton = computed(() => isActive.value)
@@
 onMounted(() => {
-  isTouchDeviceClient.value = isTouchDevice()
   onScroll()
 })

Also applies to: 34-34

Copy link
Contributor

Choose a reason for hiding this comment

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

This is ok, requirements changed to only show the button on touch decices

Copy link
Contributor

Choose a reason for hiding this comment

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

@graphieros Thank you for clarifying! I understand the requirements have shifted to show the button only on touch devices. The current implementation correctly achieves that.

Since issue #1379 is titled "Back-to-top button on desktop" and the PR description mentions "Desktop Support: Enabled the button on desktop", you may want to update both to reflect this new direction—perhaps explaining that after discussion, the button was decided to remain touch-only to avoid cluttering desktop UI (where keyboard shortcuts like Home/End are available).


✏️ Learnings added
Learnt from: graphieros
Repo: npmx-dev/npmx.dev PR: 1453
File: app/components/ScrollToTop.client.vue:12-20
Timestamp: 2026-02-13T14:11:12.069Z
Learning: For the ScrollToTop button component in app/components/ScrollToTop.client.vue: the button should only be shown on touch devices (gated by `isTouchDevice()`), not on desktop, to avoid cluttering desktop UI where keyboard shortcuts are available.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@danielroe
Copy link
Member

looking great!

but one more thought - we might want to update the implementation on the package page as we already have a bottom bar - maybe (on that page) build another button into that bottom bar and hide this one?

Screen Shot 2026-02-13 at 14 47 16

@RYGRIT
Copy link
Contributor Author

RYGRIT commented Feb 13, 2026

Yes, I also think they should be merged.

However, this raises another issue: when switching to other languages, the floating buttons at the bottom extend beyond the screen, and the display changes accordingly (for example, the floating button area becomes higher). I mentioned this issue in PR #1284 before. I'm unsure whether the text content should be hidden on small screens. I haven't thought of a better solution at the moment.

Device: iPhone 14 Pro Max - browser (simulated) image image
image

Copy link
Contributor

@OrbisK OrbisK left a comment

Choose a reason for hiding this comment

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

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
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()"

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
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()"

Comment on lines +64 to +68
const cleanup = [
useEventListener(window, 'wheel', cancel, { passive: true }),
useEventListener(window, 'touchstart', cancel, { passive: true }),
useEventListener(window, 'mousedown', cancel, { passive: true }),
]
Copy link
Contributor

@OrbisK OrbisK Feb 13, 2026

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.

rafId = requestAnimationFrame(animate)
}

onBeforeUnmount(cancel)
Copy link
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).

@danielroe
Copy link
Member

However, this raises another issue: when switching to other languages, the floating buttons at the bottom extend beyond the screen, and the display changes accordingly

@RYGRIT maybe we can switch to icon-only for the bottom bar on mobile? and long-press on the button shows what it does?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Back-to-top button on desktop

4 participants