Skip to content

Commit 40f28ef

Browse files
committed
Add iPhone-style damped smooth scrolling and hide cursor follower on mobile/tablet
Replace native browser momentum scrolling with a custom lerp-based scroll engine that caps scroll delta per event and decelerates smoothly, mimicking iOS-style scroll behavior. Hide the cursor follower on screens <= 1024px since it serves no purpose on phones and tablets.
1 parent 1b811ff commit 40f28ef

1 file changed

Lines changed: 273 additions & 12 deletions

File tree

index.html

Lines changed: 273 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@
132132
html, body {
133133
height: 100%;
134134
overflow: hidden;
135-
scroll-behavior: smooth;
136135
background-color: var(--bg-light);
137136
}
138137

@@ -337,8 +336,8 @@
337336
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
338337
will-change: opacity;
339338
overflow-y: auto;
340-
scroll-behavior: smooth;
341339
-webkit-overflow-scrolling: touch;
340+
overscroll-behavior: contain;
342341
padding-bottom: 52px;
343342
scrollbar-width: thin;
344343
scrollbar-color: transparent transparent;
@@ -975,6 +974,10 @@
975974
.content {
976975
grid-column: 2 / 3;
977976
}
977+
978+
.cursor-follower {
979+
display: none !important;
980+
}
978981
}
979982

980983

@@ -985,6 +988,7 @@
985988
html, body {
986989
overflow: auto;
987990
height: auto;
991+
overscroll-behavior: contain;
988992
}
989993

990994
.mobile-top-bar {
@@ -2214,6 +2218,12 @@
22142218
if (targetSection) {
22152219
targetSection.classList.add('active');
22162220
if (typeof badgeRectsDirty !== 'undefined') badgeRectsDirty = true;
2221+
// Reset smooth scroller to current scroll position of newly active section
2222+
const scroller = smoothScrollInstances.get(targetSection);
2223+
if (scroller) {
2224+
scroller.targetScroll = targetSection.scrollTop;
2225+
scroller.currentScroll = targetSection.scrollTop;
2226+
}
22172227
}
22182228
navItems.forEach(nav => {
22192229
nav.classList.remove('active');
@@ -2338,7 +2348,253 @@
23382348

23392349

23402350
/* ============================================
2341-
7. SCROLL INDICATOR
2351+
7. SMOOTH SCROLL ENGINE (iPhone-style damped)
2352+
============================================ */
2353+
const smoothScrollInstances = new Map();
2354+
2355+
class SmoothScroller {
2356+
constructor(el) {
2357+
this.el = el;
2358+
this.targetScroll = el.scrollTop;
2359+
this.currentScroll = el.scrollTop;
2360+
this.running = false;
2361+
this.lerpFactor = 0.1; // deceleration: lower = smoother/slower stop
2362+
this.maxWheelDelta = 120; // cap per wheel event (prevents overshooting)
2363+
this.touchMultiplier = 1.0; // touch swipe sensitivity
2364+
this.maxTouchVelocity = 6; // cap touch fling speed
2365+
this.velocityDecay = 0.92; // touch momentum decay per frame
2366+
2367+
// Touch state
2368+
this._touchStartY = 0;
2369+
this._touchLastY = 0;
2370+
this._touchVelocity = 0;
2371+
this._touchTimestamp = 0;
2372+
this._isTouching = false;
2373+
this._touchMomentum = false;
2374+
2375+
// Bind handlers
2376+
this._onWheel = this._onWheel.bind(this);
2377+
this._onTouchStart = this._onTouchStart.bind(this);
2378+
this._onTouchMove = this._onTouchMove.bind(this);
2379+
this._onTouchEnd = this._onTouchEnd.bind(this);
2380+
this._onKeyDown = this._onKeyDown.bind(this);
2381+
this._tick = this._tick.bind(this);
2382+
2383+
this._attach();
2384+
}
2385+
2386+
_attach() {
2387+
this.el.addEventListener('wheel', this._onWheel, { passive: false });
2388+
this.el.addEventListener('touchstart', this._onTouchStart, { passive: true });
2389+
this.el.addEventListener('touchmove', this._onTouchMove, { passive: false });
2390+
this.el.addEventListener('touchend', this._onTouchEnd, { passive: true });
2391+
}
2392+
2393+
destroy() {
2394+
this.el.removeEventListener('wheel', this._onWheel);
2395+
this.el.removeEventListener('touchstart', this._onTouchStart);
2396+
this.el.removeEventListener('touchmove', this._onTouchMove);
2397+
this.el.removeEventListener('touchend', this._onTouchEnd);
2398+
this.running = false;
2399+
}
2400+
2401+
get maxScroll() {
2402+
return Math.max(0, this.el.scrollHeight - this.el.clientHeight);
2403+
}
2404+
2405+
get isScrollable() {
2406+
return this.el.scrollHeight > this.el.clientHeight;
2407+
}
2408+
2409+
_clamp(val) {
2410+
return Math.max(0, Math.min(val, this.maxScroll));
2411+
}
2412+
2413+
scrollTo(target, immediate) {
2414+
this.targetScroll = this._clamp(target);
2415+
if (immediate) {
2416+
this.currentScroll = this.targetScroll;
2417+
this.el.scrollTop = this.currentScroll;
2418+
}
2419+
this._touchMomentum = false;
2420+
this._startLoop();
2421+
}
2422+
2423+
_onWheel(e) {
2424+
if (!this.isScrollable) return;
2425+
// Don't hijack horizontal scrolling
2426+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
2427+
e.preventDefault();
2428+
2429+
// Clamp the delta to prevent overshooting from fast flicks
2430+
let delta = e.deltaY;
2431+
if (e.deltaMode === 1) delta *= 40; // line mode
2432+
if (e.deltaMode === 2) delta *= this.el.clientHeight; // page mode
2433+
delta = Math.max(-this.maxWheelDelta, Math.min(this.maxWheelDelta, delta));
2434+
2435+
this.targetScroll = this._clamp(this.targetScroll + delta);
2436+
this._touchMomentum = false;
2437+
this._startLoop();
2438+
}
2439+
2440+
_onTouchStart(e) {
2441+
if (!this.isScrollable) return;
2442+
const touch = e.touches[0];
2443+
this._touchStartY = touch.clientY;
2444+
this._touchLastY = touch.clientY;
2445+
this._touchVelocity = 0;
2446+
this._touchTimestamp = performance.now();
2447+
this._isTouching = true;
2448+
this._touchMomentum = false;
2449+
// Sync target to current actual position to stop any ongoing animation
2450+
this.targetScroll = this.el.scrollTop;
2451+
this.currentScroll = this.el.scrollTop;
2452+
}
2453+
2454+
_onTouchMove(e) {
2455+
if (!this._isTouching || !this.isScrollable) return;
2456+
const touch = e.touches[0];
2457+
const now = performance.now();
2458+
const dt = now - this._touchTimestamp;
2459+
const dy = this._touchLastY - touch.clientY;
2460+
2461+
// Only prevent default if we're actually scrolling vertically
2462+
if (Math.abs(dy) > 0) {
2463+
e.preventDefault();
2464+
}
2465+
2466+
// Calculate velocity (px/ms)
2467+
if (dt > 0) {
2468+
this._touchVelocity = (dy / dt) * this.touchMultiplier;
2469+
}
2470+
2471+
this._touchLastY = touch.clientY;
2472+
this._touchTimestamp = now;
2473+
2474+
// Apply touch movement directly (1:1 tracking while finger is down)
2475+
this.targetScroll = this._clamp(this.targetScroll + dy);
2476+
this.currentScroll = this.targetScroll;
2477+
this.el.scrollTop = this.currentScroll;
2478+
updateScrollIndicator();
2479+
}
2480+
2481+
_onTouchEnd() {
2482+
if (!this._isTouching) return;
2483+
this._isTouching = false;
2484+
2485+
// Cap the velocity for damped momentum
2486+
this._touchVelocity = Math.max(
2487+
-this.maxTouchVelocity,
2488+
Math.min(this.maxTouchVelocity, this._touchVelocity)
2489+
);
2490+
2491+
// If there's meaningful velocity, start momentum
2492+
if (Math.abs(this._touchVelocity) > 0.05) {
2493+
this._touchMomentum = true;
2494+
this._startLoop();
2495+
}
2496+
}
2497+
2498+
_onKeyDown(e) {
2499+
if (!this.isScrollable) return;
2500+
2501+
let delta = 0;
2502+
const pageStep = this.el.clientHeight * 0.85;
2503+
2504+
switch (e.key) {
2505+
case 'ArrowDown': delta = 60; break;
2506+
case 'ArrowUp': delta = -60; break;
2507+
case 'PageDown': delta = pageStep; break;
2508+
case 'PageUp': delta = -pageStep; break;
2509+
case ' ':
2510+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
2511+
delta = e.shiftKey ? -pageStep : pageStep;
2512+
break;
2513+
case 'Home':
2514+
this.targetScroll = 0;
2515+
this._touchMomentum = false;
2516+
this._startLoop();
2517+
e.preventDefault();
2518+
return;
2519+
case 'End':
2520+
this.targetScroll = this.maxScroll;
2521+
this._touchMomentum = false;
2522+
this._startLoop();
2523+
e.preventDefault();
2524+
return;
2525+
default: return;
2526+
}
2527+
2528+
e.preventDefault();
2529+
this.targetScroll = this._clamp(this.targetScroll + delta);
2530+
this._touchMomentum = false;
2531+
this._startLoop();
2532+
}
2533+
2534+
_startLoop() {
2535+
if (!this.running) {
2536+
this.running = true;
2537+
requestAnimationFrame(this._tick);
2538+
}
2539+
}
2540+
2541+
_tick() {
2542+
if (!this.running) return;
2543+
2544+
if (this._touchMomentum) {
2545+
// Damped momentum from touch fling
2546+
this._touchVelocity *= this.velocityDecay;
2547+
this.targetScroll = this._clamp(this.targetScroll + this._touchVelocity * 16);
2548+
2549+
// Stop when velocity is negligible
2550+
if (Math.abs(this._touchVelocity) < 0.01) {
2551+
this._touchMomentum = false;
2552+
}
2553+
}
2554+
2555+
// Lerp toward target
2556+
const diff = this.targetScroll - this.currentScroll;
2557+
2558+
if (Math.abs(diff) < 0.5) {
2559+
// Close enough, snap and stop
2560+
this.currentScroll = this.targetScroll;
2561+
this.el.scrollTop = this.currentScroll;
2562+
this.running = false;
2563+
updateScrollIndicator();
2564+
return;
2565+
}
2566+
2567+
this.currentScroll += diff * this.lerpFactor;
2568+
this.el.scrollTop = this.currentScroll;
2569+
updateScrollIndicator();
2570+
2571+
requestAnimationFrame(this._tick);
2572+
}
2573+
}
2574+
2575+
// Initialize smooth scrollers for all sections
2576+
function initSmoothScrollers() {
2577+
sections.forEach(section => {
2578+
if (!smoothScrollInstances.has(section)) {
2579+
const scroller = new SmoothScroller(section);
2580+
smoothScrollInstances.set(section, scroller);
2581+
}
2582+
});
2583+
}
2584+
2585+
initSmoothScrollers();
2586+
2587+
// Global keyboard scroll handler (sections don't receive focus natively)
2588+
document.addEventListener('keydown', function(e) {
2589+
const activeSection = document.querySelector('section.active');
2590+
if (!activeSection) return;
2591+
const scroller = smoothScrollInstances.get(activeSection);
2592+
if (scroller) scroller._onKeyDown(e);
2593+
});
2594+
2595+
2596+
/* ============================================
2597+
7b. SCROLL INDICATOR
23422598
============================================ */
23432599
const scrollIndicatorDown = document.getElementById('scroll-indicator-down');
23442600
const scrollIndicatorUp = document.getElementById('scroll-indicator-up');
@@ -2348,10 +2604,11 @@
23482604
function updateScrollIndicator() {
23492605
const activeSection = document.querySelector('section.active');
23502606
if (!activeSection) return;
2607+
const scroller = smoothScrollInstances.get(activeSection);
23512608
const isScrollable = activeSection.scrollHeight > activeSection.clientHeight;
2352-
const isAtBottom = activeSection.scrollTop + activeSection.clientHeight >= activeSection.scrollHeight - 20;
2353-
const isAtTop = activeSection.scrollTop <= 20;
2354-
const currentScrollTop = activeSection.scrollTop;
2609+
const currentScrollTop = scroller ? scroller.currentScroll : activeSection.scrollTop;
2610+
const isAtBottom = currentScrollTop + activeSection.clientHeight >= activeSection.scrollHeight - 20;
2611+
const isAtTop = currentScrollTop <= 20;
23552612

23562613
if (currentScrollTop < lastScrollTop) {
23572614
scrollDirection = 'up';
@@ -2373,7 +2630,11 @@
23732630
if (scrollIndicatorDown) {
23742631
scrollIndicatorDown.addEventListener('click', () => {
23752632
const activeSection = document.querySelector('section.active');
2376-
if (activeSection) {
2633+
if (!activeSection) return;
2634+
const scroller = smoothScrollInstances.get(activeSection);
2635+
if (scroller) {
2636+
scroller.scrollTo(scroller.maxScroll);
2637+
} else {
23772638
activeSection.scrollTo({ top: activeSection.scrollHeight, behavior: 'smooth' });
23782639
}
23792640
});
@@ -2382,16 +2643,16 @@
23822643
if (scrollIndicatorUp) {
23832644
scrollIndicatorUp.addEventListener('click', () => {
23842645
const activeSection = document.querySelector('section.active');
2385-
if (activeSection) {
2646+
if (!activeSection) return;
2647+
const scroller = smoothScrollInstances.get(activeSection);
2648+
if (scroller) {
2649+
scroller.scrollTo(0);
2650+
} else {
23862651
activeSection.scrollTo({ top: 0, behavior: 'smooth' });
23872652
}
23882653
});
23892654
}
23902655

2391-
sections.forEach(section => {
2392-
section.addEventListener('scroll', updateScrollIndicator);
2393-
});
2394-
23952656

23962657

23972658
/* ============================================

0 commit comments

Comments
 (0)