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
text/typescript
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 });
}
}