@mlola-ui/scene 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 +32 -0
- package/package.json +46 -0
- package/src/index.ts +3 -0
- package/src/primitives.tsx +156 -0
- package/src/scene.css +140 -0
- package/src/spatial-toggle.tsx +78 -0
- package/src/stage.tsx +154 -0
- package/src/use-reduced-motion.ts +18 -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,32 @@
|
|
|
1
|
+
# @mlola-ui/scene
|
|
2
|
+
|
|
3
|
+
Optional, lightweight spatial presentation for Mlola UI: perspective stages,
|
|
4
|
+
depth layers, card stacks, and mesh gradients. WebGL is not required, and a
|
|
5
|
+
scene effect always has a static fallback for reduced motion, reduced data, and
|
|
6
|
+
assistive technology.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @mlola-ui/scene
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Use
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
import { CardStack3D, MeshGradient, Stage } from "@mlola-ui/scene";
|
|
18
|
+
|
|
19
|
+
<Stage depth={2}><CardStack3D cards={cards} /></Stage>;
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Exports
|
|
23
|
+
|
|
24
|
+
| Path | What it is |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `@mlola-ui/scene` | `Stage`, `CardStack3D`, `MeshGradient`, `SpatialToggle` |
|
|
27
|
+
| `@mlola-ui/scene/scene.css` | the scene layer |
|
|
28
|
+
|
|
29
|
+
Requires React and ReactDOM 18 or newer as peers. Scene effects are
|
|
30
|
+
non-semantic and never the only way to reach content.
|
|
31
|
+
|
|
32
|
+
MIT licensed. Part of [Mlola UI](https://ui.mlola.com).
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mlola-ui/scene",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Optional lightweight spatial presentation for Mlola UI",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"./src/scene.css"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./stage": "./src/stage.tsx",
|
|
16
|
+
"./primitives": "./src/primitives.tsx",
|
|
17
|
+
"./scene.css": "./src/scene.css"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": ">=18",
|
|
21
|
+
"react-dom": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "Mlola",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/mlolahq/mlola-ui.git",
|
|
28
|
+
"directory": "packages/scene"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://ui.mlola.com",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/mlolahq/mlola-ui/issues"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"mlola",
|
|
36
|
+
"ui",
|
|
37
|
+
"3d",
|
|
38
|
+
"scene",
|
|
39
|
+
"perspective",
|
|
40
|
+
"css"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"type": "module"
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
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/scene.css
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
@layer mlola.scene {
|
|
2
|
+
.ml-scene-card-stack {
|
|
3
|
+
position: relative;
|
|
4
|
+
width: 100%;
|
|
5
|
+
height: 11rem;
|
|
6
|
+
cursor: pointer;
|
|
7
|
+
perspective: 1000px;
|
|
8
|
+
user-select: none;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.ml-scene-card-stack-item {
|
|
12
|
+
position: absolute;
|
|
13
|
+
inset-inline: 0;
|
|
14
|
+
top: 0;
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
justify-content: space-between;
|
|
18
|
+
height: 9rem;
|
|
19
|
+
padding: 1.25rem;
|
|
20
|
+
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
|
|
21
|
+
border-radius: var(--ml-radius-lg, 1rem);
|
|
22
|
+
background: var(--card);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.ml-scene-card-stack-header {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
justify-content: space-between;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.ml-scene-card-stack-tag {
|
|
32
|
+
display: inline-flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
padding: 0.125rem 0.625rem;
|
|
35
|
+
border-radius: 9999px;
|
|
36
|
+
background: color-mix(in srgb, var(--signal) 15%, transparent);
|
|
37
|
+
color: var(--signal);
|
|
38
|
+
font-family: var(--ml-font-mono);
|
|
39
|
+
font-size: 10px;
|
|
40
|
+
font-weight: 600;
|
|
41
|
+
letter-spacing: 0.05em;
|
|
42
|
+
text-transform: uppercase;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.ml-scene-card-stack-index {
|
|
46
|
+
color: var(--muted-foreground);
|
|
47
|
+
font-family: var(--ml-font-mono);
|
|
48
|
+
font-size: 0.75rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.ml-scene-card-stack-title {
|
|
52
|
+
color: var(--foreground);
|
|
53
|
+
font-size: 0.875rem;
|
|
54
|
+
font-weight: 700;
|
|
55
|
+
letter-spacing: -0.025em;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.ml-scene-card-stack-subtitle {
|
|
59
|
+
margin-top: 0.125rem;
|
|
60
|
+
color: var(--muted-foreground);
|
|
61
|
+
font-size: 0.75rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.ml-scene-device-frame {
|
|
65
|
+
position: relative;
|
|
66
|
+
margin-inline: auto;
|
|
67
|
+
padding: 0.75rem;
|
|
68
|
+
border-radius: 38px;
|
|
69
|
+
perspective: 1000px;
|
|
70
|
+
box-shadow: 0 25px 50px -12px oklch(0% 0 0 / 0.25);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.ml-scene-device-screen {
|
|
74
|
+
position: relative;
|
|
75
|
+
overflow: hidden;
|
|
76
|
+
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
|
|
77
|
+
border-radius: 28px;
|
|
78
|
+
background: var(--background);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.ml-scene-device-notch {
|
|
82
|
+
display: flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
justify-content: center;
|
|
85
|
+
width: 7rem;
|
|
86
|
+
height: 1rem;
|
|
87
|
+
margin: 0.625rem auto;
|
|
88
|
+
border-radius: 9999px;
|
|
89
|
+
background: color-mix(in srgb, var(--foreground) 20%, transparent);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.ml-scene-device-notch-dot {
|
|
93
|
+
width: 0.5rem;
|
|
94
|
+
height: 0.5rem;
|
|
95
|
+
border-radius: 9999px;
|
|
96
|
+
background: color-mix(in srgb, var(--foreground) 40%, transparent);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.ml-scene-device-content {
|
|
100
|
+
padding: 1rem;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.ml-scene-spatial-toggle {
|
|
104
|
+
display: inline-flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
gap: 0.75rem;
|
|
107
|
+
user-select: none;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.ml-scene-spatial-switch {
|
|
111
|
+
position: relative;
|
|
112
|
+
width: 3rem;
|
|
113
|
+
height: 1.75rem;
|
|
114
|
+
padding: 0.25rem;
|
|
115
|
+
border: none;
|
|
116
|
+
border-radius: 9999px;
|
|
117
|
+
box-shadow: inset 0 2px 4px oklch(0% 0 0 / 0.25);
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.ml-scene-spatial-switch:disabled {
|
|
122
|
+
cursor: not-allowed;
|
|
123
|
+
opacity: 0.5;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.ml-scene-spatial-thumb {
|
|
127
|
+
display: block;
|
|
128
|
+
width: 1.25rem;
|
|
129
|
+
height: 1.25rem;
|
|
130
|
+
border-radius: 9999px;
|
|
131
|
+
background: oklch(1 0 0);
|
|
132
|
+
box-shadow: 0 2px 6px oklch(0% 0 0 / 0.3);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.ml-scene-spatial-label {
|
|
136
|
+
color: var(--foreground);
|
|
137
|
+
font-size: 0.75rem;
|
|
138
|
+
font-weight: 600;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
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";
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
}
|