@mlola-ui/motion 1.0.8 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/pointer-math.d.ts +15 -0
- package/dist/pointer-math.js +35 -0
- package/dist/primitives.d.ts +19 -0
- package/dist/primitives.js +107 -0
- package/dist/raf.d.ts +6 -0
- package/dist/raf.js +49 -0
- package/dist/reduced-motion.d.ts +1 -0
- package/dist/reduced-motion.js +16 -0
- package/dist/spring.d.ts +49 -0
- package/dist/spring.js +79 -0
- package/dist/tilt-3d.d.ts +8 -0
- package/dist/tilt-3d.js +76 -0
- package/package.json +20 -7
- package/src/index.ts +0 -6
- package/src/pointer-math.ts +0 -54
- package/src/primitives.tsx +0 -229
- package/src/raf.ts +0 -52
- package/src/reduced-motion.ts +0 -20
- package/src/spring.ts +0 -134
- package/src/tilt-3d.tsx +0 -139
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface PointerKinetics {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
deltaX: number;
|
|
5
|
+
deltaY: number;
|
|
6
|
+
velocityX: number;
|
|
7
|
+
velocityY: number;
|
|
8
|
+
speed: number;
|
|
9
|
+
angle: number;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
pointerType: string;
|
|
12
|
+
active: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare const idlePointerKinetics: PointerKinetics;
|
|
15
|
+
export declare function calculatePointerKinetics(previous: PointerKinetics, point: Pick<PointerKinetics, "x" | "y" | "timestamp" | "pointerType">, smoothing?: number): PointerKinetics;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const idlePointerKinetics = Object.freeze({
|
|
2
|
+
x: 0,
|
|
3
|
+
y: 0,
|
|
4
|
+
deltaX: 0,
|
|
5
|
+
deltaY: 0,
|
|
6
|
+
velocityX: 0,
|
|
7
|
+
velocityY: 0,
|
|
8
|
+
speed: 0,
|
|
9
|
+
angle: 0,
|
|
10
|
+
timestamp: 0,
|
|
11
|
+
pointerType: "",
|
|
12
|
+
active: false,
|
|
13
|
+
});
|
|
14
|
+
export function calculatePointerKinetics(previous, point, smoothing = 0.25) {
|
|
15
|
+
const deltaX = previous.active ? point.x - previous.x : 0;
|
|
16
|
+
const deltaY = previous.active ? point.y - previous.y : 0;
|
|
17
|
+
const elapsed = previous.active
|
|
18
|
+
? Math.max(1, Math.min(64, point.timestamp - previous.timestamp))
|
|
19
|
+
: 16;
|
|
20
|
+
const instantX = (deltaX / elapsed) * 1000;
|
|
21
|
+
const instantY = (deltaY / elapsed) * 1000;
|
|
22
|
+
const blend = Math.max(0, Math.min(1, smoothing));
|
|
23
|
+
const velocityX = previous.velocityX + (instantX - previous.velocityX) * blend;
|
|
24
|
+
const velocityY = previous.velocityY + (instantY - previous.velocityY) * blend;
|
|
25
|
+
return {
|
|
26
|
+
...point,
|
|
27
|
+
deltaX,
|
|
28
|
+
deltaY,
|
|
29
|
+
velocityX,
|
|
30
|
+
velocityY,
|
|
31
|
+
speed: Math.hypot(velocityX, velocityY),
|
|
32
|
+
angle: Math.atan2(velocityY, velocityX),
|
|
33
|
+
active: true,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface AnimatedCounterProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
3
|
+
value: number | string;
|
|
4
|
+
stagger?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function AnimatedCounter({ value, className, stagger, "aria-label": spoken, ...props }: AnimatedCounterProps): React.JSX.Element;
|
|
7
|
+
export interface TextSwapProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function TextSwap({ text, className, style, ...props }: TextSwapProps): React.JSX.Element;
|
|
11
|
+
export declare function ShimmerText({ children, className, ...props }: React.HTMLAttributes<HTMLSpanElement>): React.JSX.Element;
|
|
12
|
+
export interface MagneticProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
13
|
+
pull?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare const Magnetic: React.ForwardRefExoticComponent<MagneticProps & React.RefAttributes<HTMLDivElement>>;
|
|
16
|
+
export interface OriginMorphProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
17
|
+
isOpen: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function OriginMorph({ isOpen, children, className, style, ...props }: OriginMorphProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createElement as _createElement } from "react";
|
|
3
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { useReducedMotion } from "./reduced-motion.js";
|
|
6
|
+
import { createSpringAnimation, springPresets } from "./spring.js";
|
|
7
|
+
function joinClasses(...values) {
|
|
8
|
+
return values.filter(Boolean).join(" ");
|
|
9
|
+
}
|
|
10
|
+
export function AnimatedCounter({ value, className, stagger = 35, "aria-label": spoken, ...props }) {
|
|
11
|
+
const reducedMotion = useReducedMotion();
|
|
12
|
+
const text = String(value);
|
|
13
|
+
return (_jsxs("span", { ...props, className: joinClasses("ml-motion-counter", className), children: [_jsx("span", { className: "ml-visually-hidden", children: spoken ?? text }), Array.from(text).map((character, index) => (_jsx("span", { "aria-hidden": "true", className: "ml-motion-counter-digit", style: reducedMotion
|
|
14
|
+
? undefined
|
|
15
|
+
: {
|
|
16
|
+
animation: "counter-flip 280ms cubic-bezier(0.34, 1.56, 0.64, 1) both",
|
|
17
|
+
animationDelay: `${index * stagger}ms`,
|
|
18
|
+
}, children: character }, `${index}-${character}`)))] }));
|
|
19
|
+
}
|
|
20
|
+
export function TextSwap({ text, className, style, ...props }) {
|
|
21
|
+
const reducedMotion = useReducedMotion();
|
|
22
|
+
return (_createElement("span", { ...props, key: text, className: joinClasses("ml-motion-text", className), style: {
|
|
23
|
+
animation: reducedMotion
|
|
24
|
+
? undefined
|
|
25
|
+
: "text-swap 240ms cubic-bezier(0.16, 1, 0.3, 1) both",
|
|
26
|
+
...style,
|
|
27
|
+
} }, text));
|
|
28
|
+
}
|
|
29
|
+
export function ShimmerText({ children, className, ...props }) {
|
|
30
|
+
const reducedMotion = useReducedMotion();
|
|
31
|
+
return (_jsx("span", { ...props, className: joinClasses(!reducedMotion && "ml-motion-shimmer", className), children: children }));
|
|
32
|
+
}
|
|
33
|
+
export const Magnetic = React.forwardRef(({ children, pull = 0.25, className, style, onPointerMove, onPointerLeave, onPointerCancel, ...props }, forwardedRef) => {
|
|
34
|
+
const reducedMotion = useReducedMotion();
|
|
35
|
+
const nodeRef = React.useRef(null);
|
|
36
|
+
const offset = React.useRef({ x: 0, y: 0 });
|
|
37
|
+
const setRef = React.useCallback((node) => {
|
|
38
|
+
nodeRef.current = node;
|
|
39
|
+
if (typeof forwardedRef === "function")
|
|
40
|
+
forwardedRef(node);
|
|
41
|
+
else if (forwardedRef)
|
|
42
|
+
forwardedRef.current = node;
|
|
43
|
+
}, [forwardedRef]);
|
|
44
|
+
// The pull is a real spring, not a CSS transition: it can be interrupted
|
|
45
|
+
// mid-flight, and the loop stops the moment it comes to rest.
|
|
46
|
+
const paint = React.useCallback(() => {
|
|
47
|
+
const node = nodeRef.current;
|
|
48
|
+
if (!node)
|
|
49
|
+
return;
|
|
50
|
+
node.style.transform = `translate3d(${offset.current.x.toFixed(2)}px, ${offset.current.y.toFixed(2)}px, 0)`;
|
|
51
|
+
}, []);
|
|
52
|
+
const springX = React.useMemo(() => createSpringAnimation({
|
|
53
|
+
options: springPresets.snappy,
|
|
54
|
+
onUpdate: (value) => {
|
|
55
|
+
offset.current.x = value;
|
|
56
|
+
paint();
|
|
57
|
+
},
|
|
58
|
+
}), [paint]);
|
|
59
|
+
const springY = React.useMemo(() => createSpringAnimation({
|
|
60
|
+
options: springPresets.snappy,
|
|
61
|
+
onUpdate: (value) => {
|
|
62
|
+
offset.current.y = value;
|
|
63
|
+
paint();
|
|
64
|
+
},
|
|
65
|
+
}), [paint]);
|
|
66
|
+
React.useEffect(() => () => {
|
|
67
|
+
springX.stop();
|
|
68
|
+
springY.stop();
|
|
69
|
+
}, [springX, springY]);
|
|
70
|
+
const moveTo = (x, y) => {
|
|
71
|
+
springX.setTarget(x);
|
|
72
|
+
springY.setTarget(y);
|
|
73
|
+
};
|
|
74
|
+
const reset = () => moveTo(0, 0);
|
|
75
|
+
const handlePointerMove = (event) => {
|
|
76
|
+
onPointerMove?.(event);
|
|
77
|
+
const node = nodeRef.current;
|
|
78
|
+
if (event.defaultPrevented || reducedMotion || event.pointerType === "touch" || !node)
|
|
79
|
+
return;
|
|
80
|
+
const rect = node.getBoundingClientRect();
|
|
81
|
+
moveTo((event.clientX - (rect.left + rect.width / 2)) * pull, (event.clientY - (rect.top + rect.height / 2)) * pull);
|
|
82
|
+
};
|
|
83
|
+
React.useEffect(() => {
|
|
84
|
+
if (!reducedMotion)
|
|
85
|
+
return;
|
|
86
|
+
springX.set(0);
|
|
87
|
+
springY.set(0);
|
|
88
|
+
}, [reducedMotion, springX, springY]);
|
|
89
|
+
return (_jsx("div", { ...props, ref: setRef, onPointerMove: handlePointerMove, onPointerLeave: (event) => {
|
|
90
|
+
onPointerLeave?.(event);
|
|
91
|
+
reset();
|
|
92
|
+
}, onPointerCancel: (event) => {
|
|
93
|
+
onPointerCancel?.(event);
|
|
94
|
+
reset();
|
|
95
|
+
}, className: joinClasses("ml-motion-magnetic", className), style: {
|
|
96
|
+
transform: "translate3d(0, 0, 0)",
|
|
97
|
+
willChange: reducedMotion ? undefined : "transform",
|
|
98
|
+
...style,
|
|
99
|
+
}, children: children }));
|
|
100
|
+
});
|
|
101
|
+
Magnetic.displayName = "Magnetic";
|
|
102
|
+
export function OriginMorph({ isOpen, children, className, style, ...props }) {
|
|
103
|
+
const reducedMotion = useReducedMotion();
|
|
104
|
+
if (!isOpen)
|
|
105
|
+
return null;
|
|
106
|
+
return (_jsx("div", { ...props, className: joinClasses("ml-motion-origin-morph", !reducedMotion && "ml-animate-pop", className), style: style, children: children }));
|
|
107
|
+
}
|
package/dist/raf.d.ts
ADDED
package/dist/raf.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const queue = new Map();
|
|
2
|
+
let frameId = null;
|
|
3
|
+
function flush(timestamp) {
|
|
4
|
+
frameId = null;
|
|
5
|
+
const tasks = Array.from(queue.values());
|
|
6
|
+
queue.clear();
|
|
7
|
+
for (const task of tasks)
|
|
8
|
+
task(timestamp);
|
|
9
|
+
}
|
|
10
|
+
export function scheduleFrame(task) {
|
|
11
|
+
queue.set(task, task);
|
|
12
|
+
if (frameId === null) {
|
|
13
|
+
if (typeof requestAnimationFrame === "undefined") {
|
|
14
|
+
flush(typeof performance === "undefined" ? Date.now() : performance.now());
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
frameId = requestAnimationFrame(flush);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return () => {
|
|
21
|
+
queue.delete(task);
|
|
22
|
+
if (queue.size === 0 && frameId !== null && typeof cancelAnimationFrame !== "undefined") {
|
|
23
|
+
cancelAnimationFrame(frameId);
|
|
24
|
+
frameId = null;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function createRafBatcher(apply) {
|
|
29
|
+
let latest;
|
|
30
|
+
let queued = false;
|
|
31
|
+
let cancel = () => { };
|
|
32
|
+
return {
|
|
33
|
+
schedule(value) {
|
|
34
|
+
latest = value;
|
|
35
|
+
if (queued)
|
|
36
|
+
return;
|
|
37
|
+
queued = true;
|
|
38
|
+
const task = (timestamp) => {
|
|
39
|
+
queued = false;
|
|
40
|
+
apply(latest, timestamp);
|
|
41
|
+
};
|
|
42
|
+
cancel = scheduleFrame(task);
|
|
43
|
+
},
|
|
44
|
+
cancel() {
|
|
45
|
+
queued = false;
|
|
46
|
+
cancel();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function useReducedMotion(): boolean;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
const query = "(prefers-reduced-motion: reduce)";
|
|
4
|
+
function subscribe(listener) {
|
|
5
|
+
if (typeof window === "undefined")
|
|
6
|
+
return () => { };
|
|
7
|
+
const media = window.matchMedia(query);
|
|
8
|
+
media.addEventListener("change", listener);
|
|
9
|
+
return () => media.removeEventListener("change", listener);
|
|
10
|
+
}
|
|
11
|
+
function snapshot() {
|
|
12
|
+
return typeof window !== "undefined" && window.matchMedia(query).matches;
|
|
13
|
+
}
|
|
14
|
+
export function useReducedMotion() {
|
|
15
|
+
return React.useSyncExternalStore(subscribe, snapshot, () => false);
|
|
16
|
+
}
|
package/dist/spring.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface SpringOptions {
|
|
2
|
+
stiffness?: number;
|
|
3
|
+
damping?: number;
|
|
4
|
+
mass?: number;
|
|
5
|
+
restSpeed?: number;
|
|
6
|
+
restDelta?: number;
|
|
7
|
+
maxDelta?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface SpringState {
|
|
10
|
+
value: number;
|
|
11
|
+
velocity: number;
|
|
12
|
+
}
|
|
13
|
+
export interface SpringStep extends SpringState {
|
|
14
|
+
done: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare const springPresets: {
|
|
17
|
+
readonly gentle: {
|
|
18
|
+
readonly stiffness: 170;
|
|
19
|
+
readonly damping: 26;
|
|
20
|
+
readonly mass: 1;
|
|
21
|
+
};
|
|
22
|
+
readonly snappy: {
|
|
23
|
+
readonly stiffness: 320;
|
|
24
|
+
readonly damping: 28;
|
|
25
|
+
readonly mass: 0.9;
|
|
26
|
+
};
|
|
27
|
+
readonly bouncy: {
|
|
28
|
+
readonly stiffness: 260;
|
|
29
|
+
readonly damping: 18;
|
|
30
|
+
readonly mass: 1;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
export declare function integrateSpring(state: SpringState, target: number, deltaMs: number, { stiffness, damping, mass, restSpeed, restDelta, maxDelta, }?: SpringOptions): SpringStep;
|
|
34
|
+
export interface SpringAnimation {
|
|
35
|
+
setTarget(target: number): void;
|
|
36
|
+
set(value: number): void;
|
|
37
|
+
stop(): void;
|
|
38
|
+
get(): SpringState & {
|
|
39
|
+
target: number;
|
|
40
|
+
running: boolean;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export declare function createSpringAnimation({ from, to, options, onUpdate, onComplete, }: {
|
|
44
|
+
from?: number;
|
|
45
|
+
to?: number;
|
|
46
|
+
options?: SpringOptions;
|
|
47
|
+
onUpdate: (value: number, state: SpringState) => void;
|
|
48
|
+
onComplete?: (value: number) => void;
|
|
49
|
+
}): SpringAnimation;
|
package/dist/spring.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export const springPresets = {
|
|
2
|
+
gentle: { stiffness: 170, damping: 26, mass: 1 },
|
|
3
|
+
snappy: { stiffness: 320, damping: 28, mass: 0.9 },
|
|
4
|
+
bouncy: { stiffness: 260, damping: 18, mass: 1 },
|
|
5
|
+
};
|
|
6
|
+
export function integrateSpring(state, target, deltaMs, { stiffness = 240, damping = 26, mass = 1, restSpeed = 0.01, restDelta = 0.01, maxDelta = 32, } = {}) {
|
|
7
|
+
const safeMass = Math.max(0.001, mass);
|
|
8
|
+
let remaining = Math.max(0, Math.min(maxDelta, deltaMs)) / 1000;
|
|
9
|
+
let value = state.value;
|
|
10
|
+
let velocity = state.velocity;
|
|
11
|
+
// Fixed substeps make the semi-implicit Euler integration stable after slow frames.
|
|
12
|
+
while (remaining > 0) {
|
|
13
|
+
const step = Math.min(remaining, 1 / 120);
|
|
14
|
+
const springForce = -stiffness * (value - target);
|
|
15
|
+
const dampingForce = -damping * velocity;
|
|
16
|
+
velocity += ((springForce + dampingForce) / safeMass) * step;
|
|
17
|
+
value += velocity * step;
|
|
18
|
+
remaining -= step;
|
|
19
|
+
}
|
|
20
|
+
const done = Math.abs(velocity) <= restSpeed && Math.abs(target - value) <= restDelta;
|
|
21
|
+
return done
|
|
22
|
+
? { value: target, velocity: 0, done: true }
|
|
23
|
+
: { value, velocity, done: false };
|
|
24
|
+
}
|
|
25
|
+
export function createSpringAnimation({ from = 0, to = from, options, onUpdate, onComplete, }) {
|
|
26
|
+
let state = { value: from, velocity: 0 };
|
|
27
|
+
let target = to;
|
|
28
|
+
let running = false;
|
|
29
|
+
let frame = null;
|
|
30
|
+
let previousTime = 0;
|
|
31
|
+
const stop = () => {
|
|
32
|
+
if (frame !== null && typeof cancelAnimationFrame !== "undefined") {
|
|
33
|
+
cancelAnimationFrame(frame);
|
|
34
|
+
}
|
|
35
|
+
frame = null;
|
|
36
|
+
running = false;
|
|
37
|
+
previousTime = 0;
|
|
38
|
+
};
|
|
39
|
+
const tick = (timestamp) => {
|
|
40
|
+
const delta = previousTime === 0 ? 1000 / 60 : timestamp - previousTime;
|
|
41
|
+
previousTime = timestamp;
|
|
42
|
+
const next = integrateSpring(state, target, delta, options);
|
|
43
|
+
state = { value: next.value, velocity: next.velocity };
|
|
44
|
+
onUpdate(state.value, state);
|
|
45
|
+
if (next.done) {
|
|
46
|
+
stop();
|
|
47
|
+
onComplete?.(state.value);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
frame = requestAnimationFrame(tick);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const start = () => {
|
|
54
|
+
if (running || Object.is(state.value, target))
|
|
55
|
+
return;
|
|
56
|
+
running = true;
|
|
57
|
+
if (typeof requestAnimationFrame === "undefined") {
|
|
58
|
+
state = { value: target, velocity: 0 };
|
|
59
|
+
onUpdate(target, state);
|
|
60
|
+
running = false;
|
|
61
|
+
onComplete?.(target);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
frame = requestAnimationFrame(tick);
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
setTarget(nextTarget) {
|
|
68
|
+
target = nextTarget;
|
|
69
|
+
start();
|
|
70
|
+
},
|
|
71
|
+
set(value) {
|
|
72
|
+
stop();
|
|
73
|
+
state = { value, velocity: 0 };
|
|
74
|
+
onUpdate(value, state);
|
|
75
|
+
},
|
|
76
|
+
stop,
|
|
77
|
+
get: () => ({ ...state, target, running }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface Tilt3DProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
3
|
+
maxTilt?: number;
|
|
4
|
+
perspective?: number;
|
|
5
|
+
glare?: boolean;
|
|
6
|
+
scale?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare const Tilt3D: React.ForwardRefExoticComponent<Tilt3DProps & React.RefAttributes<HTMLDivElement>>;
|
package/dist/tilt-3d.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { createRafBatcher } from "./raf.js";
|
|
5
|
+
import { useReducedMotion } from "./reduced-motion.js";
|
|
6
|
+
export const Tilt3D = React.forwardRef(({ maxTilt = 12, perspective = 900, glare = true, scale = 1.02, className, style, children, onPointerMove, onPointerLeave, onPointerCancel, ...props }, forwardedRef) => {
|
|
7
|
+
const cardRef = React.useRef(null);
|
|
8
|
+
const reducedMotion = useReducedMotion();
|
|
9
|
+
const setRef = React.useCallback((node) => {
|
|
10
|
+
cardRef.current = node;
|
|
11
|
+
if (typeof forwardedRef === "function")
|
|
12
|
+
forwardedRef(node);
|
|
13
|
+
else if (forwardedRef)
|
|
14
|
+
forwardedRef.current = node;
|
|
15
|
+
}, [forwardedRef]);
|
|
16
|
+
const batcher = React.useMemo(() => createRafBatcher((frame) => {
|
|
17
|
+
const node = cardRef.current;
|
|
18
|
+
if (!node)
|
|
19
|
+
return;
|
|
20
|
+
node.style.transform = frame.active
|
|
21
|
+
? `perspective(${perspective}px) rotateX(${frame.x.toFixed(2)}deg) rotateY(${frame.y.toFixed(2)}deg) scale3d(${scale}, ${scale}, ${scale})`
|
|
22
|
+
: `perspective(${perspective}px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)`;
|
|
23
|
+
node.style.transition = frame.active
|
|
24
|
+
? "transform 80ms linear"
|
|
25
|
+
: "transform 400ms cubic-bezier(0.16, 1, 0.3, 1)";
|
|
26
|
+
node.style.setProperty("--mlola-glare-x", `${frame.glareX}%`);
|
|
27
|
+
node.style.setProperty("--mlola-glare-y", `${frame.glareY}%`);
|
|
28
|
+
node.style.setProperty("--mlola-glare-opacity", frame.active && glare ? "1" : "0");
|
|
29
|
+
}), [glare, perspective, scale]);
|
|
30
|
+
React.useEffect(() => () => batcher.cancel(), [batcher]);
|
|
31
|
+
const reset = () => {
|
|
32
|
+
batcher.schedule({ x: 0, y: 0, glareX: 50, glareY: 50, active: false });
|
|
33
|
+
};
|
|
34
|
+
const handlePointerMove = (event) => {
|
|
35
|
+
onPointerMove?.(event);
|
|
36
|
+
const node = cardRef.current;
|
|
37
|
+
if (event.defaultPrevented ||
|
|
38
|
+
reducedMotion ||
|
|
39
|
+
event.pointerType === "touch" ||
|
|
40
|
+
!node)
|
|
41
|
+
return;
|
|
42
|
+
const rect = node.getBoundingClientRect();
|
|
43
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
44
|
+
return;
|
|
45
|
+
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
|
46
|
+
const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
|
|
47
|
+
batcher.schedule({
|
|
48
|
+
x: (y - 0.5) * -maxTilt,
|
|
49
|
+
y: (x - 0.5) * maxTilt,
|
|
50
|
+
glareX: x * 100,
|
|
51
|
+
glareY: y * 100,
|
|
52
|
+
active: true,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const handlePointerLeave = (event) => {
|
|
56
|
+
onPointerLeave?.(event);
|
|
57
|
+
reset();
|
|
58
|
+
};
|
|
59
|
+
const handlePointerCancel = (event) => {
|
|
60
|
+
onPointerCancel?.(event);
|
|
61
|
+
reset();
|
|
62
|
+
};
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
if (reducedMotion)
|
|
65
|
+
reset();
|
|
66
|
+
}, [reducedMotion]);
|
|
67
|
+
return (_jsxs("div", { ...props, ref: setRef, onPointerMove: handlePointerMove, onPointerLeave: handlePointerLeave, onPointerCancel: handlePointerCancel, className: className, style: {
|
|
68
|
+
transform: `perspective(${perspective}px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)`,
|
|
69
|
+
transformStyle: "preserve-3d",
|
|
70
|
+
transition: "transform 400ms cubic-bezier(0.16, 1, 0.3, 1)",
|
|
71
|
+
willChange: reducedMotion ? undefined : "transform",
|
|
72
|
+
position: "relative",
|
|
73
|
+
...style,
|
|
74
|
+
}, children: [children, glare && (_jsx("span", { "aria-hidden": "true", className: "ml-motion-tilt-glare", style: reducedMotion ? { transition: "none" } : undefined }))] }));
|
|
75
|
+
});
|
|
76
|
+
Tilt3D.displayName = "Tilt3D";
|
package/package.json
CHANGED
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mlola-ui/motion",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Reduced-motion-aware kinetic primitives for Mlola UI",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./
|
|
7
|
-
"types": "./
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
8
|
"sideEffects": [
|
|
9
9
|
"./motion.css"
|
|
10
10
|
],
|
|
11
11
|
"files": [
|
|
12
|
-
"
|
|
12
|
+
"dist",
|
|
13
13
|
"motion.css"
|
|
14
14
|
],
|
|
15
15
|
"exports": {
|
|
16
|
-
".":
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./physics": {
|
|
21
|
+
"types": "./dist/spring.d.ts",
|
|
22
|
+
"default": "./dist/spring.js"
|
|
23
|
+
},
|
|
24
|
+
"./raf": {
|
|
25
|
+
"types": "./dist/raf.d.ts",
|
|
26
|
+
"default": "./dist/raf.js"
|
|
27
|
+
},
|
|
19
28
|
"./motion.css": "./motion.css"
|
|
20
29
|
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.json",
|
|
32
|
+
"prepack": "npm run build"
|
|
33
|
+
},
|
|
21
34
|
"peerDependencies": {
|
|
22
35
|
"react": ">=18",
|
|
23
36
|
"react-dom": ">=18"
|
package/src/index.ts
DELETED
package/src/pointer-math.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
export interface PointerKinetics {
|
|
2
|
-
x: number;
|
|
3
|
-
y: number;
|
|
4
|
-
deltaX: number;
|
|
5
|
-
deltaY: number;
|
|
6
|
-
velocityX: number;
|
|
7
|
-
velocityY: number;
|
|
8
|
-
speed: number;
|
|
9
|
-
angle: number;
|
|
10
|
-
timestamp: number;
|
|
11
|
-
pointerType: string;
|
|
12
|
-
active: boolean;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export const idlePointerKinetics: PointerKinetics = Object.freeze({
|
|
16
|
-
x: 0,
|
|
17
|
-
y: 0,
|
|
18
|
-
deltaX: 0,
|
|
19
|
-
deltaY: 0,
|
|
20
|
-
velocityX: 0,
|
|
21
|
-
velocityY: 0,
|
|
22
|
-
speed: 0,
|
|
23
|
-
angle: 0,
|
|
24
|
-
timestamp: 0,
|
|
25
|
-
pointerType: "",
|
|
26
|
-
active: false,
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
export function calculatePointerKinetics(
|
|
30
|
-
previous: PointerKinetics,
|
|
31
|
-
point: Pick<PointerKinetics, "x" | "y" | "timestamp" | "pointerType">,
|
|
32
|
-
smoothing = 0.25
|
|
33
|
-
): PointerKinetics {
|
|
34
|
-
const deltaX = previous.active ? point.x - previous.x : 0;
|
|
35
|
-
const deltaY = previous.active ? point.y - previous.y : 0;
|
|
36
|
-
const elapsed = previous.active
|
|
37
|
-
? Math.max(1, Math.min(64, point.timestamp - previous.timestamp))
|
|
38
|
-
: 16;
|
|
39
|
-
const instantX = (deltaX / elapsed) * 1000;
|
|
40
|
-
const instantY = (deltaY / elapsed) * 1000;
|
|
41
|
-
const blend = Math.max(0, Math.min(1, smoothing));
|
|
42
|
-
const velocityX = previous.velocityX + (instantX - previous.velocityX) * blend;
|
|
43
|
-
const velocityY = previous.velocityY + (instantY - previous.velocityY) * blend;
|
|
44
|
-
return {
|
|
45
|
-
...point,
|
|
46
|
-
deltaX,
|
|
47
|
-
deltaY,
|
|
48
|
-
velocityX,
|
|
49
|
-
velocityY,
|
|
50
|
-
speed: Math.hypot(velocityX, velocityY),
|
|
51
|
-
angle: Math.atan2(velocityY, velocityX),
|
|
52
|
-
active: true,
|
|
53
|
-
};
|
|
54
|
-
}
|
package/src/primitives.tsx
DELETED
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import * as React from "react";
|
|
4
|
-
import { useReducedMotion } from "./reduced-motion";
|
|
5
|
-
import { createSpringAnimation, springPresets } from "./spring";
|
|
6
|
-
|
|
7
|
-
function joinClasses(...values: Array<string | false | null | undefined>) {
|
|
8
|
-
return values.filter(Boolean).join(" ");
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface AnimatedCounterProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
12
|
-
value: number | string;
|
|
13
|
-
stagger?: number;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function AnimatedCounter({
|
|
17
|
-
value,
|
|
18
|
-
className,
|
|
19
|
-
stagger = 35,
|
|
20
|
-
"aria-label": spoken,
|
|
21
|
-
...props
|
|
22
|
-
}: AnimatedCounterProps) {
|
|
23
|
-
const reducedMotion = useReducedMotion();
|
|
24
|
-
const text = String(value);
|
|
25
|
-
return (
|
|
26
|
-
<span {...props} className={joinClasses("ml-motion-counter", className)}>
|
|
27
|
-
{/* A span cannot carry a label, so the value is read as text and the flipping digits are hidden. */}
|
|
28
|
-
<span className="ml-visually-hidden">{spoken ?? text}</span>
|
|
29
|
-
{Array.from(text).map((character, index) => (
|
|
30
|
-
<span
|
|
31
|
-
aria-hidden="true"
|
|
32
|
-
key={`${index}-${character}`}
|
|
33
|
-
className="ml-motion-counter-digit"
|
|
34
|
-
style={
|
|
35
|
-
reducedMotion
|
|
36
|
-
? undefined
|
|
37
|
-
: {
|
|
38
|
-
animation: "counter-flip 280ms cubic-bezier(0.34, 1.56, 0.64, 1) both",
|
|
39
|
-
animationDelay: `${index * stagger}ms`,
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
>
|
|
43
|
-
{character}
|
|
44
|
-
</span>
|
|
45
|
-
))}
|
|
46
|
-
</span>
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface TextSwapProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
51
|
-
text: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function TextSwap({ text, className, style, ...props }: TextSwapProps) {
|
|
55
|
-
const reducedMotion = useReducedMotion();
|
|
56
|
-
return (
|
|
57
|
-
<span
|
|
58
|
-
{...props}
|
|
59
|
-
key={text}
|
|
60
|
-
className={joinClasses("ml-motion-text", className)}
|
|
61
|
-
style={{
|
|
62
|
-
animation: reducedMotion
|
|
63
|
-
? undefined
|
|
64
|
-
: "text-swap 240ms cubic-bezier(0.16, 1, 0.3, 1) both",
|
|
65
|
-
...style,
|
|
66
|
-
}}
|
|
67
|
-
>
|
|
68
|
-
{text}
|
|
69
|
-
</span>
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function ShimmerText({
|
|
74
|
-
children,
|
|
75
|
-
className,
|
|
76
|
-
...props
|
|
77
|
-
}: React.HTMLAttributes<HTMLSpanElement>) {
|
|
78
|
-
const reducedMotion = useReducedMotion();
|
|
79
|
-
return (
|
|
80
|
-
<span
|
|
81
|
-
{...props}
|
|
82
|
-
className={joinClasses(!reducedMotion && "ml-motion-shimmer", className)}
|
|
83
|
-
>
|
|
84
|
-
{children}
|
|
85
|
-
</span>
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export interface MagneticProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
90
|
-
pull?: number;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export const Magnetic = React.forwardRef<HTMLDivElement, MagneticProps>(
|
|
94
|
-
(
|
|
95
|
-
{
|
|
96
|
-
children,
|
|
97
|
-
pull = 0.25,
|
|
98
|
-
className,
|
|
99
|
-
style,
|
|
100
|
-
onPointerMove,
|
|
101
|
-
onPointerLeave,
|
|
102
|
-
onPointerCancel,
|
|
103
|
-
...props
|
|
104
|
-
},
|
|
105
|
-
forwardedRef
|
|
106
|
-
) => {
|
|
107
|
-
const reducedMotion = useReducedMotion();
|
|
108
|
-
const nodeRef = React.useRef<HTMLDivElement>(null);
|
|
109
|
-
const offset = React.useRef({ x: 0, y: 0 });
|
|
110
|
-
const setRef = React.useCallback(
|
|
111
|
-
(node: HTMLDivElement | null) => {
|
|
112
|
-
nodeRef.current = node;
|
|
113
|
-
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
114
|
-
else if (forwardedRef) forwardedRef.current = node;
|
|
115
|
-
},
|
|
116
|
-
[forwardedRef]
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
// The pull is a real spring, not a CSS transition: it can be interrupted
|
|
120
|
-
// mid-flight, and the loop stops the moment it comes to rest.
|
|
121
|
-
const paint = React.useCallback(() => {
|
|
122
|
-
const node = nodeRef.current;
|
|
123
|
-
if (!node) return;
|
|
124
|
-
node.style.transform = `translate3d(${offset.current.x.toFixed(2)}px, ${offset.current.y.toFixed(2)}px, 0)`;
|
|
125
|
-
}, []);
|
|
126
|
-
const springX = React.useMemo(
|
|
127
|
-
() =>
|
|
128
|
-
createSpringAnimation({
|
|
129
|
-
options: springPresets.snappy,
|
|
130
|
-
onUpdate: (value) => {
|
|
131
|
-
offset.current.x = value;
|
|
132
|
-
paint();
|
|
133
|
-
},
|
|
134
|
-
}),
|
|
135
|
-
[paint]
|
|
136
|
-
);
|
|
137
|
-
const springY = React.useMemo(
|
|
138
|
-
() =>
|
|
139
|
-
createSpringAnimation({
|
|
140
|
-
options: springPresets.snappy,
|
|
141
|
-
onUpdate: (value) => {
|
|
142
|
-
offset.current.y = value;
|
|
143
|
-
paint();
|
|
144
|
-
},
|
|
145
|
-
}),
|
|
146
|
-
[paint]
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
React.useEffect(
|
|
150
|
-
() => () => {
|
|
151
|
-
springX.stop();
|
|
152
|
-
springY.stop();
|
|
153
|
-
},
|
|
154
|
-
[springX, springY]
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
const moveTo = (x: number, y: number) => {
|
|
158
|
-
springX.setTarget(x);
|
|
159
|
-
springY.setTarget(y);
|
|
160
|
-
};
|
|
161
|
-
const reset = () => moveTo(0, 0);
|
|
162
|
-
|
|
163
|
-
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
164
|
-
onPointerMove?.(event);
|
|
165
|
-
const node = nodeRef.current;
|
|
166
|
-
if (event.defaultPrevented || reducedMotion || event.pointerType === "touch" || !node) return;
|
|
167
|
-
const rect = node.getBoundingClientRect();
|
|
168
|
-
moveTo(
|
|
169
|
-
(event.clientX - (rect.left + rect.width / 2)) * pull,
|
|
170
|
-
(event.clientY - (rect.top + rect.height / 2)) * pull
|
|
171
|
-
);
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
React.useEffect(() => {
|
|
175
|
-
if (!reducedMotion) return;
|
|
176
|
-
springX.set(0);
|
|
177
|
-
springY.set(0);
|
|
178
|
-
}, [reducedMotion, springX, springY]);
|
|
179
|
-
|
|
180
|
-
return (
|
|
181
|
-
<div
|
|
182
|
-
{...props}
|
|
183
|
-
ref={setRef}
|
|
184
|
-
onPointerMove={handlePointerMove}
|
|
185
|
-
onPointerLeave={(event) => {
|
|
186
|
-
onPointerLeave?.(event);
|
|
187
|
-
reset();
|
|
188
|
-
}}
|
|
189
|
-
onPointerCancel={(event) => {
|
|
190
|
-
onPointerCancel?.(event);
|
|
191
|
-
reset();
|
|
192
|
-
}}
|
|
193
|
-
className={joinClasses("ml-motion-magnetic", className)}
|
|
194
|
-
style={{
|
|
195
|
-
transform: "translate3d(0, 0, 0)",
|
|
196
|
-
willChange: reducedMotion ? undefined : "transform",
|
|
197
|
-
...style,
|
|
198
|
-
}}
|
|
199
|
-
>
|
|
200
|
-
{children}
|
|
201
|
-
</div>
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
);
|
|
205
|
-
Magnetic.displayName = "Magnetic";
|
|
206
|
-
|
|
207
|
-
export interface OriginMorphProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
208
|
-
isOpen: boolean;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export function OriginMorph({
|
|
212
|
-
isOpen,
|
|
213
|
-
children,
|
|
214
|
-
className,
|
|
215
|
-
style,
|
|
216
|
-
...props
|
|
217
|
-
}: OriginMorphProps) {
|
|
218
|
-
const reducedMotion = useReducedMotion();
|
|
219
|
-
if (!isOpen) return null;
|
|
220
|
-
return (
|
|
221
|
-
<div
|
|
222
|
-
{...props}
|
|
223
|
-
className={joinClasses("ml-motion-origin-morph", !reducedMotion && "ml-animate-pop", className)}
|
|
224
|
-
style={style}
|
|
225
|
-
>
|
|
226
|
-
{children}
|
|
227
|
-
</div>
|
|
228
|
-
);
|
|
229
|
-
}
|
package/src/raf.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
export type FrameTask = (timestamp: number) => void;
|
|
2
|
-
|
|
3
|
-
const queue = new Map<FrameTask, FrameTask>();
|
|
4
|
-
let frameId: number | null = null;
|
|
5
|
-
|
|
6
|
-
function flush(timestamp: number): void {
|
|
7
|
-
frameId = null;
|
|
8
|
-
const tasks = Array.from(queue.values());
|
|
9
|
-
queue.clear();
|
|
10
|
-
for (const task of tasks) task(timestamp);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function scheduleFrame(task: FrameTask): () => void {
|
|
14
|
-
queue.set(task, task);
|
|
15
|
-
if (frameId === null) {
|
|
16
|
-
if (typeof requestAnimationFrame === "undefined") {
|
|
17
|
-
flush(typeof performance === "undefined" ? Date.now() : performance.now());
|
|
18
|
-
} else {
|
|
19
|
-
frameId = requestAnimationFrame(flush);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return () => {
|
|
23
|
-
queue.delete(task);
|
|
24
|
-
if (queue.size === 0 && frameId !== null && typeof cancelAnimationFrame !== "undefined") {
|
|
25
|
-
cancelAnimationFrame(frameId);
|
|
26
|
-
frameId = null;
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function createRafBatcher<T>(apply: (value: T, timestamp: number) => void) {
|
|
32
|
-
let latest: T;
|
|
33
|
-
let queued = false;
|
|
34
|
-
let cancel = () => {};
|
|
35
|
-
|
|
36
|
-
return {
|
|
37
|
-
schedule(value: T) {
|
|
38
|
-
latest = value;
|
|
39
|
-
if (queued) return;
|
|
40
|
-
queued = true;
|
|
41
|
-
const task = (timestamp: number) => {
|
|
42
|
-
queued = false;
|
|
43
|
-
apply(latest, timestamp);
|
|
44
|
-
};
|
|
45
|
-
cancel = scheduleFrame(task);
|
|
46
|
-
},
|
|
47
|
-
cancel() {
|
|
48
|
-
queued = false;
|
|
49
|
-
cancel();
|
|
50
|
-
},
|
|
51
|
-
};
|
|
52
|
-
}
|
package/src/reduced-motion.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import * as React from "react";
|
|
4
|
-
|
|
5
|
-
const query = "(prefers-reduced-motion: reduce)";
|
|
6
|
-
|
|
7
|
-
function subscribe(listener: () => void): () => void {
|
|
8
|
-
if (typeof window === "undefined") return () => {};
|
|
9
|
-
const media = window.matchMedia(query);
|
|
10
|
-
media.addEventListener("change", listener);
|
|
11
|
-
return () => media.removeEventListener("change", listener);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function snapshot(): boolean {
|
|
15
|
-
return typeof window !== "undefined" && window.matchMedia(query).matches;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function useReducedMotion(): boolean {
|
|
19
|
-
return React.useSyncExternalStore(subscribe, snapshot, () => false);
|
|
20
|
-
}
|
package/src/spring.ts
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
export interface SpringOptions {
|
|
2
|
-
stiffness?: number;
|
|
3
|
-
damping?: number;
|
|
4
|
-
mass?: number;
|
|
5
|
-
restSpeed?: number;
|
|
6
|
-
restDelta?: number;
|
|
7
|
-
maxDelta?: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface SpringState {
|
|
11
|
-
value: number;
|
|
12
|
-
velocity: number;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface SpringStep extends SpringState {
|
|
16
|
-
done: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export const springPresets = {
|
|
20
|
-
gentle: { stiffness: 170, damping: 26, mass: 1 },
|
|
21
|
-
snappy: { stiffness: 320, damping: 28, mass: 0.9 },
|
|
22
|
-
bouncy: { stiffness: 260, damping: 18, mass: 1 },
|
|
23
|
-
} as const satisfies Record<string, SpringOptions>;
|
|
24
|
-
|
|
25
|
-
export function integrateSpring(
|
|
26
|
-
state: SpringState,
|
|
27
|
-
target: number,
|
|
28
|
-
deltaMs: number,
|
|
29
|
-
{
|
|
30
|
-
stiffness = 240,
|
|
31
|
-
damping = 26,
|
|
32
|
-
mass = 1,
|
|
33
|
-
restSpeed = 0.01,
|
|
34
|
-
restDelta = 0.01,
|
|
35
|
-
maxDelta = 32,
|
|
36
|
-
}: SpringOptions = {}
|
|
37
|
-
): SpringStep {
|
|
38
|
-
const safeMass = Math.max(0.001, mass);
|
|
39
|
-
let remaining = Math.max(0, Math.min(maxDelta, deltaMs)) / 1000;
|
|
40
|
-
let value = state.value;
|
|
41
|
-
let velocity = state.velocity;
|
|
42
|
-
|
|
43
|
-
// Fixed substeps make the semi-implicit Euler integration stable after slow frames.
|
|
44
|
-
while (remaining > 0) {
|
|
45
|
-
const step = Math.min(remaining, 1 / 120);
|
|
46
|
-
const springForce = -stiffness * (value - target);
|
|
47
|
-
const dampingForce = -damping * velocity;
|
|
48
|
-
velocity += ((springForce + dampingForce) / safeMass) * step;
|
|
49
|
-
value += velocity * step;
|
|
50
|
-
remaining -= step;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const done = Math.abs(velocity) <= restSpeed && Math.abs(target - value) <= restDelta;
|
|
54
|
-
return done
|
|
55
|
-
? { value: target, velocity: 0, done: true }
|
|
56
|
-
: { value, velocity, done: false };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface SpringAnimation {
|
|
60
|
-
setTarget(target: number): void;
|
|
61
|
-
set(value: number): void;
|
|
62
|
-
stop(): void;
|
|
63
|
-
get(): SpringState & { target: number; running: boolean };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function createSpringAnimation({
|
|
67
|
-
from = 0,
|
|
68
|
-
to = from,
|
|
69
|
-
options,
|
|
70
|
-
onUpdate,
|
|
71
|
-
onComplete,
|
|
72
|
-
}: {
|
|
73
|
-
from?: number;
|
|
74
|
-
to?: number;
|
|
75
|
-
options?: SpringOptions;
|
|
76
|
-
onUpdate: (value: number, state: SpringState) => void;
|
|
77
|
-
onComplete?: (value: number) => void;
|
|
78
|
-
}): SpringAnimation {
|
|
79
|
-
let state: SpringState = { value: from, velocity: 0 };
|
|
80
|
-
let target = to;
|
|
81
|
-
let running = false;
|
|
82
|
-
let frame: number | null = null;
|
|
83
|
-
let previousTime = 0;
|
|
84
|
-
|
|
85
|
-
const stop = () => {
|
|
86
|
-
if (frame !== null && typeof cancelAnimationFrame !== "undefined") {
|
|
87
|
-
cancelAnimationFrame(frame);
|
|
88
|
-
}
|
|
89
|
-
frame = null;
|
|
90
|
-
running = false;
|
|
91
|
-
previousTime = 0;
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const tick = (timestamp: number) => {
|
|
95
|
-
const delta = previousTime === 0 ? 1000 / 60 : timestamp - previousTime;
|
|
96
|
-
previousTime = timestamp;
|
|
97
|
-
const next = integrateSpring(state, target, delta, options);
|
|
98
|
-
state = { value: next.value, velocity: next.velocity };
|
|
99
|
-
onUpdate(state.value, state);
|
|
100
|
-
if (next.done) {
|
|
101
|
-
stop();
|
|
102
|
-
onComplete?.(state.value);
|
|
103
|
-
} else {
|
|
104
|
-
frame = requestAnimationFrame(tick);
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
const start = () => {
|
|
109
|
-
if (running || Object.is(state.value, target)) return;
|
|
110
|
-
running = true;
|
|
111
|
-
if (typeof requestAnimationFrame === "undefined") {
|
|
112
|
-
state = { value: target, velocity: 0 };
|
|
113
|
-
onUpdate(target, state);
|
|
114
|
-
running = false;
|
|
115
|
-
onComplete?.(target);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
frame = requestAnimationFrame(tick);
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
return {
|
|
122
|
-
setTarget(nextTarget) {
|
|
123
|
-
target = nextTarget;
|
|
124
|
-
start();
|
|
125
|
-
},
|
|
126
|
-
set(value) {
|
|
127
|
-
stop();
|
|
128
|
-
state = { value, velocity: 0 };
|
|
129
|
-
onUpdate(value, state);
|
|
130
|
-
},
|
|
131
|
-
stop,
|
|
132
|
-
get: () => ({ ...state, target, running }),
|
|
133
|
-
};
|
|
134
|
-
}
|
package/src/tilt-3d.tsx
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import * as React from "react";
|
|
4
|
-
import { createRafBatcher } from "./raf";
|
|
5
|
-
import { useReducedMotion } from "./reduced-motion";
|
|
6
|
-
|
|
7
|
-
export interface Tilt3DProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
|
-
maxTilt?: number;
|
|
9
|
-
perspective?: number;
|
|
10
|
-
glare?: boolean;
|
|
11
|
-
scale?: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface TiltFrame {
|
|
15
|
-
x: number;
|
|
16
|
-
y: number;
|
|
17
|
-
glareX: number;
|
|
18
|
-
glareY: number;
|
|
19
|
-
active: boolean;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export const Tilt3D = React.forwardRef<HTMLDivElement, Tilt3DProps>(
|
|
23
|
-
(
|
|
24
|
-
{
|
|
25
|
-
maxTilt = 12,
|
|
26
|
-
perspective = 900,
|
|
27
|
-
glare = true,
|
|
28
|
-
scale = 1.02,
|
|
29
|
-
className,
|
|
30
|
-
style,
|
|
31
|
-
children,
|
|
32
|
-
onPointerMove,
|
|
33
|
-
onPointerLeave,
|
|
34
|
-
onPointerCancel,
|
|
35
|
-
...props
|
|
36
|
-
},
|
|
37
|
-
forwardedRef
|
|
38
|
-
) => {
|
|
39
|
-
const cardRef = React.useRef<HTMLDivElement>(null);
|
|
40
|
-
const reducedMotion = useReducedMotion();
|
|
41
|
-
const setRef = React.useCallback(
|
|
42
|
-
(node: HTMLDivElement | null) => {
|
|
43
|
-
cardRef.current = node;
|
|
44
|
-
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
45
|
-
else if (forwardedRef) forwardedRef.current = node;
|
|
46
|
-
},
|
|
47
|
-
[forwardedRef]
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
const batcher = React.useMemo(
|
|
51
|
-
() =>
|
|
52
|
-
createRafBatcher<TiltFrame>((frame) => {
|
|
53
|
-
const node = cardRef.current;
|
|
54
|
-
if (!node) return;
|
|
55
|
-
node.style.transform = frame.active
|
|
56
|
-
? `perspective(${perspective}px) rotateX(${frame.x.toFixed(2)}deg) rotateY(${frame.y.toFixed(2)}deg) scale3d(${scale}, ${scale}, ${scale})`
|
|
57
|
-
: `perspective(${perspective}px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)`;
|
|
58
|
-
node.style.transition = frame.active
|
|
59
|
-
? "transform 80ms linear"
|
|
60
|
-
: "transform 400ms cubic-bezier(0.16, 1, 0.3, 1)";
|
|
61
|
-
node.style.setProperty("--mlola-glare-x", `${frame.glareX}%`);
|
|
62
|
-
node.style.setProperty("--mlola-glare-y", `${frame.glareY}%`);
|
|
63
|
-
node.style.setProperty("--mlola-glare-opacity", frame.active && glare ? "1" : "0");
|
|
64
|
-
}),
|
|
65
|
-
[glare, perspective, scale]
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
React.useEffect(() => () => batcher.cancel(), [batcher]);
|
|
69
|
-
|
|
70
|
-
const reset = () => {
|
|
71
|
-
batcher.schedule({ x: 0, y: 0, glareX: 50, glareY: 50, active: false });
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
75
|
-
onPointerMove?.(event);
|
|
76
|
-
const node = cardRef.current;
|
|
77
|
-
if (
|
|
78
|
-
event.defaultPrevented ||
|
|
79
|
-
reducedMotion ||
|
|
80
|
-
event.pointerType === "touch" ||
|
|
81
|
-
!node
|
|
82
|
-
) return;
|
|
83
|
-
const rect = node.getBoundingClientRect();
|
|
84
|
-
if (rect.width <= 0 || rect.height <= 0) return;
|
|
85
|
-
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
|
86
|
-
const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
|
|
87
|
-
batcher.schedule({
|
|
88
|
-
x: (y - 0.5) * -maxTilt,
|
|
89
|
-
y: (x - 0.5) * maxTilt,
|
|
90
|
-
glareX: x * 100,
|
|
91
|
-
glareY: y * 100,
|
|
92
|
-
active: true,
|
|
93
|
-
});
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const handlePointerLeave = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
97
|
-
onPointerLeave?.(event);
|
|
98
|
-
reset();
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
const handlePointerCancel = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
102
|
-
onPointerCancel?.(event);
|
|
103
|
-
reset();
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
React.useEffect(() => {
|
|
107
|
-
if (reducedMotion) reset();
|
|
108
|
-
}, [reducedMotion]);
|
|
109
|
-
|
|
110
|
-
return (
|
|
111
|
-
<div
|
|
112
|
-
{...props}
|
|
113
|
-
ref={setRef}
|
|
114
|
-
onPointerMove={handlePointerMove}
|
|
115
|
-
onPointerLeave={handlePointerLeave}
|
|
116
|
-
onPointerCancel={handlePointerCancel}
|
|
117
|
-
className={className}
|
|
118
|
-
style={{
|
|
119
|
-
transform: `perspective(${perspective}px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)`,
|
|
120
|
-
transformStyle: "preserve-3d",
|
|
121
|
-
transition: "transform 400ms cubic-bezier(0.16, 1, 0.3, 1)",
|
|
122
|
-
willChange: reducedMotion ? undefined : "transform",
|
|
123
|
-
position: "relative",
|
|
124
|
-
...style,
|
|
125
|
-
}}
|
|
126
|
-
>
|
|
127
|
-
{children}
|
|
128
|
-
{glare && (
|
|
129
|
-
<span
|
|
130
|
-
aria-hidden="true"
|
|
131
|
-
className="ml-motion-tilt-glare"
|
|
132
|
-
style={reducedMotion ? { transition: "none" } : undefined}
|
|
133
|
-
/>
|
|
134
|
-
)}
|
|
135
|
-
</div>
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
);
|
|
139
|
-
Tilt3D.displayName = "Tilt3D";
|