@mlola-ui/scene 1.0.8 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/primitives.d.ts +19 -0
- package/dist/primitives.js +71 -0
- package/dist/spatial-toggle.d.ts +14 -0
- package/dist/spatial-toggle.js +24 -0
- package/dist/stage.d.ts +12 -0
- package/dist/stage.js +90 -0
- package/dist/use-reduced-motion.d.ts +1 -0
- package/dist/use-reduced-motion.js +12 -0
- package/package.json +21 -7
- package/src/index.ts +0 -3
- package/src/primitives.tsx +0 -156
- package/src/spatial-toggle.tsx +0 -78
- package/src/stage.tsx +0 -154
- package/src/use-reduced-motion.ts +0 -18
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface MeshGradientProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
3
|
+
animated?: boolean;
|
|
4
|
+
blur?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare const MeshGradient: React.ForwardRefExoticComponent<MeshGradientProps & React.RefAttributes<HTMLDivElement>>;
|
|
7
|
+
export interface CardStack3DItem {
|
|
8
|
+
title: string;
|
|
9
|
+
subtitle: string;
|
|
10
|
+
tag: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CardStack3DProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
13
|
+
cards: CardStack3DItem[];
|
|
14
|
+
}
|
|
15
|
+
export declare function CardStack3D({ cards, className, onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: CardStack3DProps): React.JSX.Element;
|
|
16
|
+
export interface DeviceFrame3DProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
17
|
+
tilt?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function DeviceFrame3D({ children, className, tilt, style, ...props }: DeviceFrame3DProps): React.JSX.Element;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useReducedMotion } from "./use-reduced-motion.js";
|
|
5
|
+
export const MeshGradient = React.forwardRef(({ animated = true, blur = 50, style, ...props }, ref) => {
|
|
6
|
+
const reducedMotion = useReducedMotion();
|
|
7
|
+
const shouldAnimate = animated && !reducedMotion;
|
|
8
|
+
return (_jsx("div", { ...props, ref: ref, "aria-hidden": "true", style: {
|
|
9
|
+
position: "absolute",
|
|
10
|
+
inset: 0,
|
|
11
|
+
pointerEvents: "none",
|
|
12
|
+
overflow: "hidden",
|
|
13
|
+
backgroundImage: `
|
|
14
|
+
radial-gradient(circle at 18% 25%, color-mix(in srgb, var(--ml-primary) 22%, transparent), transparent 55%),
|
|
15
|
+
radial-gradient(circle at 82% 70%, color-mix(in srgb, var(--ml-primary, var(--primary)) 14%, transparent), transparent 60%),
|
|
16
|
+
radial-gradient(circle at 50% 90%, color-mix(in srgb, var(--ml-accent, var(--accent)) 30%, transparent), transparent 70%)
|
|
17
|
+
`,
|
|
18
|
+
filter: `blur(${Math.max(0, Math.min(160, Number.isFinite(blur) ? blur : 50))}px)`,
|
|
19
|
+
animation: shouldAnimate ? "float-3d 14s ease-in-out infinite" : undefined,
|
|
20
|
+
...style,
|
|
21
|
+
} }));
|
|
22
|
+
});
|
|
23
|
+
MeshGradient.displayName = "MeshGradient";
|
|
24
|
+
function joinClasses(...values) {
|
|
25
|
+
return values.filter(Boolean).join(" ");
|
|
26
|
+
}
|
|
27
|
+
export function CardStack3D({ cards, className = "", onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }) {
|
|
28
|
+
const [active, setActive] = React.useState(false);
|
|
29
|
+
const reducedMotion = useReducedMotion();
|
|
30
|
+
return (_jsx("div", { ...props, tabIndex: props.tabIndex ?? 0, "aria-label": props["aria-label"] ?? "Card stack", onPointerEnter: (event) => {
|
|
31
|
+
onPointerEnter?.(event);
|
|
32
|
+
if (event.pointerType !== "touch")
|
|
33
|
+
setActive(true);
|
|
34
|
+
}, onPointerLeave: (event) => {
|
|
35
|
+
onPointerLeave?.(event);
|
|
36
|
+
setActive(false);
|
|
37
|
+
}, onFocus: (event) => {
|
|
38
|
+
onFocus?.(event);
|
|
39
|
+
setActive(true);
|
|
40
|
+
}, onBlur: (event) => {
|
|
41
|
+
onBlur?.(event);
|
|
42
|
+
if (!event.currentTarget.contains(event.relatedTarget))
|
|
43
|
+
setActive(false);
|
|
44
|
+
}, className: joinClasses("ml-scene-card-stack", className), children: cards.slice(0, 3).map((card, index) => {
|
|
45
|
+
const layer = 2 - index;
|
|
46
|
+
const fanDown = active ? layer * 18 : layer * 6;
|
|
47
|
+
const fanScale = active ? 1 - layer * 0.03 : 1 - layer * 0.04;
|
|
48
|
+
const rotateZ = active ? (index - 1) * 2.5 : 0;
|
|
49
|
+
return (_jsxs("div", { className: "ml-scene-card-stack-item", style: {
|
|
50
|
+
transform: `translate3d(0, ${fanDown}px, ${index * 12}px) rotateZ(${rotateZ}deg) scale(${fanScale})`,
|
|
51
|
+
transformOrigin: "top center",
|
|
52
|
+
zIndex: index + 10,
|
|
53
|
+
boxShadow: active
|
|
54
|
+
? "0 18px 38px -10px color-mix(in srgb, var(--foreground) 14%, transparent), 0 0 0 1px color-mix(in srgb, var(--border) 90%, transparent)"
|
|
55
|
+
: "0 8px 20px -6px color-mix(in srgb, var(--foreground) 8%, transparent), 0 0 0 1px color-mix(in srgb, var(--border) 70%, transparent)",
|
|
56
|
+
transition: reducedMotion
|
|
57
|
+
? "none"
|
|
58
|
+
: "transform 300ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1)",
|
|
59
|
+
}, children: [_jsxs("div", { className: "ml-scene-card-stack-header", children: [_jsx("span", { className: "ml-scene-card-stack-tag", children: card.tag }), _jsxs("span", { className: "ml-scene-card-stack-index", children: ["0", index + 1] })] }), _jsxs("div", { children: [_jsx("p", { className: "ml-scene-card-stack-title", children: card.title }), _jsx("p", { className: "ml-scene-card-stack-subtitle", children: card.subtitle })] })] }, `${card.title}-${index}`));
|
|
60
|
+
}) }));
|
|
61
|
+
}
|
|
62
|
+
export function DeviceFrame3D({ children, className = "", tilt = true, style, ...props }) {
|
|
63
|
+
const reducedMotion = useReducedMotion();
|
|
64
|
+
return (_jsx("div", { ...props, className: joinClasses("ml-scene-device-frame", className), style: {
|
|
65
|
+
background: "linear-gradient(145deg, oklch(35% 0.02 260), oklch(18% 0.015 260))",
|
|
66
|
+
boxShadow: "0 30px 70px -15px oklch(0% 0 0 / 0.5), inset 0 1px 2px oklch(100% 0 0 / 0.2)",
|
|
67
|
+
transform: tilt && !reducedMotion ? "rotateX(1deg) rotateY(-2deg)" : undefined,
|
|
68
|
+
transformStyle: "preserve-3d",
|
|
69
|
+
...style,
|
|
70
|
+
}, children: _jsxs("div", { className: "ml-scene-device-screen", children: [_jsx("div", { className: "ml-scene-device-notch", children: _jsx("span", { className: "ml-scene-device-notch-dot", "aria-hidden": "true" }) }), _jsx("div", { className: "ml-scene-device-content", children: children })] }) }));
|
|
71
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface SpatialToggleProps {
|
|
3
|
+
checked?: boolean;
|
|
4
|
+
onChange?: (checked: boolean) => void;
|
|
5
|
+
label?: React.ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
id?: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const SpatialToggle: React.ForwardRefExoticComponent<SpatialToggleProps & React.RefAttributes<HTMLButtonElement>>;
|
|
12
|
+
/** @deprecated Use SpatialToggle. Kept as a source-compatible scene export. */
|
|
13
|
+
export declare const Toggle3D: React.ForwardRefExoticComponent<SpatialToggleProps & React.RefAttributes<HTMLButtonElement>>;
|
|
14
|
+
export type Toggle3DProps = SpatialToggleProps;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useReducedMotion } from "./use-reduced-motion.js";
|
|
5
|
+
function joinClasses(...values) {
|
|
6
|
+
return values.filter(Boolean).join(" ");
|
|
7
|
+
}
|
|
8
|
+
export const SpatialToggle = React.forwardRef(({ checked = false, onChange, label, className = "", disabled = false, id, name, }, ref) => {
|
|
9
|
+
const reducedMotion = useReducedMotion();
|
|
10
|
+
return (_jsxs("span", { className: joinClasses("ml-scene-spatial-toggle", className), children: [_jsx("button", { ref: ref, id: id, name: name, type: "button", role: "switch", "aria-checked": checked, "aria-label": typeof label === "string" ? label : undefined, disabled: disabled, onClick: () => onChange?.(!checked), className: "ml-scene-spatial-switch", style: {
|
|
11
|
+
background: checked
|
|
12
|
+
? "var(--ml-primary)"
|
|
13
|
+
: "var(--ml-background-subtle)",
|
|
14
|
+
transition: reducedMotion ? "none" : "background 240ms ease",
|
|
15
|
+
}, children: _jsx("span", { "aria-hidden": "true", className: "ml-scene-spatial-thumb", style: {
|
|
16
|
+
transform: checked ? "translate3d(20px, 0, 0)" : "translate3d(0, 0, 0)",
|
|
17
|
+
transition: reducedMotion
|
|
18
|
+
? "none"
|
|
19
|
+
: "transform 300ms cubic-bezier(0.16, 1, 0.3, 1)",
|
|
20
|
+
} }) }), label ? (_jsx("span", { "aria-hidden": "true", className: "ml-scene-spatial-label", children: label })) : null] }));
|
|
21
|
+
});
|
|
22
|
+
SpatialToggle.displayName = "SpatialToggle";
|
|
23
|
+
/** @deprecated Use SpatialToggle. Kept as a source-compatible scene export. */
|
|
24
|
+
export const Toggle3D = SpatialToggle;
|
package/dist/stage.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface Stage3DProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
3
|
+
perspective?: number;
|
|
4
|
+
interactive?: boolean;
|
|
5
|
+
maxRotationX?: number;
|
|
6
|
+
maxRotationY?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare const Stage3D: React.ForwardRefExoticComponent<Stage3DProps & React.RefAttributes<HTMLDivElement>>;
|
|
9
|
+
export interface FloatingLayerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
10
|
+
depth?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare const FloatingLayer: React.ForwardRefExoticComponent<FloatingLayerProps & React.RefAttributes<HTMLDivElement>>;
|
package/dist/stage.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useReducedMotion } from "./use-reduced-motion.js";
|
|
5
|
+
function finite(value, fallback) {
|
|
6
|
+
return Number.isFinite(value) ? value : fallback;
|
|
7
|
+
}
|
|
8
|
+
export const Stage3D = React.forwardRef(({ perspective = 1200, interactive = true, maxRotationX = 12, maxRotationY = 14, children, style, onPointerMove, onPointerLeave, onPointerCancel, ...props }, forwardedRef) => {
|
|
9
|
+
const reducedMotion = useReducedMotion();
|
|
10
|
+
const stageRef = React.useRef(null);
|
|
11
|
+
const contentRef = React.useRef(null);
|
|
12
|
+
const frameRef = React.useRef(null);
|
|
13
|
+
const setRef = React.useCallback((node) => {
|
|
14
|
+
stageRef.current = node;
|
|
15
|
+
if (typeof forwardedRef === "function")
|
|
16
|
+
forwardedRef(node);
|
|
17
|
+
else if (forwardedRef)
|
|
18
|
+
forwardedRef.current = node;
|
|
19
|
+
}, [forwardedRef]);
|
|
20
|
+
const renderRotation = React.useCallback((x, y, active) => {
|
|
21
|
+
const content = contentRef.current;
|
|
22
|
+
if (!content)
|
|
23
|
+
return;
|
|
24
|
+
if (frameRef.current !== null)
|
|
25
|
+
cancelAnimationFrame(frameRef.current);
|
|
26
|
+
if (typeof requestAnimationFrame === "undefined")
|
|
27
|
+
return;
|
|
28
|
+
frameRef.current = requestAnimationFrame(() => {
|
|
29
|
+
content.style.transform = `rotateX(${x.toFixed(2)}deg) rotateY(${y.toFixed(2)}deg)`;
|
|
30
|
+
content.style.transition = active
|
|
31
|
+
? "transform 80ms linear"
|
|
32
|
+
: "transform 360ms cubic-bezier(0.16, 1, 0.3, 1)";
|
|
33
|
+
frameRef.current = null;
|
|
34
|
+
});
|
|
35
|
+
}, []);
|
|
36
|
+
React.useEffect(() => () => {
|
|
37
|
+
if (frameRef.current !== null)
|
|
38
|
+
cancelAnimationFrame(frameRef.current);
|
|
39
|
+
}, []);
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
if (reducedMotion || !interactive)
|
|
42
|
+
renderRotation(0, 0, false);
|
|
43
|
+
}, [interactive, reducedMotion, renderRotation]);
|
|
44
|
+
const reset = () => renderRotation(0, 0, false);
|
|
45
|
+
return (_jsx("div", { ...props, ref: setRef, onPointerMove: (event) => {
|
|
46
|
+
onPointerMove?.(event);
|
|
47
|
+
const stage = stageRef.current;
|
|
48
|
+
if (event.defaultPrevented ||
|
|
49
|
+
!interactive ||
|
|
50
|
+
reducedMotion ||
|
|
51
|
+
event.pointerType === "touch" ||
|
|
52
|
+
!stage)
|
|
53
|
+
return;
|
|
54
|
+
const rect = stage.getBoundingClientRect();
|
|
55
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
56
|
+
return;
|
|
57
|
+
const px = (event.clientX - rect.left) / rect.width - 0.5;
|
|
58
|
+
const py = (event.clientY - rect.top) / rect.height - 0.5;
|
|
59
|
+
renderRotation(-py * finite(maxRotationX, 12), px * finite(maxRotationY, 14), true);
|
|
60
|
+
}, onPointerLeave: (event) => {
|
|
61
|
+
onPointerLeave?.(event);
|
|
62
|
+
reset();
|
|
63
|
+
}, onPointerCancel: (event) => {
|
|
64
|
+
onPointerCancel?.(event);
|
|
65
|
+
reset();
|
|
66
|
+
}, style: {
|
|
67
|
+
perspective: `${Math.max(1, finite(perspective, 1200))}px`,
|
|
68
|
+
transformStyle: "preserve-3d",
|
|
69
|
+
...style,
|
|
70
|
+
}, children: _jsx("div", { ref: contentRef, style: {
|
|
71
|
+
width: "100%",
|
|
72
|
+
height: "100%",
|
|
73
|
+
transform: "rotateX(0deg) rotateY(0deg)",
|
|
74
|
+
transformStyle: "preserve-3d",
|
|
75
|
+
transition: reducedMotion
|
|
76
|
+
? "none"
|
|
77
|
+
: "transform 360ms cubic-bezier(0.16, 1, 0.3, 1)",
|
|
78
|
+
willChange: interactive && !reducedMotion ? "transform" : undefined,
|
|
79
|
+
}, children: children }) }));
|
|
80
|
+
});
|
|
81
|
+
Stage3D.displayName = "Stage3D";
|
|
82
|
+
export const FloatingLayer = React.forwardRef(({ depth = 30, children, style, ...props }, ref) => {
|
|
83
|
+
const safeDepth = Math.max(-1000, Math.min(1000, finite(depth, 30)));
|
|
84
|
+
return (_jsx("div", { ...props, ref: ref, style: {
|
|
85
|
+
transform: `translateZ(${safeDepth}px)`,
|
|
86
|
+
transformStyle: "preserve-3d",
|
|
87
|
+
...style,
|
|
88
|
+
}, children: children }));
|
|
89
|
+
});
|
|
90
|
+
FloatingLayer.displayName = "FloatingLayer";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function useReducedMotion(): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
const query = "(prefers-reduced-motion: reduce)";
|
|
4
|
+
export function useReducedMotion() {
|
|
5
|
+
return React.useSyncExternalStore((listener) => {
|
|
6
|
+
if (typeof window === "undefined")
|
|
7
|
+
return () => { };
|
|
8
|
+
const media = window.matchMedia(query);
|
|
9
|
+
media.addEventListener("change", listener);
|
|
10
|
+
return () => media.removeEventListener("change", listener);
|
|
11
|
+
}, () => typeof window !== "undefined" && window.matchMedia(query).matches, () => false);
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mlola-ui/scene",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Optional lightweight spatial presentation for Mlola UI",
|
|
5
|
-
"main": "./
|
|
6
|
-
"types": "./
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
7
|
"sideEffects": [
|
|
8
8
|
"./src/scene.css"
|
|
9
9
|
],
|
|
10
10
|
"files": [
|
|
11
|
-
"
|
|
11
|
+
"dist",
|
|
12
|
+
"src/scene.css"
|
|
12
13
|
],
|
|
13
14
|
"exports": {
|
|
14
|
-
".":
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./stage": {
|
|
20
|
+
"types": "./dist/stage.d.ts",
|
|
21
|
+
"default": "./dist/stage.js"
|
|
22
|
+
},
|
|
23
|
+
"./primitives": {
|
|
24
|
+
"types": "./dist/primitives.d.ts",
|
|
25
|
+
"default": "./dist/primitives.js"
|
|
26
|
+
},
|
|
17
27
|
"./scene.css": "./src/scene.css"
|
|
18
28
|
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.json",
|
|
31
|
+
"prepack": "npm run build"
|
|
32
|
+
},
|
|
19
33
|
"peerDependencies": {
|
|
20
34
|
"react": ">=18",
|
|
21
35
|
"react-dom": ">=18"
|
package/src/index.ts
DELETED
package/src/primitives.tsx
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import * as React from "react";
|
|
4
|
-
import { useReducedMotion } from "./use-reduced-motion";
|
|
5
|
-
|
|
6
|
-
export interface MeshGradientProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
7
|
-
animated?: boolean;
|
|
8
|
-
blur?: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export const MeshGradient = React.forwardRef<HTMLDivElement, MeshGradientProps>(
|
|
12
|
-
({ animated = true, blur = 50, style, ...props }, ref) => {
|
|
13
|
-
const reducedMotion = useReducedMotion();
|
|
14
|
-
const shouldAnimate = animated && !reducedMotion;
|
|
15
|
-
return (
|
|
16
|
-
<div
|
|
17
|
-
{...props}
|
|
18
|
-
ref={ref}
|
|
19
|
-
aria-hidden="true"
|
|
20
|
-
style={{
|
|
21
|
-
position: "absolute",
|
|
22
|
-
inset: 0,
|
|
23
|
-
pointerEvents: "none",
|
|
24
|
-
overflow: "hidden",
|
|
25
|
-
backgroundImage: `
|
|
26
|
-
radial-gradient(circle at 18% 25%, color-mix(in srgb, var(--ml-primary) 22%, transparent), transparent 55%),
|
|
27
|
-
radial-gradient(circle at 82% 70%, color-mix(in srgb, var(--ml-primary, var(--primary)) 14%, transparent), transparent 60%),
|
|
28
|
-
radial-gradient(circle at 50% 90%, color-mix(in srgb, var(--ml-accent, var(--accent)) 30%, transparent), transparent 70%)
|
|
29
|
-
`,
|
|
30
|
-
filter: `blur(${Math.max(0, Math.min(160, Number.isFinite(blur) ? blur : 50))}px)`,
|
|
31
|
-
animation: shouldAnimate ? "float-3d 14s ease-in-out infinite" : undefined,
|
|
32
|
-
...style,
|
|
33
|
-
}}
|
|
34
|
-
/>
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
);
|
|
38
|
-
MeshGradient.displayName = "MeshGradient";
|
|
39
|
-
|
|
40
|
-
export interface CardStack3DItem {
|
|
41
|
-
title: string;
|
|
42
|
-
subtitle: string;
|
|
43
|
-
tag: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface CardStack3DProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
47
|
-
cards: CardStack3DItem[];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function joinClasses(...values: Array<string | false | null | undefined>) {
|
|
51
|
-
return values.filter(Boolean).join(" ");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function CardStack3D({
|
|
55
|
-
cards,
|
|
56
|
-
className = "",
|
|
57
|
-
onPointerEnter,
|
|
58
|
-
onPointerLeave,
|
|
59
|
-
onFocus,
|
|
60
|
-
onBlur,
|
|
61
|
-
...props
|
|
62
|
-
}: CardStack3DProps) {
|
|
63
|
-
const [active, setActive] = React.useState(false);
|
|
64
|
-
const reducedMotion = useReducedMotion();
|
|
65
|
-
return (
|
|
66
|
-
<div
|
|
67
|
-
{...props}
|
|
68
|
-
tabIndex={props.tabIndex ?? 0}
|
|
69
|
-
aria-label={props["aria-label"] ?? "Card stack"}
|
|
70
|
-
onPointerEnter={(event) => {
|
|
71
|
-
onPointerEnter?.(event);
|
|
72
|
-
if (event.pointerType !== "touch") setActive(true);
|
|
73
|
-
}}
|
|
74
|
-
onPointerLeave={(event) => {
|
|
75
|
-
onPointerLeave?.(event);
|
|
76
|
-
setActive(false);
|
|
77
|
-
}}
|
|
78
|
-
onFocus={(event) => {
|
|
79
|
-
onFocus?.(event);
|
|
80
|
-
setActive(true);
|
|
81
|
-
}}
|
|
82
|
-
onBlur={(event) => {
|
|
83
|
-
onBlur?.(event);
|
|
84
|
-
if (!event.currentTarget.contains(event.relatedTarget)) setActive(false);
|
|
85
|
-
}}
|
|
86
|
-
className={joinClasses("ml-scene-card-stack", className)}
|
|
87
|
-
>
|
|
88
|
-
{cards.slice(0, 3).map((card, index) => {
|
|
89
|
-
const layer = 2 - index;
|
|
90
|
-
const fanDown = active ? layer * 18 : layer * 6;
|
|
91
|
-
const fanScale = active ? 1 - layer * 0.03 : 1 - layer * 0.04;
|
|
92
|
-
const rotateZ = active ? (index - 1) * 2.5 : 0;
|
|
93
|
-
return (
|
|
94
|
-
<div
|
|
95
|
-
key={`${card.title}-${index}`}
|
|
96
|
-
className="ml-scene-card-stack-item"
|
|
97
|
-
style={{
|
|
98
|
-
transform: `translate3d(0, ${fanDown}px, ${index * 12}px) rotateZ(${rotateZ}deg) scale(${fanScale})`,
|
|
99
|
-
transformOrigin: "top center",
|
|
100
|
-
zIndex: index + 10,
|
|
101
|
-
boxShadow: active
|
|
102
|
-
? "0 18px 38px -10px color-mix(in srgb, var(--foreground) 14%, transparent), 0 0 0 1px color-mix(in srgb, var(--border) 90%, transparent)"
|
|
103
|
-
: "0 8px 20px -6px color-mix(in srgb, var(--foreground) 8%, transparent), 0 0 0 1px color-mix(in srgb, var(--border) 70%, transparent)",
|
|
104
|
-
transition: reducedMotion
|
|
105
|
-
? "none"
|
|
106
|
-
: "transform 300ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1)",
|
|
107
|
-
}}
|
|
108
|
-
>
|
|
109
|
-
<div className="ml-scene-card-stack-header">
|
|
110
|
-
<span className="ml-scene-card-stack-tag">{card.tag}</span>
|
|
111
|
-
<span className="ml-scene-card-stack-index">0{index + 1}</span>
|
|
112
|
-
</div>
|
|
113
|
-
<div>
|
|
114
|
-
<p className="ml-scene-card-stack-title">{card.title}</p>
|
|
115
|
-
<p className="ml-scene-card-stack-subtitle">{card.subtitle}</p>
|
|
116
|
-
</div>
|
|
117
|
-
</div>
|
|
118
|
-
);
|
|
119
|
-
})}
|
|
120
|
-
</div>
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export interface DeviceFrame3DProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
125
|
-
tilt?: boolean;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function DeviceFrame3D({
|
|
129
|
-
children,
|
|
130
|
-
className = "",
|
|
131
|
-
tilt = true,
|
|
132
|
-
style,
|
|
133
|
-
...props
|
|
134
|
-
}: DeviceFrame3DProps) {
|
|
135
|
-
const reducedMotion = useReducedMotion();
|
|
136
|
-
return (
|
|
137
|
-
<div
|
|
138
|
-
{...props}
|
|
139
|
-
className={joinClasses("ml-scene-device-frame", className)}
|
|
140
|
-
style={{
|
|
141
|
-
background: "linear-gradient(145deg, oklch(35% 0.02 260), oklch(18% 0.015 260))",
|
|
142
|
-
boxShadow: "0 30px 70px -15px oklch(0% 0 0 / 0.5), inset 0 1px 2px oklch(100% 0 0 / 0.2)",
|
|
143
|
-
transform: tilt && !reducedMotion ? "rotateX(1deg) rotateY(-2deg)" : undefined,
|
|
144
|
-
transformStyle: "preserve-3d",
|
|
145
|
-
...style,
|
|
146
|
-
}}
|
|
147
|
-
>
|
|
148
|
-
<div className="ml-scene-device-screen">
|
|
149
|
-
<div className="ml-scene-device-notch">
|
|
150
|
-
<span className="ml-scene-device-notch-dot" aria-hidden="true" />
|
|
151
|
-
</div>
|
|
152
|
-
<div className="ml-scene-device-content">{children}</div>
|
|
153
|
-
</div>
|
|
154
|
-
</div>
|
|
155
|
-
);
|
|
156
|
-
}
|
package/src/spatial-toggle.tsx
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import * as React from "react";
|
|
4
|
-
import { useReducedMotion } from "./use-reduced-motion";
|
|
5
|
-
|
|
6
|
-
export interface SpatialToggleProps {
|
|
7
|
-
checked?: boolean;
|
|
8
|
-
onChange?: (checked: boolean) => void;
|
|
9
|
-
label?: React.ReactNode;
|
|
10
|
-
className?: string;
|
|
11
|
-
disabled?: boolean;
|
|
12
|
-
id?: string;
|
|
13
|
-
name?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function joinClasses(...values: Array<string | false | null | undefined>) {
|
|
17
|
-
return values.filter(Boolean).join(" ");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const SpatialToggle = React.forwardRef<HTMLButtonElement, SpatialToggleProps>(
|
|
21
|
-
(
|
|
22
|
-
{
|
|
23
|
-
checked = false,
|
|
24
|
-
onChange,
|
|
25
|
-
label,
|
|
26
|
-
className = "",
|
|
27
|
-
disabled = false,
|
|
28
|
-
id,
|
|
29
|
-
name,
|
|
30
|
-
},
|
|
31
|
-
ref
|
|
32
|
-
) => {
|
|
33
|
-
const reducedMotion = useReducedMotion();
|
|
34
|
-
return (
|
|
35
|
-
<span className={joinClasses("ml-scene-spatial-toggle", className)}>
|
|
36
|
-
<button
|
|
37
|
-
ref={ref}
|
|
38
|
-
id={id}
|
|
39
|
-
name={name}
|
|
40
|
-
type="button"
|
|
41
|
-
role="switch"
|
|
42
|
-
aria-checked={checked}
|
|
43
|
-
aria-label={typeof label === "string" ? label : undefined}
|
|
44
|
-
disabled={disabled}
|
|
45
|
-
onClick={() => onChange?.(!checked)}
|
|
46
|
-
className="ml-scene-spatial-switch"
|
|
47
|
-
style={{
|
|
48
|
-
background: checked
|
|
49
|
-
? "var(--ml-primary)"
|
|
50
|
-
: "var(--ml-background-subtle)",
|
|
51
|
-
transition: reducedMotion ? "none" : "background 240ms ease",
|
|
52
|
-
}}
|
|
53
|
-
>
|
|
54
|
-
<span
|
|
55
|
-
aria-hidden="true"
|
|
56
|
-
className="ml-scene-spatial-thumb"
|
|
57
|
-
style={{
|
|
58
|
-
transform: checked ? "translate3d(20px, 0, 0)" : "translate3d(0, 0, 0)",
|
|
59
|
-
transition: reducedMotion
|
|
60
|
-
? "none"
|
|
61
|
-
: "transform 300ms cubic-bezier(0.16, 1, 0.3, 1)",
|
|
62
|
-
}}
|
|
63
|
-
/>
|
|
64
|
-
</button>
|
|
65
|
-
{label ? (
|
|
66
|
-
<span aria-hidden="true" className="ml-scene-spatial-label">
|
|
67
|
-
{label}
|
|
68
|
-
</span>
|
|
69
|
-
) : null}
|
|
70
|
-
</span>
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
);
|
|
74
|
-
SpatialToggle.displayName = "SpatialToggle";
|
|
75
|
-
|
|
76
|
-
/** @deprecated Use SpatialToggle. Kept as a source-compatible scene export. */
|
|
77
|
-
export const Toggle3D = SpatialToggle;
|
|
78
|
-
export type Toggle3DProps = SpatialToggleProps;
|
package/src/stage.tsx
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import * as React from "react";
|
|
4
|
-
import { useReducedMotion } from "./use-reduced-motion";
|
|
5
|
-
|
|
6
|
-
function finite(value: number, fallback: number): number {
|
|
7
|
-
return Number.isFinite(value) ? value : fallback;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface Stage3DProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
11
|
-
perspective?: number;
|
|
12
|
-
interactive?: boolean;
|
|
13
|
-
maxRotationX?: number;
|
|
14
|
-
maxRotationY?: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export const Stage3D = React.forwardRef<HTMLDivElement, Stage3DProps>(
|
|
18
|
-
(
|
|
19
|
-
{
|
|
20
|
-
perspective = 1200,
|
|
21
|
-
interactive = true,
|
|
22
|
-
maxRotationX = 12,
|
|
23
|
-
maxRotationY = 14,
|
|
24
|
-
children,
|
|
25
|
-
style,
|
|
26
|
-
onPointerMove,
|
|
27
|
-
onPointerLeave,
|
|
28
|
-
onPointerCancel,
|
|
29
|
-
...props
|
|
30
|
-
},
|
|
31
|
-
forwardedRef
|
|
32
|
-
) => {
|
|
33
|
-
const reducedMotion = useReducedMotion();
|
|
34
|
-
const stageRef = React.useRef<HTMLDivElement>(null);
|
|
35
|
-
const contentRef = React.useRef<HTMLDivElement>(null);
|
|
36
|
-
const frameRef = React.useRef<number | null>(null);
|
|
37
|
-
const setRef = React.useCallback(
|
|
38
|
-
(node: HTMLDivElement | null) => {
|
|
39
|
-
stageRef.current = node;
|
|
40
|
-
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
41
|
-
else if (forwardedRef) forwardedRef.current = node;
|
|
42
|
-
},
|
|
43
|
-
[forwardedRef]
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
const renderRotation = React.useCallback((x: number, y: number, active: boolean) => {
|
|
47
|
-
const content = contentRef.current;
|
|
48
|
-
if (!content) return;
|
|
49
|
-
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
|
50
|
-
if (typeof requestAnimationFrame === "undefined") return;
|
|
51
|
-
frameRef.current = requestAnimationFrame(() => {
|
|
52
|
-
content.style.transform = `rotateX(${x.toFixed(2)}deg) rotateY(${y.toFixed(2)}deg)`;
|
|
53
|
-
content.style.transition = active
|
|
54
|
-
? "transform 80ms linear"
|
|
55
|
-
: "transform 360ms cubic-bezier(0.16, 1, 0.3, 1)";
|
|
56
|
-
frameRef.current = null;
|
|
57
|
-
});
|
|
58
|
-
}, []);
|
|
59
|
-
|
|
60
|
-
React.useEffect(
|
|
61
|
-
() => () => {
|
|
62
|
-
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
|
63
|
-
},
|
|
64
|
-
[]
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
React.useEffect(() => {
|
|
68
|
-
if (reducedMotion || !interactive) renderRotation(0, 0, false);
|
|
69
|
-
}, [interactive, reducedMotion, renderRotation]);
|
|
70
|
-
|
|
71
|
-
const reset = () => renderRotation(0, 0, false);
|
|
72
|
-
|
|
73
|
-
return (
|
|
74
|
-
<div
|
|
75
|
-
{...props}
|
|
76
|
-
ref={setRef}
|
|
77
|
-
onPointerMove={(event) => {
|
|
78
|
-
onPointerMove?.(event);
|
|
79
|
-
const stage = stageRef.current;
|
|
80
|
-
if (
|
|
81
|
-
event.defaultPrevented ||
|
|
82
|
-
!interactive ||
|
|
83
|
-
reducedMotion ||
|
|
84
|
-
event.pointerType === "touch" ||
|
|
85
|
-
!stage
|
|
86
|
-
) return;
|
|
87
|
-
const rect = stage.getBoundingClientRect();
|
|
88
|
-
if (rect.width <= 0 || rect.height <= 0) return;
|
|
89
|
-
const px = (event.clientX - rect.left) / rect.width - 0.5;
|
|
90
|
-
const py = (event.clientY - rect.top) / rect.height - 0.5;
|
|
91
|
-
renderRotation(
|
|
92
|
-
-py * finite(maxRotationX, 12),
|
|
93
|
-
px * finite(maxRotationY, 14),
|
|
94
|
-
true
|
|
95
|
-
);
|
|
96
|
-
}}
|
|
97
|
-
onPointerLeave={(event) => {
|
|
98
|
-
onPointerLeave?.(event);
|
|
99
|
-
reset();
|
|
100
|
-
}}
|
|
101
|
-
onPointerCancel={(event) => {
|
|
102
|
-
onPointerCancel?.(event);
|
|
103
|
-
reset();
|
|
104
|
-
}}
|
|
105
|
-
style={{
|
|
106
|
-
perspective: `${Math.max(1, finite(perspective, 1200))}px`,
|
|
107
|
-
transformStyle: "preserve-3d",
|
|
108
|
-
...style,
|
|
109
|
-
}}
|
|
110
|
-
>
|
|
111
|
-
<div
|
|
112
|
-
ref={contentRef}
|
|
113
|
-
style={{
|
|
114
|
-
width: "100%",
|
|
115
|
-
height: "100%",
|
|
116
|
-
transform: "rotateX(0deg) rotateY(0deg)",
|
|
117
|
-
transformStyle: "preserve-3d",
|
|
118
|
-
transition: reducedMotion
|
|
119
|
-
? "none"
|
|
120
|
-
: "transform 360ms cubic-bezier(0.16, 1, 0.3, 1)",
|
|
121
|
-
willChange: interactive && !reducedMotion ? "transform" : undefined,
|
|
122
|
-
}}
|
|
123
|
-
>
|
|
124
|
-
{children}
|
|
125
|
-
</div>
|
|
126
|
-
</div>
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
);
|
|
130
|
-
Stage3D.displayName = "Stage3D";
|
|
131
|
-
|
|
132
|
-
export interface FloatingLayerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
133
|
-
depth?: number;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export const FloatingLayer = React.forwardRef<HTMLDivElement, FloatingLayerProps>(
|
|
137
|
-
({ depth = 30, children, style, ...props }, ref) => {
|
|
138
|
-
const safeDepth = Math.max(-1000, Math.min(1000, finite(depth, 30)));
|
|
139
|
-
return (
|
|
140
|
-
<div
|
|
141
|
-
{...props}
|
|
142
|
-
ref={ref}
|
|
143
|
-
style={{
|
|
144
|
-
transform: `translateZ(${safeDepth}px)`,
|
|
145
|
-
transformStyle: "preserve-3d",
|
|
146
|
-
...style,
|
|
147
|
-
}}
|
|
148
|
-
>
|
|
149
|
-
{children}
|
|
150
|
-
</div>
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
);
|
|
154
|
-
FloatingLayer.displayName = "FloatingLayer";
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import * as React from "react";
|
|
4
|
-
|
|
5
|
-
const query = "(prefers-reduced-motion: reduce)";
|
|
6
|
-
|
|
7
|
-
export function useReducedMotion(): boolean {
|
|
8
|
-
return React.useSyncExternalStore(
|
|
9
|
-
(listener) => {
|
|
10
|
-
if (typeof window === "undefined") return () => {};
|
|
11
|
-
const media = window.matchMedia(query);
|
|
12
|
-
media.addEventListener("change", listener);
|
|
13
|
-
return () => media.removeEventListener("change", listener);
|
|
14
|
-
},
|
|
15
|
-
() => typeof window !== "undefined" && window.matchMedia(query).matches,
|
|
16
|
-
() => false
|
|
17
|
-
);
|
|
18
|
-
}
|