@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 +21 -0
- package/README.md +36 -0
- package/motion.css +288 -0
- package/package.json +47 -0
- package/src/index.ts +6 -0
- package/src/pointer-math.ts +54 -0
- package/src/primitives.tsx +230 -0
- package/src/raf.ts +52 -0
- package/src/reduced-motion.ts +20 -0
- package/src/spring.ts +134 -0
- package/src/tilt-3d.tsx +139 -0
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,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
|
+
}
|
package/src/tilt-3d.tsx
ADDED
|
@@ -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";
|