@mlola-ui/motion 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mlola
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # @mlola-ui/motion
2
+
3
+ Native kinetic primitives for Mlola UI. Pointer tilt, magnetic pull, digit
4
+ reels, soft text swaps, and a spring integrator — all reduced-motion aware, and
5
+ none of them a continuous animation loop while idle.
6
+
7
+ The engine already samples a damped oscillator into CSS `linear()` easings, so
8
+ static transitions carry the theme's motion without any JavaScript. This
9
+ package is for the interruptible, pointer-driven cases CSS cannot express.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @mlola-ui/motion
15
+ ```
16
+
17
+ ## Use
18
+
19
+ ```tsx
20
+ import { Magnetic, Tilt3D } from "@mlola-ui/motion";
21
+
22
+ <Magnetic pull={0.2}><button className="ml-button">Join</button></Magnetic>;
23
+ ```
24
+
25
+ ## Exports
26
+
27
+ | Path | What it is |
28
+ | --- | --- |
29
+ | `@mlola-ui/motion` | `Magnetic`, `Tilt3D`, `AnimatedCounter`, `TextSwap`, `ShimmerText` |
30
+ | `@mlola-ui/motion/physics` | `integrateSpring`, `createSpringAnimation`, presets |
31
+ | `@mlola-ui/motion/raf` | frame batching helpers |
32
+
33
+ Requires React and ReactDOM 18 or newer as peers. Every spatial effect collapses
34
+ under `prefers-reduced-motion: reduce`.
35
+
36
+ MIT licensed. Part of [Mlola UI](https://ui.mlola.com).
package/motion.css ADDED
@@ -0,0 +1,288 @@
1
+ /*
2
+ * @mlola-ui/motion
3
+ * Micro-transitions & physics-inspired animations.
4
+ *
5
+ * Designed to feel crisp, organic, and never mechanical:
6
+ * - Springs use cubic-bezier(0.16, 1, 0.3, 1) and (0.34, 1.56, 0.64, 1)
7
+ * - Shimmers and glares use CSS masks + gradients
8
+ * - Respects prefers-reduced-motion
9
+ */
10
+
11
+ :root {
12
+ --ease-spring-soft: cubic-bezier(0.16, 1, 0.3, 1);
13
+ --ease-spring-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
14
+ --ease-in-out-smooth: cubic-bezier(0.65, 0, 0.35, 1);
15
+ --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
16
+
17
+ --duration-micro: 120ms;
18
+ --duration-short: 180ms;
19
+ --duration-normal: 260ms;
20
+ --duration-smooth: 420ms;
21
+ --duration-slow: 700ms;
22
+ }
23
+
24
+ @layer utilities {
25
+ /* ── 3D tilt & perspective ── */
26
+ .perspective-1000 {
27
+ perspective: 1000px;
28
+ }
29
+ .preserve-3d {
30
+ transform-style: preserve-3d;
31
+ }
32
+ .backface-hidden {
33
+ backface-visibility: hidden;
34
+ }
35
+
36
+ /* ── Transitions: kinetic spring patterns ── */
37
+ .motion-spring {
38
+ transition-timing-function: var(--ease-spring-soft);
39
+ transition-duration: var(--duration-normal);
40
+ }
41
+ .motion-bounce {
42
+ transition-timing-function: var(--ease-spring-bounce);
43
+ transition-duration: var(--duration-smooth);
44
+ }
45
+
46
+ /* Glassmorphism surfaces */
47
+ .glass-card {
48
+ background: color-mix(in srgb, var(--card) 75%, transparent);
49
+ backdrop-filter: blur(16px) saturate(180%);
50
+ -webkit-backdrop-filter: blur(16px) saturate(180%);
51
+ border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
52
+ }
53
+
54
+ .glass-panel {
55
+ background: color-mix(in srgb, var(--background) 80%, transparent);
56
+ backdrop-filter: blur(20px) saturate(190%);
57
+ -webkit-backdrop-filter: blur(20px) saturate(190%);
58
+ border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
59
+ }
60
+
61
+ /* Gradient border highlight */
62
+ .border-glow {
63
+ position: relative;
64
+ }
65
+ .border-glow::before {
66
+ content: "";
67
+ position: absolute;
68
+ inset: -1px;
69
+ border-radius: inherit;
70
+ padding: 1px;
71
+ background: linear-gradient(135deg, color-mix(in srgb, var(--signal) 60%, transparent), transparent 60%);
72
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
73
+ mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
74
+ -webkit-mask-composite: xor;
75
+ mask-composite: exclude;
76
+ pointer-events: none;
77
+ }
78
+
79
+ /* Shimmer text sweep */
80
+ .animate-shimmer {
81
+ background: linear-gradient(
82
+ 90deg,
83
+ color-mix(in srgb, var(--foreground) 70%, transparent) 0%,
84
+ var(--signal) 50%,
85
+ color-mix(in srgb, var(--foreground) 70%, transparent) 100%
86
+ );
87
+ background-size: 200% auto;
88
+ background-clip: text;
89
+ -webkit-background-clip: text;
90
+ -webkit-text-fill-color: transparent;
91
+ animation: shimmer-sweep 3s ease-in-out infinite;
92
+ }
93
+
94
+ /* Error shake */
95
+ .animate-shake {
96
+ animation: shake 450ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
97
+ }
98
+
99
+ /* Pop-in (badges, counters, success checks) */
100
+ .animate-pop-in {
101
+ animation: pop-in 240ms var(--ease-spring-bounce) both;
102
+ }
103
+
104
+ /* Pulse glow on signal ring */
105
+ .animate-pulse-glow {
106
+ animation: pulse-glow 2.4s ease-in-out infinite;
107
+ }
108
+
109
+ /* 3D float */
110
+ .animate-float {
111
+ animation: float-3d 6s ease-in-out infinite;
112
+ }
113
+
114
+ /* Ambient glow orb */
115
+ .ambient-orb {
116
+ position: absolute;
117
+ border-radius: 9999px;
118
+ filter: blur(70px);
119
+ pointer-events: none;
120
+ opacity: 0.45;
121
+ animation: orb-pulse 8s ease-in-out infinite alternate;
122
+ }
123
+ }
124
+
125
+ @keyframes shimmer-sweep {
126
+ 0% {
127
+ background-position: 200% center;
128
+ }
129
+ 100% {
130
+ background-position: -200% center;
131
+ }
132
+ }
133
+
134
+ @keyframes shake {
135
+ 10%, 90% {
136
+ transform: translate3d(-1px, 0, 0);
137
+ }
138
+ 20%, 80% {
139
+ transform: translate3d(2px, 0, 0);
140
+ }
141
+ 30%, 50%, 70% {
142
+ transform: translate3d(-4px, 0, 0);
143
+ }
144
+ 40%, 60% {
145
+ transform: translate3d(4px, 0, 0);
146
+ }
147
+ }
148
+
149
+ @keyframes pop-in {
150
+ 0% {
151
+ opacity: 0;
152
+ transform: scale(0.7) translateY(4px);
153
+ }
154
+ 100% {
155
+ opacity: 1;
156
+ transform: scale(1) translateY(0);
157
+ }
158
+ }
159
+
160
+ @keyframes pulse-glow {
161
+ 0%, 100% {
162
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--signal) 40%, transparent);
163
+ }
164
+ 50% {
165
+ box-shadow: 0 0 0 14px color-mix(in srgb, var(--signal) 0%, transparent);
166
+ }
167
+ }
168
+
169
+ @keyframes float-3d {
170
+ 0%, 100% {
171
+ transform: translateY(0) rotateX(0deg) rotateY(0deg);
172
+ }
173
+ 50% {
174
+ transform: translateY(-8px) rotateX(2deg) rotateY(-3deg);
175
+ }
176
+ }
177
+
178
+ @keyframes orb-pulse {
179
+ 0% {
180
+ transform: scale(1) translate(0, 0);
181
+ opacity: 0.35;
182
+ }
183
+ 100% {
184
+ transform: scale(1.15) translate(20px, -20px);
185
+ opacity: 0.55;
186
+ }
187
+ }
188
+
189
+ @keyframes counter-flip {
190
+ 0% {
191
+ opacity: 0;
192
+ transform: translateY(10px) rotateX(-45deg);
193
+ filter: blur(2px);
194
+ }
195
+ 100% {
196
+ opacity: 1;
197
+ transform: translateY(0) rotateX(0);
198
+ filter: blur(0);
199
+ }
200
+ }
201
+
202
+ @keyframes text-swap {
203
+ 0% {
204
+ opacity: 0;
205
+ transform: translateY(6px);
206
+ filter: blur(4px);
207
+ }
208
+ 100% {
209
+ opacity: 1;
210
+ transform: translateY(0);
211
+ filter: blur(0);
212
+ }
213
+ }
214
+
215
+ @layer mlola.motion {
216
+ /* A light surface is already near white, so `screen` has no headroom and the
217
+ glare disappears. Tint the surface in light, lift it in dark. */
218
+ .ml-motion-tilt-glare {
219
+ position: absolute;
220
+ inset: 0;
221
+ border-radius: inherit;
222
+ pointer-events: none;
223
+ opacity: var(--mlola-glare-opacity, 0);
224
+ transition: opacity 240ms ease;
225
+ background: radial-gradient(
226
+ circle at var(--mlola-glare-x, 50%) var(--mlola-glare-y, 50%),
227
+ color-mix(in srgb, var(--ml-primary) 34%, transparent),
228
+ transparent 62%
229
+ );
230
+ mix-blend-mode: multiply;
231
+ }
232
+
233
+ [data-mode="dark"] .ml-motion-tilt-glare {
234
+ background: radial-gradient(
235
+ circle at var(--mlola-glare-x, 50%) var(--mlola-glare-y, 50%),
236
+ color-mix(in srgb, var(--ml-primary) 42%, transparent),
237
+ transparent 62%
238
+ );
239
+ mix-blend-mode: screen;
240
+ }
241
+
242
+ .ml-motion-counter {
243
+ display: inline-flex;
244
+ font-variant-numeric: tabular-nums;
245
+ }
246
+
247
+ .ml-motion-counter-digit {
248
+ display: inline-block;
249
+ }
250
+
251
+ .ml-motion-text {
252
+ display: inline-block;
253
+ }
254
+
255
+ .ml-motion-shimmer {
256
+ background: linear-gradient(
257
+ 90deg,
258
+ color-mix(in srgb, var(--foreground) 70%, transparent) 0%,
259
+ var(--signal) 50%,
260
+ color-mix(in srgb, var(--foreground) 70%, transparent) 100%
261
+ );
262
+ background-size: 200% auto;
263
+ background-clip: text;
264
+ -webkit-background-clip: text;
265
+ -webkit-text-fill-color: transparent;
266
+ animation: shimmer-sweep 3s ease-in-out infinite;
267
+ }
268
+
269
+ .ml-motion-magnetic {
270
+ display: inline-block;
271
+ }
272
+
273
+ .ml-motion-origin-morph {
274
+ transform: translateZ(0);
275
+ }
276
+ }
277
+
278
+ @media (prefers-reduced-motion: reduce) {
279
+ .animate-shimmer,
280
+ .animate-shake,
281
+ .animate-pop-in,
282
+ .animate-pulse-glow,
283
+ .animate-float,
284
+ .ambient-orb,
285
+ .ml-motion-shimmer {
286
+ animation: none !important;
287
+ }
288
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@mlola-ui/motion",
3
+ "version": "1.0.0",
4
+ "description": "Reduced-motion-aware kinetic primitives for Mlola UI",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "sideEffects": [
9
+ "./motion.css"
10
+ ],
11
+ "files": [
12
+ "src",
13
+ "motion.css"
14
+ ],
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./physics": "./src/spring.ts",
18
+ "./raf": "./src/raf.ts",
19
+ "./motion.css": "./motion.css"
20
+ },
21
+ "peerDependencies": {
22
+ "react": ">=18",
23
+ "react-dom": ">=18"
24
+ },
25
+ "license": "MIT",
26
+ "author": "Mlola",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/mlolahq/mlola-ui.git",
30
+ "directory": "packages/motion"
31
+ },
32
+ "homepage": "https://ui.mlola.com",
33
+ "bugs": {
34
+ "url": "https://github.com/mlolahq/mlola-ui/issues"
35
+ },
36
+ "keywords": [
37
+ "mlola",
38
+ "ui",
39
+ "motion",
40
+ "spring",
41
+ "animation",
42
+ "css"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
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";
@@ -0,0 +1,54 @@
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
+ }
@@ -0,0 +1,230 @@
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
+ ...props
21
+ }: AnimatedCounterProps) {
22
+ const reducedMotion = useReducedMotion();
23
+ const text = String(value);
24
+ return (
25
+ <span
26
+ {...props}
27
+ className={joinClasses("ml-motion-counter", className)}
28
+ aria-label={props["aria-label"] ?? text}
29
+ >
30
+ {Array.from(text).map((character, index) => (
31
+ <span
32
+ aria-hidden="true"
33
+ key={`${index}-${character}`}
34
+ className="ml-motion-counter-digit"
35
+ style={
36
+ reducedMotion
37
+ ? undefined
38
+ : {
39
+ animation: "counter-flip 280ms cubic-bezier(0.34, 1.56, 0.64, 1) both",
40
+ animationDelay: `${index * stagger}ms`,
41
+ }
42
+ }
43
+ >
44
+ {character}
45
+ </span>
46
+ ))}
47
+ </span>
48
+ );
49
+ }
50
+
51
+ export interface TextSwapProps extends React.HTMLAttributes<HTMLSpanElement> {
52
+ text: string;
53
+ }
54
+
55
+ export function TextSwap({ text, className, style, ...props }: TextSwapProps) {
56
+ const reducedMotion = useReducedMotion();
57
+ return (
58
+ <span
59
+ {...props}
60
+ key={text}
61
+ className={joinClasses("ml-motion-text", className)}
62
+ style={{
63
+ animation: reducedMotion
64
+ ? undefined
65
+ : "text-swap 240ms cubic-bezier(0.16, 1, 0.3, 1) both",
66
+ ...style,
67
+ }}
68
+ >
69
+ {text}
70
+ </span>
71
+ );
72
+ }
73
+
74
+ export function ShimmerText({
75
+ children,
76
+ className,
77
+ ...props
78
+ }: React.HTMLAttributes<HTMLSpanElement>) {
79
+ const reducedMotion = useReducedMotion();
80
+ return (
81
+ <span
82
+ {...props}
83
+ className={joinClasses(!reducedMotion && "ml-motion-shimmer", className)}
84
+ >
85
+ {children}
86
+ </span>
87
+ );
88
+ }
89
+
90
+ export interface MagneticProps extends React.HTMLAttributes<HTMLDivElement> {
91
+ pull?: number;
92
+ }
93
+
94
+ export const Magnetic = React.forwardRef<HTMLDivElement, MagneticProps>(
95
+ (
96
+ {
97
+ children,
98
+ pull = 0.25,
99
+ className,
100
+ style,
101
+ onPointerMove,
102
+ onPointerLeave,
103
+ onPointerCancel,
104
+ ...props
105
+ },
106
+ forwardedRef
107
+ ) => {
108
+ const reducedMotion = useReducedMotion();
109
+ const nodeRef = React.useRef<HTMLDivElement>(null);
110
+ const offset = React.useRef({ x: 0, y: 0 });
111
+ const setRef = React.useCallback(
112
+ (node: HTMLDivElement | null) => {
113
+ nodeRef.current = node;
114
+ if (typeof forwardedRef === "function") forwardedRef(node);
115
+ else if (forwardedRef) forwardedRef.current = node;
116
+ },
117
+ [forwardedRef]
118
+ );
119
+
120
+ // The pull is a real spring, not a CSS transition: it can be interrupted
121
+ // mid-flight, and the loop stops the moment it comes to rest.
122
+ const paint = React.useCallback(() => {
123
+ const node = nodeRef.current;
124
+ if (!node) return;
125
+ node.style.transform = `translate3d(${offset.current.x.toFixed(2)}px, ${offset.current.y.toFixed(2)}px, 0)`;
126
+ }, []);
127
+ const springX = React.useMemo(
128
+ () =>
129
+ createSpringAnimation({
130
+ options: springPresets.snappy,
131
+ onUpdate: (value) => {
132
+ offset.current.x = value;
133
+ paint();
134
+ },
135
+ }),
136
+ [paint]
137
+ );
138
+ const springY = React.useMemo(
139
+ () =>
140
+ createSpringAnimation({
141
+ options: springPresets.snappy,
142
+ onUpdate: (value) => {
143
+ offset.current.y = value;
144
+ paint();
145
+ },
146
+ }),
147
+ [paint]
148
+ );
149
+
150
+ React.useEffect(
151
+ () => () => {
152
+ springX.stop();
153
+ springY.stop();
154
+ },
155
+ [springX, springY]
156
+ );
157
+
158
+ const moveTo = (x: number, y: number) => {
159
+ springX.setTarget(x);
160
+ springY.setTarget(y);
161
+ };
162
+ const reset = () => moveTo(0, 0);
163
+
164
+ const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
165
+ onPointerMove?.(event);
166
+ const node = nodeRef.current;
167
+ if (event.defaultPrevented || reducedMotion || event.pointerType === "touch" || !node) return;
168
+ const rect = node.getBoundingClientRect();
169
+ moveTo(
170
+ (event.clientX - (rect.left + rect.width / 2)) * pull,
171
+ (event.clientY - (rect.top + rect.height / 2)) * pull
172
+ );
173
+ };
174
+
175
+ React.useEffect(() => {
176
+ if (!reducedMotion) return;
177
+ springX.set(0);
178
+ springY.set(0);
179
+ }, [reducedMotion, springX, springY]);
180
+
181
+ return (
182
+ <div
183
+ {...props}
184
+ ref={setRef}
185
+ onPointerMove={handlePointerMove}
186
+ onPointerLeave={(event) => {
187
+ onPointerLeave?.(event);
188
+ reset();
189
+ }}
190
+ onPointerCancel={(event) => {
191
+ onPointerCancel?.(event);
192
+ reset();
193
+ }}
194
+ className={joinClasses("ml-motion-magnetic", className)}
195
+ style={{
196
+ transform: "translate3d(0, 0, 0)",
197
+ willChange: reducedMotion ? undefined : "transform",
198
+ ...style,
199
+ }}
200
+ >
201
+ {children}
202
+ </div>
203
+ );
204
+ }
205
+ );
206
+ Magnetic.displayName = "Magnetic";
207
+
208
+ export interface OriginMorphProps extends React.HTMLAttributes<HTMLDivElement> {
209
+ isOpen: boolean;
210
+ }
211
+
212
+ export function OriginMorph({
213
+ isOpen,
214
+ children,
215
+ className,
216
+ style,
217
+ ...props
218
+ }: OriginMorphProps) {
219
+ const reducedMotion = useReducedMotion();
220
+ if (!isOpen) return null;
221
+ return (
222
+ <div
223
+ {...props}
224
+ className={joinClasses("ml-motion-origin-morph", !reducedMotion && "ml-animate-pop", className)}
225
+ style={style}
226
+ >
227
+ {children}
228
+ </div>
229
+ );
230
+ }
package/src/raf.ts ADDED
@@ -0,0 +1,52 @@
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
+ }
@@ -0,0 +1,20 @@
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 ADDED
@@ -0,0 +1,134 @@
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
+ }
@@ -0,0 +1,139 @@
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";