From 0ebc3d0f8c3b6c1e8e2e5d7b40dcad49ef5ea43d Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:08:58 +0100 Subject: [PATCH] fix(calendar): use pure JS animation for truly linear swipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace CSS transitions with requestAnimationFrame animation: - Constant speed (2.5 px/ms) throughout entire animation - No easing at all - completely linear movement - Smoother continuation from user gesture to animation - Remove CSS transition class entirely 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/calendar/ViewCarousel.svelte | 78 +++++++++++++------ 1 file changed, 53 insertions(+), 25 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte b/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte index 963941bb2..65b99b8ef 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte @@ -23,13 +23,15 @@ let startX = $state(0); let isSwiping = $state(false); let isAnimating = $state(false); - let animationDuration = $state(0); // Velocity tracking for momentum let lastX = 0; let lastTime = 0; let velocity = 0; + // Animation frame tracking + let animationFrameId: number | null = null; + // Container refs let viewportEl: HTMLDivElement; let viewportWidth = $state(0); @@ -37,6 +39,8 @@ // Threshold: 15% of viewport width or high velocity triggers navigation const SNAP_THRESHOLD = 0.15; const VELOCITY_THRESHOLD = 0.3; // px/ms + // Animation speed (px/ms) - constant speed for linear feel + const ANIMATION_SPEED = 2.5; // Debounce for wheel events const WHEEL_DEBOUNCE_MS = 80; let wheelDebounceTimer: ReturnType | null = null; @@ -132,9 +136,8 @@ function handleTouchCancel() { if (!isSwiping) return; isSwiping = false; - const distance = Math.abs(offsetX); isAnimating = true; - animateToOffset(0, distance, () => { + animateToOffset(0, () => { isAnimating = false; }); } @@ -158,42 +161,71 @@ isAnimating = true; if (targetPage === 'prev') { - const distance = viewportWidth - offsetX; - animateToOffset(viewportWidth, distance, () => { + animateToOffset(viewportWidth, () => { viewStore.goToPrevious(); offsetX = 0; isAnimating = false; }); } else if (targetPage === 'next') { - const distance = viewportWidth + offsetX; - animateToOffset(-viewportWidth, distance, () => { + animateToOffset(-viewportWidth, () => { viewStore.goToNext(); offsetX = 0; isAnimating = false; }); } else { - const distance = Math.abs(offsetX); - animateToOffset(0, distance, () => { + animateToOffset(0, () => { isAnimating = false; }); } } - function animateToOffset(targetX: number, distance: number, onComplete: () => void) { - // Calculate duration based on distance (faster for shorter distances) - // Min 80ms, max 200ms, scales with distance - const baseDuration = 150; - const duration = Math.min(200, Math.max(80, (distance / viewportWidth) * baseDuration)); - animationDuration = duration; + function animateToOffset(targetX: number, onComplete: () => void) { + // Cancel any existing animation + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + } - offsetX = targetX; - setTimeout(onComplete, duration); + const startX = offsetX; + const distance = targetX - startX; + const direction = Math.sign(distance); + const absDistance = Math.abs(distance); + + // If already at target, complete immediately + if (absDistance < 1) { + offsetX = targetX; + onComplete(); + return; + } + + let lastFrameTime = performance.now(); + + function tick() { + const now = performance.now(); + const dt = now - lastFrameTime; + lastFrameTime = now; + + // Move at constant speed + const step = ANIMATION_SPEED * dt * direction; + offsetX += step; + + // Check if we've reached or passed the target + const reachedTarget = + (direction > 0 && offsetX >= targetX) || (direction < 0 && offsetX <= targetX); + + if (reachedTarget) { + offsetX = targetX; + animationFrameId = null; + onComplete(); + } else { + animationFrameId = requestAnimationFrame(tick); + } + } + + animationFrameId = requestAnimationFrame(tick); } // Computed styles - let trackStyle = $derived( - `transform: translateX(calc(-33.333% + ${offsetX}px)); --duration: ${animationDuration}ms` - ); + let trackStyle = $derived(`transform: translateX(calc(-33.333% + ${offsetX}px))`); @@ -206,7 +238,7 @@ ontouchend={handleTouchEnd} ontouchcancel={handleTouchCancel} > -