Initial commit -- Core Cooldown v0.1.0
This commit is contained in:
366
src/lib/utils/animate.ts
Normal file
366
src/lib/utils/animate.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user