Files
core-cooldown/src/lib/utils/animate.ts

367 lines
9.6 KiB
TypeScript

import { animate } from "motion";
/**
* Svelte action: fade in + slide up on mount
*/
export function fadeIn(
node: HTMLElement,
options?: { duration?: number; delay?: number; y?: number },
) {
const { duration = 0.5, delay = 0, y = 15 } = options ?? {};
node.style.opacity = "0";
const controls = animate(
node,
{ opacity: [0, 1], transform: [`translateY(${y}px)`, "translateY(0px)"] },
{ duration, delay, easing: [0.22, 0.03, 0.26, 1] },
);
return {
destroy() {
controls.cancel();
},
};
}
/**
* Svelte action: scale in + fade on mount
*/
export function scaleIn(
node: HTMLElement,
options?: { duration?: number; delay?: number },
) {
const { duration = 0.6, delay = 0 } = options ?? {};
node.style.opacity = "0";
const controls = animate(
node,
{ opacity: [0, 1], transform: ["scale(0.92)", "scale(1)"] },
{ duration, delay, easing: [0.22, 0.03, 0.26, 1] },
);
return {
destroy() {
controls.cancel();
},
};
}
/**
* Svelte action: animate when scrolled into view (IntersectionObserver)
*/
export function inView(
node: HTMLElement,
options?: { delay?: number; y?: number; threshold?: number },
) {
const { delay = 0, y = 20, threshold = 0.05 } = options ?? {};
node.style.opacity = "0";
node.style.transform = `translateY(${y}px)`;
let controls: ReturnType<typeof animate> | null = null;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
controls = animate(
node,
{
opacity: [0, 1],
transform: [`translateY(${y}px)`, "translateY(0px)"],
},
{ duration: 0.45, delay, easing: [0.22, 0.03, 0.26, 1] },
);
observer.disconnect();
}
},
{ threshold },
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
controls?.cancel();
},
};
}
/**
* Svelte action: spring-scale press feedback on buttons
*/
export function pressable(node: HTMLElement) {
let active: ReturnType<typeof animate> | null = null;
function onDown() {
active?.cancel();
// Explicit [from, to] avoids transform conflicts with fadeIn's translateY
active = animate(
node,
{ transform: ["scale(1)", "scale(0.95)"] },
{ duration: 0.1, easing: [0.22, 0.03, 0.26, 1] },
);
}
function onUp() {
active?.cancel();
active = animate(
node,
{ transform: ["scale(0.95)", "scale(1)"] },
{ duration: 0.3, easing: [0.22, 1.2, 0.36, 1] },
);
}
node.addEventListener("mousedown", onDown);
node.addEventListener("mouseup", onUp);
node.addEventListener("mouseleave", onUp);
return {
destroy() {
node.removeEventListener("mousedown", onDown);
node.removeEventListener("mouseup", onUp);
node.removeEventListener("mouseleave", onUp);
active?.cancel();
},
};
}
/**
* Svelte action: animated glow band on hover.
* Creates a crisp ring "band" plus a soft atmospheric glow.
* Pass a hex color (e.g. "#ff4d00").
*
* Uses same-hue zero-alpha as the "off" state so the Web Animations API
* interpolates through the correct color channel instead of through black.
*/
export function glowHover(
node: HTMLElement,
options?: { color?: string },
) {
let color = options?.color ?? "#ff4d00";
let enterAnim: ReturnType<typeof animate> | null = null;
let leaveAnim: ReturnType<typeof animate> | null = null;
// "off" state: same hue at zero alpha (NOT transparent, which is rgba(0,0,0,0))
function off() { return `0 0 0 0px ${color}00, 0 0 0px 0px ${color}00`; }
function on() { return `0 0 0 1.5px ${color}90, 0 0 22px 6px ${color}40`; }
node.style.boxShadow = off();
function onEnter() {
leaveAnim?.cancel();
enterAnim = animate(
node,
{ boxShadow: [off(), on()] },
{ duration: 0.4, easing: [0.22, 0.03, 0.26, 1] },
);
}
function onLeave() {
enterAnim?.cancel();
leaveAnim = animate(
node,
{ boxShadow: [on(), off()] },
{ duration: 0.5, easing: [0.22, 0.03, 0.26, 1] },
);
}
node.addEventListener("mouseenter", onEnter);
node.addEventListener("mouseleave", onLeave);
return {
update(newOptions?: { color?: string }) {
color = newOptions?.color ?? "#ff4d00";
node.style.boxShadow = off();
},
destroy() {
node.removeEventListener("mouseenter", onEnter);
node.removeEventListener("mouseleave", onLeave);
enterAnim?.cancel();
leaveAnim?.cancel();
},
};
}
/**
* Svelte action: momentum-based grab-and-drag scrolling
* with elastic overscroll and spring-back at boundaries.
*
* IMPORTANT: The node must have exactly one child element (a wrapper div).
* Overscroll transforms are applied to that child, NOT to the scroll
* container itself (which would break overflow clipping).
*/
export function dragScroll(node: HTMLElement) {
const content = node.children[0] as HTMLElement | null;
let isDown = false;
let startY = 0;
let scrollStart = 0;
let lastY = 0;
let lastTime = 0;
let animFrame = 0;
let velocitySamples: number[] = [];
let overscrollAmount = 0;
let springAnim: ReturnType<typeof animate> | null = null;
function getMaxScroll() {
return node.scrollHeight - node.clientHeight;
}
function setOverscroll(amount: number) {
if (!content) return;
overscrollAmount = amount;
if (Math.abs(amount) < 0.5) {
content.style.transform = "";
overscrollAmount = 0;
} else {
content.style.transform = `translateY(${-amount}px)`;
}
}
function springBack() {
if (!content) return;
const from = overscrollAmount;
if (Math.abs(from) < 0.5) {
setOverscroll(0);
return;
}
springAnim = animate(
content,
{ transform: [`translateY(${-from}px)`, "translateY(0px)"] },
{ duration: 0.6, easing: [0.16, 1, 0.3, 1] },
);
overscrollAmount = 0;
// Ensure DOM is clean after animation completes
springAnim.finished.then(() => {
if (content) content.style.transform = "";
}).catch(() => { /* cancelled — onDown handles cleanup */ });
}
function forceReset() {
springAnim?.cancel();
cancelAnimationFrame(animFrame);
overscrollAmount = 0;
if (content) content.style.transform = "";
}
function onDown(e: MouseEvent) {
if (e.button !== 0) return;
const tag = (e.target as HTMLElement).tagName;
if (["BUTTON", "INPUT", "LABEL", "SELECT", "TEXTAREA"].includes(tag)) return;
// Force-reset any lingering animation/transform state
forceReset();
isDown = true;
startY = e.pageY;
scrollStart = node.scrollTop;
lastY = e.pageY;
lastTime = Date.now();
velocitySamples = [];
node.style.cursor = "grabbing";
}
function onMove(e: MouseEvent) {
if (!isDown) return;
e.preventDefault();
const y = e.pageY;
const now = Date.now();
const dt = now - lastTime;
if (dt > 0) {
velocitySamples.push((lastY - y) / dt);
if (velocitySamples.length > 5) velocitySamples.shift();
}
lastY = y;
lastTime = now;
const desiredScroll = scrollStart - (y - startY);
const maxScroll = getMaxScroll();
if (desiredScroll < 0) {
node.scrollTop = 0;
setOverscroll(desiredScroll * 0.3);
} else if (desiredScroll > maxScroll) {
node.scrollTop = maxScroll;
setOverscroll((desiredScroll - maxScroll) * 0.3);
} else {
node.scrollTop = desiredScroll;
if (overscrollAmount !== 0) setOverscroll(0);
}
}
function onUp() {
if (!isDown) return;
isDown = false;
node.style.cursor = "";
if (overscrollAmount !== 0) {
springBack();
return;
}
const avgVelocity = velocitySamples.length > 0
? velocitySamples.reduce((a, b) => a + b, 0) / velocitySamples.length
: 0;
// Clamp velocity (px/ms) and bail if negligible
const maxV = 4;
const v0 = Math.max(-maxV, Math.min(maxV, avgVelocity));
if (Math.abs(v0) < 0.005) return;
// Time-based exponential decay (iOS-style scroll physics).
// position(t) = start + v0 * tau * (1 - e^(-t/tau))
// velocity(t) = v0 * e^(-t/tau) → naturally asymptotes to zero
const tau = 325; // time constant in ms — iOS UIScrollView feel
const coastStart = performance.now();
const scrollStart2 = node.scrollTop;
const totalDist = v0 * tau;
function coast() {
const t = performance.now() - coastStart;
const decay = Math.exp(-t / tau);
const offset = totalDist * (1 - decay);
const targetScroll = scrollStart2 + offset;
const maxScroll = getMaxScroll();
if (targetScroll < 0) {
node.scrollTop = 0;
const currentV = Math.abs(v0 * decay);
const bounce = Math.min(40, currentV * 50);
setOverscroll(-bounce);
springBack();
return;
}
if (targetScroll > maxScroll) {
node.scrollTop = maxScroll;
const currentV = Math.abs(v0 * decay);
const bounce = Math.min(40, currentV * 50);
setOverscroll(bounce);
springBack();
return;
}
node.scrollTop = targetScroll;
// Stop when velocity < 0.5 px/sec (completely imperceptible)
if (Math.abs(v0 * decay) > 0.0005) {
animFrame = requestAnimationFrame(coast);
}
}
coast();
}
node.addEventListener("mousedown", onDown);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return {
destroy() {
node.removeEventListener("mousedown", onDown);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
forceReset();
},
};
}