levita-js 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +558 -0
- package/dist/index.d.cts +149 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +149 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +554 -0
- package/dist/index.mjs.map +1 -0
- package/dist/style.css +56 -0
- package/package.json +51 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
|
|
3
|
+
//#region src/constants.ts
|
|
4
|
+
/** All keys of `LevitaOptions`, derived from `DEFAULT_OPTIONS`. */
|
|
5
|
+
const OPTION_KEYS = [
|
|
6
|
+
"max",
|
|
7
|
+
"perspective",
|
|
8
|
+
"scale",
|
|
9
|
+
"speed",
|
|
10
|
+
"easing",
|
|
11
|
+
"reverse",
|
|
12
|
+
"axis",
|
|
13
|
+
"reset",
|
|
14
|
+
"glare",
|
|
15
|
+
"maxGlare",
|
|
16
|
+
"shadow",
|
|
17
|
+
"gyroscope",
|
|
18
|
+
"disabled",
|
|
19
|
+
"eventsEl"
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Build a partial `LevitaOptions` object from a source,
|
|
23
|
+
* including only keys that are explicitly defined.
|
|
24
|
+
*/
|
|
25
|
+
const buildOptions = (source) => {
|
|
26
|
+
const options = {};
|
|
27
|
+
for (const key of OPTION_KEYS) if (source[key] !== void 0) options[key] = source[key];
|
|
28
|
+
return options;
|
|
29
|
+
};
|
|
30
|
+
const DEFAULT_OPTIONS = {
|
|
31
|
+
max: 15,
|
|
32
|
+
perspective: 1e3,
|
|
33
|
+
scale: 1.05,
|
|
34
|
+
speed: 200,
|
|
35
|
+
easing: "ease-out",
|
|
36
|
+
reverse: false,
|
|
37
|
+
axis: null,
|
|
38
|
+
reset: true,
|
|
39
|
+
glare: false,
|
|
40
|
+
maxGlare: .5,
|
|
41
|
+
shadow: false,
|
|
42
|
+
gyroscope: "auto",
|
|
43
|
+
disabled: false,
|
|
44
|
+
eventsEl: null
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/effects/glare.ts
|
|
49
|
+
/**
|
|
50
|
+
* Creates a radial gradient overlay that follows the tilt position,
|
|
51
|
+
* simulating light reflection on the surface.
|
|
52
|
+
*
|
|
53
|
+
* Injects two DOM elements (.levita-glare > .levita-glare-inner) into
|
|
54
|
+
* the target element. Position and opacity are driven by CSS custom
|
|
55
|
+
* properties — no JS runs per animation frame.
|
|
56
|
+
*/
|
|
57
|
+
var GlareEffect = class {
|
|
58
|
+
container;
|
|
59
|
+
inner;
|
|
60
|
+
maxOpacity;
|
|
61
|
+
/**
|
|
62
|
+
* @param el - The element to attach the glare overlay to
|
|
63
|
+
* @param maxOpacity - Maximum glare opacity (0-1)
|
|
64
|
+
*/
|
|
65
|
+
constructor(el, maxOpacity) {
|
|
66
|
+
this.maxOpacity = maxOpacity;
|
|
67
|
+
this.container = document.createElement("div");
|
|
68
|
+
this.container.classList.add("levita-glare");
|
|
69
|
+
this.inner = document.createElement("div");
|
|
70
|
+
this.inner.classList.add("levita-glare-inner");
|
|
71
|
+
this.container.appendChild(this.inner);
|
|
72
|
+
el.appendChild(this.container);
|
|
73
|
+
if (!el.style.position || el.style.position === "static") el.style.position = "relative";
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Update glare position and intensity based on normalized tilt values.
|
|
77
|
+
* Sets CSS custom properties that the stylesheet uses for rendering.
|
|
78
|
+
*
|
|
79
|
+
* @param normalizedX - Horizontal position [-1, 1]
|
|
80
|
+
* @param normalizedY - Vertical position [-1, 1]
|
|
81
|
+
*/
|
|
82
|
+
update = (normalizedX, normalizedY) => {
|
|
83
|
+
const glareX = (normalizedX + 1) / 2 * 100;
|
|
84
|
+
const glareY = (normalizedY + 1) / 2 * 100;
|
|
85
|
+
const intensity = Math.sqrt(normalizedX ** 2 + normalizedY ** 2) / Math.SQRT2;
|
|
86
|
+
this.inner.style.setProperty("--levita-glare-x", `${glareX}%`);
|
|
87
|
+
this.inner.style.setProperty("--levita-glare-y", `${glareY}%`);
|
|
88
|
+
this.inner.style.setProperty("--levita-glare-opacity", `${intensity * this.maxOpacity}`);
|
|
89
|
+
};
|
|
90
|
+
/** Remove the glare DOM elements from the parent. */
|
|
91
|
+
destroy = () => {
|
|
92
|
+
this.container.remove();
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/effects/shadow.ts
|
|
98
|
+
/**
|
|
99
|
+
* Adds a dynamic drop shadow that shifts based on the tilt angle,
|
|
100
|
+
* reinforcing the 3D depth illusion.
|
|
101
|
+
*
|
|
102
|
+
* Uses CSS custom properties (--levita-shadow-x, --levita-shadow-y)
|
|
103
|
+
* combined with `filter: drop-shadow()` — no JS runs per animation frame.
|
|
104
|
+
*/
|
|
105
|
+
var ShadowEffect = class {
|
|
106
|
+
el;
|
|
107
|
+
maxOffset;
|
|
108
|
+
/**
|
|
109
|
+
* @param el - The element to apply the shadow to
|
|
110
|
+
* @param maxOffset - Maximum shadow offset in pixels (default: 20)
|
|
111
|
+
*/
|
|
112
|
+
constructor(el, maxOffset = 20) {
|
|
113
|
+
this.el = el;
|
|
114
|
+
this.maxOffset = maxOffset;
|
|
115
|
+
this.el.classList.add("levita-shadow");
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Update shadow offset based on normalized tilt values.
|
|
119
|
+
*
|
|
120
|
+
* @param normalizedX - Horizontal position [-1, 1]
|
|
121
|
+
* @param normalizedY - Vertical position [-1, 1]
|
|
122
|
+
*/
|
|
123
|
+
update = (normalizedX, normalizedY) => {
|
|
124
|
+
const shadowX = normalizedX * this.maxOffset;
|
|
125
|
+
const shadowY = normalizedY * this.maxOffset;
|
|
126
|
+
this.el.style.setProperty("--levita-shadow-x", `${shadowX}px`);
|
|
127
|
+
this.el.style.setProperty("--levita-shadow-y", `${shadowY}px`);
|
|
128
|
+
};
|
|
129
|
+
/** Remove the shadow class and clean up CSS custom properties. */
|
|
130
|
+
destroy = () => {
|
|
131
|
+
this.el.classList.remove("levita-shadow");
|
|
132
|
+
this.el.style.removeProperty("--levita-shadow-x");
|
|
133
|
+
this.el.style.removeProperty("--levita-shadow-y");
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/layers.ts
|
|
139
|
+
/**
|
|
140
|
+
* Scan a container for children with `data-levita-offset` attributes
|
|
141
|
+
* and set the `--levita-offset` CSS custom property on each one.
|
|
142
|
+
*
|
|
143
|
+
* The CSS stylesheet uses this variable with `translateZ()` to position
|
|
144
|
+
* layers at different depths — no JS runs per animation frame.
|
|
145
|
+
*
|
|
146
|
+
* @param container - The parent element to scan for layer children
|
|
147
|
+
* @returns Array of discovered layers with their elements and offsets
|
|
148
|
+
*/
|
|
149
|
+
const scanLayers = (container) => {
|
|
150
|
+
const elements = container.querySelectorAll("[data-levita-offset]");
|
|
151
|
+
const layers = [];
|
|
152
|
+
for (const el of elements) {
|
|
153
|
+
const raw = el.dataset.levitaOffset;
|
|
154
|
+
const offset = Number.parseFloat(raw ?? "0");
|
|
155
|
+
if (!Number.isNaN(offset)) {
|
|
156
|
+
el.style.setProperty("--levita-offset", String(offset));
|
|
157
|
+
layers.push({
|
|
158
|
+
el,
|
|
159
|
+
offset
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return layers;
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Remove the `--levita-offset` CSS custom property from all layer elements.
|
|
167
|
+
*
|
|
168
|
+
* @param layers - The layers to clean up
|
|
169
|
+
*/
|
|
170
|
+
const cleanupLayers = (layers) => {
|
|
171
|
+
for (const layer of layers) layer.el.style.removeProperty("--levita-offset");
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/sensors/motion.ts
|
|
176
|
+
/**
|
|
177
|
+
* Reads device orientation (accelerometer/gyroscope) and normalizes
|
|
178
|
+
* the tilt angles to a [-1, 1] range.
|
|
179
|
+
*
|
|
180
|
+
* Handles iOS 13+ permission flow via async `requestPermission()`.
|
|
181
|
+
* On Android, permission is granted automatically.
|
|
182
|
+
*
|
|
183
|
+
* Uses exponential moving average for smoothing raw sensor data.
|
|
184
|
+
*/
|
|
185
|
+
var MotionSensor = class MotionSensor {
|
|
186
|
+
onMove;
|
|
187
|
+
axis;
|
|
188
|
+
active = false;
|
|
189
|
+
permitted = false;
|
|
190
|
+
minAngle;
|
|
191
|
+
maxAngle;
|
|
192
|
+
smoothing;
|
|
193
|
+
lastX = 0;
|
|
194
|
+
lastY = 0;
|
|
195
|
+
/**
|
|
196
|
+
* @param onMove - Callback receiving normalized { x, y } values
|
|
197
|
+
* @param axis - Restrict input to a single axis, or null for both
|
|
198
|
+
* @param minAngle - Minimum device angle mapped to -1 (default: -45)
|
|
199
|
+
* @param maxAngle - Maximum device angle mapped to 1 (default: 45)
|
|
200
|
+
* @param smoothing - Exponential moving average factor 0-1 (default: 0.15, lower = smoother)
|
|
201
|
+
*/
|
|
202
|
+
constructor(onMove, axis, minAngle = -45, maxAngle = 45, smoothing = .15) {
|
|
203
|
+
this.onMove = onMove;
|
|
204
|
+
this.axis = axis;
|
|
205
|
+
this.minAngle = minAngle;
|
|
206
|
+
this.maxAngle = maxAngle;
|
|
207
|
+
this.smoothing = smoothing;
|
|
208
|
+
}
|
|
209
|
+
/** Check if the DeviceOrientationEvent API is available in this environment. */
|
|
210
|
+
static isSupported = () => {
|
|
211
|
+
return typeof window !== "undefined" && "DeviceOrientationEvent" in window;
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* Check if explicit permission is required (iOS 13+).
|
|
215
|
+
* On Android and desktop, this returns false.
|
|
216
|
+
*/
|
|
217
|
+
static needsPermission = () => {
|
|
218
|
+
return typeof DeviceOrientationEvent !== "undefined" && "requestPermission" in DeviceOrientationEvent;
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* Request permission to access device orientation.
|
|
222
|
+
* On Android, resolves immediately to true.
|
|
223
|
+
* On iOS 13+, triggers the native permission dialog.
|
|
224
|
+
* Must be called from a user gesture on iOS.
|
|
225
|
+
*
|
|
226
|
+
* @returns Whether permission was granted
|
|
227
|
+
*/
|
|
228
|
+
requestPermission = async () => {
|
|
229
|
+
if (!MotionSensor.isSupported()) return false;
|
|
230
|
+
if (!MotionSensor.needsPermission()) {
|
|
231
|
+
this.permitted = true;
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
this.permitted = await DeviceOrientationEvent.requestPermission() === "granted";
|
|
236
|
+
return this.permitted;
|
|
237
|
+
} catch {
|
|
238
|
+
this.permitted = false;
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
/** Start listening to deviceorientation events. Requires prior permission. */
|
|
243
|
+
start = () => {
|
|
244
|
+
if (this.active || !this.permitted) return;
|
|
245
|
+
this.active = true;
|
|
246
|
+
window.addEventListener("deviceorientation", this.handleOrientation);
|
|
247
|
+
};
|
|
248
|
+
/** Stop listening and remove the deviceorientation event listener. */
|
|
249
|
+
stop = () => {
|
|
250
|
+
if (!this.active) return;
|
|
251
|
+
this.active = false;
|
|
252
|
+
window.removeEventListener("deviceorientation", this.handleOrientation);
|
|
253
|
+
};
|
|
254
|
+
/** Update the axis lock at runtime. */
|
|
255
|
+
setAxis = (axis) => {
|
|
256
|
+
this.axis = axis;
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Normalize device orientation angles to [-1, 1] and apply
|
|
260
|
+
* exponential moving average smoothing.
|
|
261
|
+
*
|
|
262
|
+
* beta = X-axis rotation [-180, 180] (front-back tilt)
|
|
263
|
+
* gamma = Y-axis rotation [-90, 90] (left-right tilt)
|
|
264
|
+
*/
|
|
265
|
+
handleOrientation = (e) => {
|
|
266
|
+
const evt = e;
|
|
267
|
+
const beta = evt.beta ?? 0;
|
|
268
|
+
const gamma = evt.gamma ?? 0;
|
|
269
|
+
const range = this.maxAngle - this.minAngle;
|
|
270
|
+
const rawX = this.axis === "y" ? 0 : this.clamp((gamma - this.minAngle) / range * 2 - 1);
|
|
271
|
+
const rawY = this.axis === "x" ? 0 : this.clamp((beta - this.minAngle) / range * 2 - 1);
|
|
272
|
+
this.lastX = this.lastX + (rawX - this.lastX) * this.smoothing;
|
|
273
|
+
this.lastY = this.lastY + (rawY - this.lastY) * this.smoothing;
|
|
274
|
+
this.onMove({
|
|
275
|
+
x: this.lastX,
|
|
276
|
+
y: this.lastY
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
/** Clamp a value to the [-1, 1] range. */
|
|
280
|
+
clamp = (value) => {
|
|
281
|
+
return Math.max(-1, Math.min(1, value));
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/sensors/pointer.ts
|
|
287
|
+
/**
|
|
288
|
+
* Tracks pointer (mouse/touch) position over an element and normalizes
|
|
289
|
+
* it to a [-1, 1] range relative to the element's bounds.
|
|
290
|
+
*
|
|
291
|
+
* Uses PointerEvent for unified mouse + touch input.
|
|
292
|
+
*/
|
|
293
|
+
var PointerSensor = class {
|
|
294
|
+
eventsEl;
|
|
295
|
+
onMove;
|
|
296
|
+
onEnter;
|
|
297
|
+
onLeave;
|
|
298
|
+
axis;
|
|
299
|
+
active = false;
|
|
300
|
+
rect = null;
|
|
301
|
+
constructor(el, onMove, onEnter, onLeave, axis, eventsEl = null) {
|
|
302
|
+
this.eventsEl = eventsEl ?? el;
|
|
303
|
+
this.onMove = onMove;
|
|
304
|
+
this.onEnter = onEnter;
|
|
305
|
+
this.onLeave = onLeave;
|
|
306
|
+
this.axis = axis;
|
|
307
|
+
}
|
|
308
|
+
/** Start listening to pointer events. */
|
|
309
|
+
start = () => {
|
|
310
|
+
if (this.active) return;
|
|
311
|
+
this.active = true;
|
|
312
|
+
this.eventsEl.addEventListener("pointermove", this.handlePointerMove);
|
|
313
|
+
this.eventsEl.addEventListener("pointerenter", this.handlePointerEnter);
|
|
314
|
+
this.eventsEl.addEventListener("pointerleave", this.handlePointerLeave);
|
|
315
|
+
};
|
|
316
|
+
/** Stop listening and remove all pointer event listeners. */
|
|
317
|
+
stop = () => {
|
|
318
|
+
if (!this.active) return;
|
|
319
|
+
this.active = false;
|
|
320
|
+
this.eventsEl.removeEventListener("pointermove", this.handlePointerMove);
|
|
321
|
+
this.eventsEl.removeEventListener("pointerenter", this.handlePointerEnter);
|
|
322
|
+
this.eventsEl.removeEventListener("pointerleave", this.handlePointerLeave);
|
|
323
|
+
};
|
|
324
|
+
/** Update the axis lock at runtime. */
|
|
325
|
+
setAxis = (axis) => {
|
|
326
|
+
this.axis = axis;
|
|
327
|
+
};
|
|
328
|
+
/**
|
|
329
|
+
* Compute normalized [-1, 1] position from pointer coordinates
|
|
330
|
+
* relative to the element's bounding rect. Respects axis locking.
|
|
331
|
+
*/
|
|
332
|
+
handlePointerMove = (e) => {
|
|
333
|
+
if (!this.rect) return;
|
|
334
|
+
const rawX = (e.clientX - this.rect.left) / this.rect.width;
|
|
335
|
+
const rawY = (e.clientY - this.rect.top) / this.rect.height;
|
|
336
|
+
const x = this.axis === "y" ? 0 : rawX * 2 - 1;
|
|
337
|
+
const y = this.axis === "x" ? 0 : rawY * 2 - 1;
|
|
338
|
+
this.onMove({
|
|
339
|
+
x,
|
|
340
|
+
y
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
handlePointerEnter = () => {
|
|
344
|
+
this.rect = this.eventsEl.getBoundingClientRect();
|
|
345
|
+
this.onEnter?.();
|
|
346
|
+
};
|
|
347
|
+
handlePointerLeave = () => {
|
|
348
|
+
this.rect = null;
|
|
349
|
+
this.onLeave?.();
|
|
350
|
+
};
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region src/levita.ts
|
|
355
|
+
/**
|
|
356
|
+
* Main entry point for the Levita 3D tilt effect.
|
|
357
|
+
*
|
|
358
|
+
* Orchestrates sensors (pointer, accelerometer), visual effects (glare, shadow),
|
|
359
|
+
* and multi-layer parallax. All rendering is driven by CSS custom properties —
|
|
360
|
+
* no requestAnimationFrame loop runs during interaction.
|
|
361
|
+
*
|
|
362
|
+
* @example
|
|
363
|
+
* ```ts
|
|
364
|
+
* import { Levita } from 'levita-js';
|
|
365
|
+
* import 'levita-js/style.css';
|
|
366
|
+
*
|
|
367
|
+
* const tilt = new Levita(element, { glare: true, shadow: true });
|
|
368
|
+
* tilt.on('move', ({ x, y }) => console.log(x, y));
|
|
369
|
+
* ```
|
|
370
|
+
*/
|
|
371
|
+
var Levita = class {
|
|
372
|
+
el;
|
|
373
|
+
options;
|
|
374
|
+
pointerSensor;
|
|
375
|
+
motionSensor = null;
|
|
376
|
+
glareEffect = null;
|
|
377
|
+
shadowEffect = null;
|
|
378
|
+
layers = [];
|
|
379
|
+
listeners = /* @__PURE__ */ new Map();
|
|
380
|
+
destroyed = false;
|
|
381
|
+
gyroscopeRequested = false;
|
|
382
|
+
/**
|
|
383
|
+
* @param el - The DOM element to apply the tilt effect to
|
|
384
|
+
* @param options - Configuration options (all optional, sensible defaults)
|
|
385
|
+
*/
|
|
386
|
+
constructor(el, options = {}) {
|
|
387
|
+
this.el = el;
|
|
388
|
+
this.options = {
|
|
389
|
+
...DEFAULT_OPTIONS,
|
|
390
|
+
...options
|
|
391
|
+
};
|
|
392
|
+
this.el.classList.add("levita");
|
|
393
|
+
this.applyBaseProperties();
|
|
394
|
+
this.layers = scanLayers(this.el);
|
|
395
|
+
if (this.options.glare) this.glareEffect = new GlareEffect(this.el, this.options.maxGlare);
|
|
396
|
+
if (this.options.shadow) this.shadowEffect = new ShadowEffect(this.el);
|
|
397
|
+
this.pointerSensor = new PointerSensor(this.el, (values) => this.handleSensorInput(values), () => this.handleEnter(), () => this.handleLeave(), this.options.axis, this.options.eventsEl);
|
|
398
|
+
if (this.options.gyroscope !== false && MotionSensor.isSupported()) {
|
|
399
|
+
this.motionSensor = new MotionSensor((values) => this.handleSensorInput(values), this.options.axis);
|
|
400
|
+
if (this.options.gyroscope === "auto") this.el.addEventListener("click", this.handleFirstTouch, { once: true });
|
|
401
|
+
}
|
|
402
|
+
if (!this.options.disabled) this.enable();
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* On first touch (iOS auto mode), request accelerometer permission
|
|
406
|
+
* and switch from pointer to motion sensor if granted.
|
|
407
|
+
*/
|
|
408
|
+
handleFirstTouch = async () => {
|
|
409
|
+
if (this.gyroscopeRequested || !this.motionSensor) return;
|
|
410
|
+
this.gyroscopeRequested = true;
|
|
411
|
+
if (await this.motionSensor.requestPermission()) {
|
|
412
|
+
this.pointerSensor.stop();
|
|
413
|
+
this.setTransition(false);
|
|
414
|
+
this.motionSensor.start();
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
/** Re-enable the tilt effect after a `disable()` call. */
|
|
418
|
+
enable = () => {
|
|
419
|
+
if (this.destroyed) return;
|
|
420
|
+
this.options.disabled = false;
|
|
421
|
+
this.pointerSensor.start();
|
|
422
|
+
};
|
|
423
|
+
/** Pause the tilt effect and reset the element to its neutral position. */
|
|
424
|
+
disable = () => {
|
|
425
|
+
this.options.disabled = true;
|
|
426
|
+
this.pointerSensor.stop();
|
|
427
|
+
this.motionSensor?.stop();
|
|
428
|
+
this.resetTransform();
|
|
429
|
+
};
|
|
430
|
+
/**
|
|
431
|
+
* Manually request accelerometer permission (for `gyroscope: true` mode).
|
|
432
|
+
* Must be called from a user gesture on iOS 13+.
|
|
433
|
+
*
|
|
434
|
+
* @returns Whether permission was granted
|
|
435
|
+
*/
|
|
436
|
+
requestPermission = async () => {
|
|
437
|
+
if (!this.motionSensor) return false;
|
|
438
|
+
const granted = await this.motionSensor.requestPermission();
|
|
439
|
+
if (granted) {
|
|
440
|
+
this.pointerSensor.stop();
|
|
441
|
+
this.setTransition(false);
|
|
442
|
+
this.motionSensor.start();
|
|
443
|
+
}
|
|
444
|
+
return granted;
|
|
445
|
+
};
|
|
446
|
+
/**
|
|
447
|
+
* Fully clean up: stop sensors, remove effects, restore the element
|
|
448
|
+
* to its original state. The instance cannot be reused after this.
|
|
449
|
+
*/
|
|
450
|
+
destroy = () => {
|
|
451
|
+
if (this.destroyed) return;
|
|
452
|
+
this.destroyed = true;
|
|
453
|
+
this.pointerSensor.stop();
|
|
454
|
+
this.motionSensor?.stop();
|
|
455
|
+
this.glareEffect?.destroy();
|
|
456
|
+
this.shadowEffect?.destroy();
|
|
457
|
+
cleanupLayers(this.layers);
|
|
458
|
+
this.el.classList.remove("levita");
|
|
459
|
+
this.removeBaseProperties();
|
|
460
|
+
this.listeners.clear();
|
|
461
|
+
this.el.removeEventListener("click", this.handleFirstTouch);
|
|
462
|
+
};
|
|
463
|
+
/**
|
|
464
|
+
* Register an event listener.
|
|
465
|
+
*
|
|
466
|
+
* @param event - Event name: 'move', 'enter', or 'leave'
|
|
467
|
+
* @param callback - Handler function
|
|
468
|
+
*/
|
|
469
|
+
on = (event, callback) => {
|
|
470
|
+
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
471
|
+
this.listeners.get(event)?.add(callback);
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* Remove a previously registered event listener.
|
|
475
|
+
*
|
|
476
|
+
* @param event - Event name
|
|
477
|
+
* @param callback - The exact handler reference passed to `on()`
|
|
478
|
+
*/
|
|
479
|
+
off = (event, callback) => {
|
|
480
|
+
this.listeners.get(event)?.delete(callback);
|
|
481
|
+
};
|
|
482
|
+
/** Emit an event to all registered listeners. */
|
|
483
|
+
emit = (event, data) => {
|
|
484
|
+
const callbacks = this.listeners.get(event);
|
|
485
|
+
if (callbacks) for (const cb of callbacks) cb(data);
|
|
486
|
+
};
|
|
487
|
+
/**
|
|
488
|
+
* Process normalized sensor input and update CSS custom properties.
|
|
489
|
+
* Maps sensor X/Y to rotateY/rotateX (swapped, rotateX = vertical tilt).
|
|
490
|
+
*/
|
|
491
|
+
handleSensorInput = (input) => {
|
|
492
|
+
if (this.options.disabled) return;
|
|
493
|
+
const multiplier = this.options.reverse ? -1 : 1;
|
|
494
|
+
const x = input.y * this.options.max * multiplier;
|
|
495
|
+
const y = input.x * this.options.max * multiplier * -1;
|
|
496
|
+
this.el.style.setProperty("--levita-x", `${x}deg`);
|
|
497
|
+
this.el.style.setProperty("--levita-y", `${y}deg`);
|
|
498
|
+
this.el.style.setProperty("--levita-scale", String(this.options.scale));
|
|
499
|
+
this.el.style.setProperty("--levita-percent-x", String(input.x));
|
|
500
|
+
this.el.style.setProperty("--levita-percent-y", String(input.y));
|
|
501
|
+
this.glareEffect?.update(input.x, input.y);
|
|
502
|
+
this.shadowEffect?.update(input.x, input.y);
|
|
503
|
+
const values = {
|
|
504
|
+
x,
|
|
505
|
+
y,
|
|
506
|
+
percentX: input.x,
|
|
507
|
+
percentY: input.y
|
|
508
|
+
};
|
|
509
|
+
this.emit("move", values);
|
|
510
|
+
};
|
|
511
|
+
handleEnter = () => {
|
|
512
|
+
this.setTransition(false);
|
|
513
|
+
this.el.style.setProperty("--levita-scale", String(this.options.scale));
|
|
514
|
+
this.emit("enter", void 0);
|
|
515
|
+
};
|
|
516
|
+
handleLeave = () => {
|
|
517
|
+
this.setTransition(true);
|
|
518
|
+
if (this.options.reset) this.resetTransform();
|
|
519
|
+
this.emit("leave", void 0);
|
|
520
|
+
};
|
|
521
|
+
/** Toggle the CSS transition on or off. */
|
|
522
|
+
setTransition = (on) => {
|
|
523
|
+
this.el.style.setProperty("--levita-speed", on ? `${this.options.speed}ms` : "0ms");
|
|
524
|
+
};
|
|
525
|
+
/** Reset the element to its neutral (non-tilted) position. */
|
|
526
|
+
resetTransform = () => {
|
|
527
|
+
this.el.style.setProperty("--levita-x", "0deg");
|
|
528
|
+
this.el.style.setProperty("--levita-y", "0deg");
|
|
529
|
+
this.el.style.setProperty("--levita-scale", "1");
|
|
530
|
+
this.el.style.setProperty("--levita-percent-x", "0");
|
|
531
|
+
this.el.style.setProperty("--levita-percent-y", "0");
|
|
532
|
+
this.glareEffect?.update(0, 0);
|
|
533
|
+
this.shadowEffect?.update(0, 0);
|
|
534
|
+
};
|
|
535
|
+
/** Apply initial CSS custom properties from options. */
|
|
536
|
+
applyBaseProperties = () => {
|
|
537
|
+
this.el.style.setProperty("--levita-perspective", `${this.options.perspective}px`);
|
|
538
|
+
this.el.style.setProperty("--levita-speed", `${this.options.speed}ms`);
|
|
539
|
+
this.el.style.setProperty("--levita-easing", this.options.easing);
|
|
540
|
+
};
|
|
541
|
+
/** Remove all Levita CSS custom properties from the element. */
|
|
542
|
+
removeBaseProperties = () => {
|
|
543
|
+
this.el.style.removeProperty("--levita-perspective");
|
|
544
|
+
this.el.style.removeProperty("--levita-speed");
|
|
545
|
+
this.el.style.removeProperty("--levita-easing");
|
|
546
|
+
this.el.style.removeProperty("--levita-x");
|
|
547
|
+
this.el.style.removeProperty("--levita-y");
|
|
548
|
+
this.el.style.removeProperty("--levita-scale");
|
|
549
|
+
this.el.style.removeProperty("--levita-percent-x");
|
|
550
|
+
this.el.style.removeProperty("--levita-percent-y");
|
|
551
|
+
};
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
//#endregion
|
|
555
|
+
exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
|
|
556
|
+
exports.Levita = Levita;
|
|
557
|
+
exports.OPTION_KEYS = OPTION_KEYS;
|
|
558
|
+
exports.buildOptions = buildOptions;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
type Axis = "x" | "y" | null;
|
|
3
|
+
type GyroscopeMode = "auto" | boolean;
|
|
4
|
+
interface LevitaOptions {
|
|
5
|
+
/** Max tilt angle in degrees. Default: 15 */
|
|
6
|
+
max: number;
|
|
7
|
+
/** CSS perspective in px. Default: 1000 */
|
|
8
|
+
perspective: number;
|
|
9
|
+
/** Scale factor on hover. Default: 1.05 */
|
|
10
|
+
scale: number;
|
|
11
|
+
/** Transition duration in ms. Default: 200 */
|
|
12
|
+
speed: number;
|
|
13
|
+
/** CSS transition easing. Default: 'ease-out' */
|
|
14
|
+
easing: string;
|
|
15
|
+
/** Invert tilt direction. Default: false */
|
|
16
|
+
reverse: boolean;
|
|
17
|
+
/** Restrict tilt to a single axis. Default: null (both) */
|
|
18
|
+
axis: Axis;
|
|
19
|
+
/** Reset tilt on pointer leave. Default: true */
|
|
20
|
+
reset: boolean;
|
|
21
|
+
/** Enable glare effect. Default: false */
|
|
22
|
+
glare: boolean;
|
|
23
|
+
/** Max glare opacity (0-1). Default: 0.5 */
|
|
24
|
+
maxGlare: number;
|
|
25
|
+
/** Enable dynamic shadow. Default: false */
|
|
26
|
+
shadow: boolean;
|
|
27
|
+
/** Gyroscope mode. Default: 'auto' */
|
|
28
|
+
gyroscope: GyroscopeMode;
|
|
29
|
+
/** Disable the effect. Default: false */
|
|
30
|
+
disabled: boolean;
|
|
31
|
+
/** Element to listen for pointer events on. Default: the element itself */
|
|
32
|
+
eventsEl: HTMLElement | null;
|
|
33
|
+
}
|
|
34
|
+
interface TiltValues {
|
|
35
|
+
/** Tilt angle on X axis in degrees */
|
|
36
|
+
x: number;
|
|
37
|
+
/** Tilt angle on Y axis in degrees */
|
|
38
|
+
y: number;
|
|
39
|
+
/** Normalized X position [-1, 1] */
|
|
40
|
+
percentX: number;
|
|
41
|
+
/** Normalized Y position [-1, 1] */
|
|
42
|
+
percentY: number;
|
|
43
|
+
}
|
|
44
|
+
interface LevitaEventMap {
|
|
45
|
+
move: TiltValues;
|
|
46
|
+
enter: undefined;
|
|
47
|
+
leave: undefined;
|
|
48
|
+
}
|
|
49
|
+
type EventCallback<T> = (data: T) => void;
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/constants.d.ts
|
|
52
|
+
/** All keys of `LevitaOptions`, derived from `DEFAULT_OPTIONS`. */
|
|
53
|
+
declare const OPTION_KEYS: readonly (keyof LevitaOptions)[];
|
|
54
|
+
/**
|
|
55
|
+
* Build a partial `LevitaOptions` object from a source,
|
|
56
|
+
* including only keys that are explicitly defined.
|
|
57
|
+
*/
|
|
58
|
+
declare const buildOptions: (source: Partial<LevitaOptions>) => Partial<LevitaOptions>;
|
|
59
|
+
declare const DEFAULT_OPTIONS: LevitaOptions;
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/levita.d.ts
|
|
62
|
+
/**
|
|
63
|
+
* Main entry point for the Levita 3D tilt effect.
|
|
64
|
+
*
|
|
65
|
+
* Orchestrates sensors (pointer, accelerometer), visual effects (glare, shadow),
|
|
66
|
+
* and multi-layer parallax. All rendering is driven by CSS custom properties —
|
|
67
|
+
* no requestAnimationFrame loop runs during interaction.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* import { Levita } from 'levita-js';
|
|
72
|
+
* import 'levita-js/style.css';
|
|
73
|
+
*
|
|
74
|
+
* const tilt = new Levita(element, { glare: true, shadow: true });
|
|
75
|
+
* tilt.on('move', ({ x, y }) => console.log(x, y));
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
declare class Levita {
|
|
79
|
+
private el;
|
|
80
|
+
private options;
|
|
81
|
+
private pointerSensor;
|
|
82
|
+
private motionSensor;
|
|
83
|
+
private glareEffect;
|
|
84
|
+
private shadowEffect;
|
|
85
|
+
private layers;
|
|
86
|
+
private listeners;
|
|
87
|
+
private destroyed;
|
|
88
|
+
private gyroscopeRequested;
|
|
89
|
+
/**
|
|
90
|
+
* @param el - The DOM element to apply the tilt effect to
|
|
91
|
+
* @param options - Configuration options (all optional, sensible defaults)
|
|
92
|
+
*/
|
|
93
|
+
constructor(el: HTMLElement, options?: Partial<LevitaOptions>);
|
|
94
|
+
/**
|
|
95
|
+
* On first touch (iOS auto mode), request accelerometer permission
|
|
96
|
+
* and switch from pointer to motion sensor if granted.
|
|
97
|
+
*/
|
|
98
|
+
private handleFirstTouch;
|
|
99
|
+
/** Re-enable the tilt effect after a `disable()` call. */
|
|
100
|
+
enable: () => void;
|
|
101
|
+
/** Pause the tilt effect and reset the element to its neutral position. */
|
|
102
|
+
disable: () => void;
|
|
103
|
+
/**
|
|
104
|
+
* Manually request accelerometer permission (for `gyroscope: true` mode).
|
|
105
|
+
* Must be called from a user gesture on iOS 13+.
|
|
106
|
+
*
|
|
107
|
+
* @returns Whether permission was granted
|
|
108
|
+
*/
|
|
109
|
+
requestPermission: () => Promise<boolean>;
|
|
110
|
+
/**
|
|
111
|
+
* Fully clean up: stop sensors, remove effects, restore the element
|
|
112
|
+
* to its original state. The instance cannot be reused after this.
|
|
113
|
+
*/
|
|
114
|
+
destroy: () => void;
|
|
115
|
+
/**
|
|
116
|
+
* Register an event listener.
|
|
117
|
+
*
|
|
118
|
+
* @param event - Event name: 'move', 'enter', or 'leave'
|
|
119
|
+
* @param callback - Handler function
|
|
120
|
+
*/
|
|
121
|
+
on: <K extends keyof LevitaEventMap>(event: K, callback: EventCallback<LevitaEventMap[K]>) => void;
|
|
122
|
+
/**
|
|
123
|
+
* Remove a previously registered event listener.
|
|
124
|
+
*
|
|
125
|
+
* @param event - Event name
|
|
126
|
+
* @param callback - The exact handler reference passed to `on()`
|
|
127
|
+
*/
|
|
128
|
+
off: <K extends keyof LevitaEventMap>(event: K, callback: EventCallback<LevitaEventMap[K]>) => void;
|
|
129
|
+
/** Emit an event to all registered listeners. */
|
|
130
|
+
private emit;
|
|
131
|
+
/**
|
|
132
|
+
* Process normalized sensor input and update CSS custom properties.
|
|
133
|
+
* Maps sensor X/Y to rotateY/rotateX (swapped, rotateX = vertical tilt).
|
|
134
|
+
*/
|
|
135
|
+
private handleSensorInput;
|
|
136
|
+
private handleEnter;
|
|
137
|
+
private handleLeave;
|
|
138
|
+
/** Toggle the CSS transition on or off. */
|
|
139
|
+
private setTransition;
|
|
140
|
+
/** Reset the element to its neutral (non-tilted) position. */
|
|
141
|
+
private resetTransform;
|
|
142
|
+
/** Apply initial CSS custom properties from options. */
|
|
143
|
+
private applyBaseProperties;
|
|
144
|
+
/** Remove all Levita CSS custom properties from the element. */
|
|
145
|
+
private removeBaseProperties;
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
export { type Axis, DEFAULT_OPTIONS, type EventCallback, type GyroscopeMode, Levita, type LevitaEventMap, type LevitaOptions, OPTION_KEYS, type TiltValues, buildOptions };
|
|
149
|
+
//# sourceMappingURL=index.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts","../src/constants.ts","../src/levita.ts"],"mappings":";KAAY,IAAA;AAAA,KACA,aAAA;AAAA,UAEK,aAAA;;EAEhB,GAAA;EALe;EAOf,WAAA;EANwB;EAQxB,KAAA;EARwB;EAUxB,KAAA;EARgB;EAUhB,MAAA;;EAEA,OAAA;EAYW;EAVX,IAAA,EAAM,IAAA;EAce;EAZrB,KAAA;EAdA;EAgBA,KAAA;EAZA;EAcA,QAAA;EAVA;EAYA,MAAA;EARA;EAUA,SAAA,EAAW,aAAA;EARX;EAUA,QAAA;EANA;EAQA,QAAA,EAAU,WAAA;AAAA;AAAA,UAGM,UAAA;EALhB;EAOA,CAAA;EALU;EAOV,CAAA;EAPqB;EASrB,QAAA;EAN0B;EAQ1B,QAAA;AAAA;AAAA,UAGgB,cAAA;EAChB,IAAA,EAAM,UAAA;EACN,KAAA;EACA,KAAA;AAAA;AAAA,KAGW,aAAA,OAAoB,IAAA,EAAM,CAAA;;;AAnDtC;AAAA,cCGa,WAAA,kBAA6B,aAAA;;;;ADF1C;cCuBa,YAAA,GAAgB,MAAA,EAAQ,OAAA,CAAQ,aAAA,MAAiB,OAAA,CAAQ,aAAA;AAAA,cAWzD,eAAA,EAAiB,aAAA;;;ADnC9B;;;;;AACA;;;;;AAEA;;;;;;AAHA,cE0Ba,MAAA;EAAA,QACJ,EAAA;EAAA,QACA,OAAA;EAAA,QACA,aAAA;EAAA,QACA,YAAA;EAAA,QACA,WAAA;EAAA,QACA,YAAA;EAAA,QACA,MAAA;EAAA,QACA,SAAA;EAAA,QACA,SAAA;EAAA,QACA,kBAAA;EFfR;;;;cEqBY,EAAA,EAAI,WAAA,EAAa,OAAA,GAAS,OAAA,CAAQ,aAAA;EFb9C;;;;EAAA,QE0DQ,gBAAA;EFrDQ;EEkEhB,MAAA;;EAOA,OAAA;EFvEA;;;;;;EEoFA,iBAAA,QAA8B,OAAA;EF3EA;;;;EE0F9B,OAAA;EFxFA;;;;AAID;;EE2GC,EAAA,mBAAsB,cAAA,EACrB,KAAA,EAAO,CAAA,EACP,QAAA,EAAU,aAAA,CAAc,cAAA,CAAe,CAAA;EF7GF;;;;;;EE2HtC,GAAA,mBAAuB,cAAA,EACtB,KAAA,EAAO,CAAA,EACP,QAAA,EAAU,aAAA,CAAc,cAAA,CAAe,CAAA;;UAMhC,IAAA;EDnLI;;;;EAAA,QCgMJ,iBAAA;EAAA,QAyBA,WAAA;EAAA,QAMA,WAAA;;UASA,aAAA;EDnN4B;EAAA,QCwN5B,cAAA;EDxNqD;EAAA,QCmOrD,mBAAA;EDnO4D;EAAA,QC0O5D,oBAAA;AAAA"}
|