From d6245c7c7e6e387aab9f9ec60ba2b5d6405cff93 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 7 Nov 2025 19:41:50 -0800 Subject: [PATCH] Add scroll hint --- .../app/(modern)/scroll/Events.ts | 3 + .../app/(modern)/scroll/SkinScroller.tsx | 20 ++++ .../app/(modern)/scroll/useScrollHint.ts | 99 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 packages/skin-database/app/(modern)/scroll/useScrollHint.ts diff --git a/packages/skin-database/app/(modern)/scroll/Events.ts b/packages/skin-database/app/(modern)/scroll/Events.ts index c2238578..c0c0085f 100644 --- a/packages/skin-database/app/(modern)/scroll/Events.ts +++ b/packages/skin-database/app/(modern)/scroll/Events.ts @@ -97,4 +97,7 @@ export type UserEvent = | { type: "menu_click"; menuItem: string; + } + | { + type: "scroll_hint_shown"; }; diff --git a/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx b/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx index b8f1d725..f1a32590 100644 --- a/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx +++ b/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx @@ -3,6 +3,7 @@ import { useState, useLayoutEffect, useEffect } from "react"; import SkinPage from "./SkinPage"; import { logUserEvent } from "./Events"; +import { useScrollHint } from "./useScrollHint"; export type ClientSkin = { screenshotUrl: string; @@ -30,6 +31,25 @@ export default function SkinScroller({ const [visibleSkinIndex, setVisibleSkinIndex] = useState(0); const [fetching, setFetching] = useState(false); const [containerRef, setContainerRef] = useState(null); + const [hasEverScrolled, setHasEverScrolled] = useState(false); + + // Track if user has ever scrolled to another skin + useEffect(() => { + if (visibleSkinIndex > 0) { + setHasEverScrolled(true); + } + }, [visibleSkinIndex]); + + // Show scroll hint only if user has never scrolled to another skin + useScrollHint({ + containerRef, + enabled: visibleSkinIndex === 0 && !hasEverScrolled, + onHintShown: () => { + logUserEvent(sessionId, { + type: "scroll_hint_shown", + }); + }, + }); useLayoutEffect(() => { if (containerRef == null) { diff --git a/packages/skin-database/app/(modern)/scroll/useScrollHint.ts b/packages/skin-database/app/(modern)/scroll/useScrollHint.ts new file mode 100644 index 00000000..f16abf7b --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/useScrollHint.ts @@ -0,0 +1,99 @@ +import { useEffect } from "react"; + +type UseScrollHintOptions = { + containerRef: HTMLDivElement | null; + enabled: boolean; + delayMs?: number; + scrollAmount?: number; + animationDuration?: number; + onHintShown?: () => void; +}; + +/** + * A hook that provides a gentle scroll hint animation to encourage user interaction. + * After a delay, if the user hasn't scrolled, it will scroll down slightly and bounce back. + */ +export function useScrollHint({ + containerRef, + enabled, + delayMs = 5000, + scrollAmount = 80, + animationDuration = 1000, + onHintShown, +}: UseScrollHintOptions) { + useEffect(() => { + if (containerRef == null || !enabled) { + return; + } + + const hintTimer = setTimeout(() => { + if (!enabled || containerRef.scrollTop !== 0) { + return; + } + + const startScrollTop = containerRef.scrollTop; + const startTime = Date.now(); + + // Temporarily disable scroll snap for smooth animation + const originalScrollSnapType = containerRef.style.scrollSnapType; + containerRef.style.scrollSnapType = "none"; + + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / animationDuration, 1); + + // Bouncy easing function - overshoots and bounces back + const easeOutBounce = (t: number) => { + const n1 = 7.5625; + const d1 = 2.75; + if (t < 1 / d1) { + return n1 * t * t; + } else if (t < 2 / d1) { + return n1 * (t -= 1.5 / d1) * t + 0.75; + } else if (t < 2.5 / d1) { + return n1 * (t -= 2.25 / d1) * t + 0.9375; + } else { + return n1 * (t -= 2.625 / d1) * t + 0.984375; + } + }; + + // Create a bounce effect: scroll down quickly, then bounce back + let offset; + if (progress < 0.4) { + // First 40%: scroll down quickly + const t = progress / 0.4; + offset = scrollAmount * t * t; // Quadratic ease-in + } else { + // Last 60%: bounce back with overshoot + const t = (progress - 0.4) / 0.6; + offset = scrollAmount * (1 - easeOutBounce(t)); + } + + containerRef.scrollTop = startScrollTop + offset; + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + // Ensure we end exactly where we started + containerRef.scrollTop = startScrollTop; + // Re-enable scroll snap + containerRef.style.scrollSnapType = originalScrollSnapType; + } + }; + + animate(); + onHintShown?.(); + }, delayMs); + + return () => { + clearTimeout(hintTimer); + }; + }, [ + containerRef, + enabled, + delayMs, + scrollAmount, + animationDuration, + onHintShown, + ]); +}