Last active
January 8, 2026 16:16
-
-
Save maximilliangeorge/f5f1e9840942be5af2f09f29b7cb3a25 to your computer and use it in GitHub Desktop.
Utility to scroll through a website for demonstration purposes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Cubic Bezier utility (from CSS spec) | |
| function cubicBezier(p1x, p1y, p2x, p2y) { | |
| const cx = 3 * p1x | |
| const bx = 3 * (p2x - p1x) - cx | |
| const ax = 1 - cx - bx | |
| const cy = 3 * p1y | |
| const by = 3 * (p2y - p1y) - cy | |
| const ay = 1 - cy - by | |
| function sampleX(t) { | |
| return ((ax * t + bx) * t + cx) * t | |
| } | |
| function sampleY(t) { | |
| return ((ay * t + by) * t + cy) * t | |
| } | |
| function sampleDX(t) { | |
| return (3 * ax * t + 2 * bx) * t + cx | |
| } | |
| // Given x, find t via Newton–Raphson | |
| function solveT(x) { | |
| let t = x | |
| for (let i = 0; i < 5; i++) { | |
| const dx = sampleX(t) - x | |
| const d = sampleDX(t) | |
| if (Math.abs(dx) < 1e-6 || d === 0) break | |
| t -= dx / d | |
| } | |
| return t | |
| } | |
| return function (x) { | |
| if (x <= 0) return 0 | |
| if (x >= 1) return 1 | |
| return sampleY(solveT(x)) | |
| } | |
| } | |
| // Named easings (CSS-compatible) | |
| const easings = { | |
| linear: cubicBezier(0, 0, 1, 1), | |
| 'ease': cubicBezier(0.25, 0.1, 0.25, 1), | |
| 'ease-in': cubicBezier(0.42, 0, 1, 1), | |
| 'ease-out': cubicBezier(0, 0, 0.58, 1), | |
| 'ease-in-out': cubicBezier(0.42, 0, 0.58, 1), | |
| 'cubic-out': cubicBezier(0.33, 1, 0.68, 1), | |
| 'cubic-in': cubicBezier(0.32, 0, 0.67, 0), | |
| 'cubic-in-out': cubicBezier(0.65, 0, 0.35, 1), | |
| 'scroll-1': cubicBezier(0.43, 0.19, 0, 1) | |
| } | |
| function scrollToY(targetY, duration = 600, easing = 'ease-in-out') { | |
| return new Promise(resolve => { | |
| const startY = window.scrollY | |
| const delta = targetY - startY | |
| const startTime = performance.now() | |
| const ease = easings[easing] ?? easings.linear | |
| let lastY = startY | |
| function tick(now) { | |
| const t = Math.min((now - startTime) / duration, 1) | |
| const desiredY = startY + delta * ease(t) | |
| const dy = desiredY - lastY | |
| // Incremental scroll → preserves momentum | |
| window.scrollBy(0, dy) | |
| lastY += dy | |
| if (t < 1) { | |
| requestAnimationFrame(tick) | |
| } else { | |
| resolve() | |
| } | |
| } | |
| requestAnimationFrame(tick) | |
| }) | |
| } | |
| function pause(ms) { | |
| return new Promise(resolve => { | |
| const start = performance.now() | |
| function tick(now) { | |
| // 0.1px drift keeps scroll state alive | |
| window.scrollBy(0, 0.1) | |
| if (now - start < ms) { | |
| requestAnimationFrame(tick) | |
| } else { | |
| resolve() | |
| } | |
| } | |
| requestAnimationFrame(tick) | |
| }) | |
| } | |
| function runOnFirstGesture(fn) { | |
| let started = false | |
| function start() { | |
| if (started) return | |
| started = true | |
| window.removeEventListener('touchstart', start) | |
| window.removeEventListener('wheel', start) | |
| fn() | |
| } | |
| window.addEventListener('touchstart', start, { passive: true }) | |
| window.addEventListener('wheel', start, { passive: true }) | |
| } | |
| async function scrollPipeMomentum(steps) { | |
| for (const [targetY, duration, easing] of steps) { | |
| if (targetY == null) { | |
| await pause(duration) | |
| } else { | |
| await scrollToY(targetY, duration, easing) | |
| } | |
| } | |
| } | |
| async function scrollPipe(steps) { | |
| runOnFirstGesture(() => { | |
| scrollPipeMomentum(steps) | |
| }) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment