@mlola-ui/motion 1.0.7 → 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.
@@ -0,0 +1,6 @@
1
+ export * from "./pointer-math.ts";
2
+ export * from "./primitives.tsx";
3
+ export * from "./raf.ts";
4
+ export * from "./reduced-motion.ts";
5
+ export * from "./spring.ts";
6
+ export * from "./tilt-3d.tsx";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./pointer-math.js";
2
+ export * from "./primitives.js";
3
+ export * from "./raf.js";
4
+ export * from "./reduced-motion.js";
5
+ export * from "./spring.js";
6
+ export * from "./tilt-3d.js";
@@ -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
@@ -0,0 +1,6 @@
1
+ export type FrameTask = (timestamp: number) => void;
2
+ export declare function scheduleFrame(task: FrameTask): () => void;
3
+ export declare function createRafBatcher<T>(apply: (value: T, timestamp: number) => void): {
4
+ schedule(value: T): void;
5
+ cancel(): void;
6
+ };
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
+ }
@@ -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>>;
@@ -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.7",
3
+ "version": "1.0.9",
4
4
  "description": "Reduced-motion-aware kinetic primitives for Mlola UI",
5
5
  "type": "module",
6
- "main": "./src/index.ts",
7
- "types": "./src/index.ts",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
8
  "sideEffects": [
9
9
  "./motion.css"
10
10
  ],
11
11
  "files": [
12
- "src",
12
+ "dist",
13
13
  "motion.css"
14
14
  ],
15
15
  "exports": {
16
- ".": "./src/index.ts",
17
- "./physics": "./src/spring.ts",
18
- "./raf": "./src/raf.ts",
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
@@ -1,6 +0,0 @@
1
- export * from "./pointer-math";
2
- export * from "./primitives";
3
- export * from "./raf";
4
- export * from "./reduced-motion";
5
- export * from "./spring";
6
- export * from "./tilt-3d";
@@ -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
- }
@@ -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
- }
@@ -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";