Add scroll hint

This commit is contained in:
Jordan Eldredge 2025-11-07 19:41:50 -08:00
parent f1339901e6
commit d6245c7c7e
3 changed files with 122 additions and 0 deletions

View file

@ -97,4 +97,7 @@ export type UserEvent =
| { | {
type: "menu_click"; type: "menu_click";
menuItem: string; menuItem: string;
}
| {
type: "scroll_hint_shown";
}; };

View file

@ -3,6 +3,7 @@
import { useState, useLayoutEffect, useEffect } from "react"; import { useState, useLayoutEffect, useEffect } from "react";
import SkinPage from "./SkinPage"; import SkinPage from "./SkinPage";
import { logUserEvent } from "./Events"; import { logUserEvent } from "./Events";
import { useScrollHint } from "./useScrollHint";
export type ClientSkin = { export type ClientSkin = {
screenshotUrl: string; screenshotUrl: string;
@ -30,6 +31,25 @@ export default function SkinScroller({
const [visibleSkinIndex, setVisibleSkinIndex] = useState(0); const [visibleSkinIndex, setVisibleSkinIndex] = useState(0);
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null); const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(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(() => { useLayoutEffect(() => {
if (containerRef == null) { if (containerRef == null) {

View file

@ -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,
]);
}