UNPKG

html-trajectory

Version:

Animate HTML elements along a curved path from their current position to a target element. Eg. a product photo flying into the cart icon.

153 lines (131 loc) 4.13 kB
export default function cannonBall( flyingId: string, targetId: string, options: { moveX?: boolean; moveY?: boolean; duration?: number; acceleration?: number; // in m/s^2 scale?: number; fly3D?: boolean; onTransitionEnd?: () => void; removeOriginal?: boolean; resetTransformation?: boolean; } = {} ) { const { moveX = true, moveY = true, duration = 1.2, acceleration = 9.81, scale = 1, fly3D = false, onTransitionEnd = () => {}, removeOriginal = true, resetTransformation = false, } = options; const flyingEl = document.getElementById(flyingId); if (!flyingEl) return; const targetEl = document.getElementById(targetId); if (!targetEl) return; const flyingRect = flyingEl.getBoundingClientRect(); const targetRect = targetEl.getBoundingClientRect(); const deltaX = (targetRect.left + targetRect.right) / 2 - (flyingRect.left + flyingRect.right) / 2; const deltaY = (targetRect.top + targetRect.bottom) / 2 - (flyingRect.top + flyingRect.bottom) / 2; const wrapper = document.createElement('div'); Object.assign(wrapper.style, { position: 'absolute', top: `${flyingRect.top}px`, left: `${flyingRect.left}px`, width: `${flyingRect.width}px`, height: `${flyingRect.height}px`, zIndex: '99', overflow: 'visible', pointerEvents: 'none', }); const clone = flyingEl.cloneNode(true) as HTMLElement; Object.assign(clone.style, { position: 'absolute', top: '0px', left: '0px', width: '100%', height: '100%', }); // Reset transformOrigin if option is enabled if (resetTransformation) { clone.style.transform = 'none'; clone.style.transformOrigin = 'center center'; } // Add html-trajectory-cloned class clone.classList.add("html-trajectory-cloned"); wrapper.appendChild(clone); document.body.appendChild(wrapper); wrapper.getBoundingClientRect(); // Remove the original element if removeOriginal is true if (removeOriginal) { flyingEl.remove(); } const effectiveAcceleration = fly3D ? 0 : acceleration; const accelerationPx = effectiveAcceleration * 100; // convert m/s² to px/s² if (moveX) { wrapper.style.transform = `translateX(${deltaX}px)`; wrapper.style.transition = `transform ${duration}s linear`; } if (moveY) { const s = deltaY; const v0 = accelerationPx === 0 ? s / duration : (s - 0.5 * accelerationPx * duration * duration) / duration; let startTime: number | null = null; function animateY(timestamp: number) { if (!startTime) startTime = timestamp; const elapsed = (timestamp - startTime) / 1000; if (elapsed >= duration) { clone.style.transform = `translateY(${deltaY}px) scale(${scale})`; // Make sure this callback is definitely executed try { onTransitionEnd(); } catch (e) { console.error("Error in onTransitionEnd callback", e); } wrapper.remove(); if (removeOriginal) { flyingEl?.remove(); } return; } const displacement = accelerationPx === 0 ? v0 * elapsed : v0 * elapsed + 0.5 * accelerationPx * elapsed * elapsed; const progress = elapsed / duration; let currentScale = 1; if (progress > 0.66) { const t = (progress - 0.66) / 0.34; currentScale = 1 + (scale - 1) * t; } clone.style.transform = `translateY(${displacement}px) scale(${currentScale})`; requestAnimationFrame(animateY); } requestAnimationFrame(animateY); } if (!moveY) { clone.style.transform = `none`; } if (!moveX && !moveY) { setTimeout(() => { onTransitionEnd(); wrapper.remove(); if (removeOriginal) { flyingEl.remove(); } }, duration * 1000); } else if (!moveX && moveY) { // handled by animateY } else if (moveX && !moveY) { wrapper.addEventListener('transitionend', () => { onTransitionEnd(); wrapper.remove(); if (removeOriginal) { flyingEl.remove(); } }, { once: true }); } }