ui-ux-consultant-cli 1.0.0-beta.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/assets/ui-ux-consultant/SKILL.md +844 -0
- package/assets/ui-ux-consultant/references/accessibility.md +175 -0
- package/assets/ui-ux-consultant/references/alt-libraries.md +90 -0
- package/assets/ui-ux-consultant/references/animations.md +448 -0
- package/assets/ui-ux-consultant/references/catalog/colors.md +91 -0
- package/assets/ui-ux-consultant/references/catalog/fonts.md +363 -0
- package/assets/ui-ux-consultant/references/catalog/products.md +340 -0
- package/assets/ui-ux-consultant/references/catalog/styles.md +165 -0
- package/assets/ui-ux-consultant/references/components.md +1116 -0
- package/assets/ui-ux-consultant/references/patterns.md +600 -0
- package/assets/ui-ux-consultant/references/performance.md +198 -0
- package/assets/ui-ux-consultant/references/stacks/astro.md +382 -0
- package/assets/ui-ux-consultant/references/stacks/flutter.md +308 -0
- package/assets/ui-ux-consultant/references/stacks/html-tailwind.md +415 -0
- package/assets/ui-ux-consultant/references/stacks/jetpack-compose.md +333 -0
- package/assets/ui-ux-consultant/references/stacks/laravel.md +521 -0
- package/assets/ui-ux-consultant/references/stacks/nextjs.md +275 -0
- package/assets/ui-ux-consultant/references/stacks/nuxt-ui.md +384 -0
- package/assets/ui-ux-consultant/references/stacks/nuxtjs.md +264 -0
- package/assets/ui-ux-consultant/references/stacks/react-native.md +346 -0
- package/assets/ui-ux-consultant/references/stacks/react.md +268 -0
- package/assets/ui-ux-consultant/references/stacks/shadcn.md +485 -0
- package/assets/ui-ux-consultant/references/stacks/svelte.md +429 -0
- package/assets/ui-ux-consultant/references/stacks/swiftui.md +336 -0
- package/assets/ui-ux-consultant/references/stacks/threejs.md +366 -0
- package/assets/ui-ux-consultant/references/stacks/vue.md +272 -0
- package/assets/ui-ux-consultant/references/theming.md +701 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +130 -0
- package/package.json +51 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# SwiftUI Reference
|
|
2
|
+
|
|
3
|
+
## When to Read
|
|
4
|
+
Read this file when building SwiftUI apps (iOS, macOS, watchOS, tvOS) — views, state, navigation, lists, forms, async data, animation, or accessibility.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Recommended Libraries
|
|
9
|
+
|
|
10
|
+
| Library | Purpose |
|
|
11
|
+
|---|---|
|
|
12
|
+
| Alamofire | HTTP networking |
|
|
13
|
+
| Kingfisher | Async image loading/caching |
|
|
14
|
+
| SwiftData (iOS 17+) | Local persistence (replaces CoreData) |
|
|
15
|
+
| The Composable Architecture (TCA) | Strict unidirectional state |
|
|
16
|
+
| Nuke | High-performance image pipeline |
|
|
17
|
+
| swift-collections | Ordered/deque collections |
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Style Recommendations
|
|
22
|
+
|
|
23
|
+
- Follow Apple HIG spacing: 8, 16, 20, 24pt increments
|
|
24
|
+
- Use SF Symbols everywhere — native, scales with Dynamic Type, supports multicolor
|
|
25
|
+
- Prefer system colors (`Color.primary`, `.secondary`, `.accentColor`) for automatic dark mode
|
|
26
|
+
- `.font(.headline)` / `.font(.body)` — not hardcoded point sizes
|
|
27
|
+
- `cornerRadius(12)` on cards; `cornerRadius(10)` on buttons — matches native iOS feel
|
|
28
|
+
- Avoid custom navigation bars when Apple's is sufficient — saves maintenance
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## State Property Wrappers
|
|
33
|
+
|
|
34
|
+
| Wrapper | Use |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `@State` | Local mutable state (owned by this view) |
|
|
37
|
+
| `@Binding` | Passed-down mutable reference |
|
|
38
|
+
| `@StateObject` | Owned ObservableObject (created once, survives body recompute) |
|
|
39
|
+
| `@ObservedObject` | Injected ObservableObject |
|
|
40
|
+
| `@EnvironmentObject` | App-wide injected object |
|
|
41
|
+
| `@Environment` | System values (colorScheme, locale, sizeClass) |
|
|
42
|
+
| `@Observable` (iOS 17+) | Modern replacement for ObservableObject |
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Top UX Patterns (with Code)
|
|
47
|
+
|
|
48
|
+
### Modern ViewModel with @Observable (iOS 17+)
|
|
49
|
+
```swift
|
|
50
|
+
@Observable class UserStore {
|
|
51
|
+
var users: [User] = []
|
|
52
|
+
var isLoading = false
|
|
53
|
+
var errorMessage: String?
|
|
54
|
+
|
|
55
|
+
func load() async {
|
|
56
|
+
isLoading = true
|
|
57
|
+
defer { isLoading = false }
|
|
58
|
+
do {
|
|
59
|
+
users = try await api.getUsers()
|
|
60
|
+
} catch {
|
|
61
|
+
errorMessage = error.localizedDescription
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
struct UserListView: View {
|
|
67
|
+
@State private var store = UserStore()
|
|
68
|
+
|
|
69
|
+
var body: some View {
|
|
70
|
+
Group {
|
|
71
|
+
if store.isLoading {
|
|
72
|
+
ProgressView("Loading users…")
|
|
73
|
+
} else if let error = store.errorMessage {
|
|
74
|
+
ContentUnavailableView(error, systemImage: "exclamationmark.triangle")
|
|
75
|
+
} else {
|
|
76
|
+
List(store.users) { user in
|
|
77
|
+
UserRow(user: user)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
.task { await store.load() }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### NavigationStack with typed destinations (iOS 16+)
|
|
87
|
+
```swift
|
|
88
|
+
@State private var path = NavigationPath()
|
|
89
|
+
|
|
90
|
+
NavigationStack(path: $path) {
|
|
91
|
+
List(items) { item in
|
|
92
|
+
NavigationLink(value: item) {
|
|
93
|
+
ItemRow(item: item)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
.navigationTitle("Items")
|
|
97
|
+
.navigationDestination(for: Item.self) { item in
|
|
98
|
+
ItemDetailView(item: item)
|
|
99
|
+
}
|
|
100
|
+
.navigationDestination(for: UserProfile.self) { profile in
|
|
101
|
+
ProfileView(profile: profile)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Async data loading with .task
|
|
107
|
+
```swift
|
|
108
|
+
struct ContentView: View {
|
|
109
|
+
@State private var posts: [Post] = []
|
|
110
|
+
|
|
111
|
+
var body: some View {
|
|
112
|
+
List(posts) { post in PostRow(post: post) }
|
|
113
|
+
.task {
|
|
114
|
+
// auto-cancels when view disappears
|
|
115
|
+
posts = (try? await api.fetchPosts()) ?? []
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// .task is always preferred over .onAppear + Task { } — cancellation is automatic
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Swipe actions on List rows
|
|
123
|
+
```swift
|
|
124
|
+
List {
|
|
125
|
+
ForEach(items) { item in
|
|
126
|
+
ItemRow(item: item)
|
|
127
|
+
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
128
|
+
Button(role: .destructive) {
|
|
129
|
+
delete(item)
|
|
130
|
+
} label: {
|
|
131
|
+
Label("Delete", systemImage: "trash")
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
.swipeActions(edge: .leading) {
|
|
135
|
+
Button {
|
|
136
|
+
archive(item)
|
|
137
|
+
} label: {
|
|
138
|
+
Label("Archive", systemImage: "archivebox")
|
|
139
|
+
}
|
|
140
|
+
.tint(.blue)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Form with validation
|
|
147
|
+
```swift
|
|
148
|
+
struct EditProfileForm: View {
|
|
149
|
+
@State private var name = ""
|
|
150
|
+
@State private var email = ""
|
|
151
|
+
@State private var password = ""
|
|
152
|
+
|
|
153
|
+
private var isValid: Bool {
|
|
154
|
+
!name.isEmpty && email.contains("@") && password.count >= 8
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
var body: some View {
|
|
158
|
+
Form {
|
|
159
|
+
Section("Account") {
|
|
160
|
+
TextField("Name", text: $name)
|
|
161
|
+
TextField("Email", text: $email)
|
|
162
|
+
.textContentType(.emailAddress)
|
|
163
|
+
.keyboardType(.emailAddress)
|
|
164
|
+
.autocorrectionDisabled()
|
|
165
|
+
SecureField("Password", text: $password)
|
|
166
|
+
.textContentType(.newPassword)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
Section {
|
|
170
|
+
Button("Save") { save() }
|
|
171
|
+
.disabled(!isValid)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
.navigationTitle("Edit Profile")
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Animation respecting reduced motion
|
|
180
|
+
```swift
|
|
181
|
+
@Environment(\.accessibilityReduceMotion) var reduceMotion
|
|
182
|
+
|
|
183
|
+
Button("Toggle") {
|
|
184
|
+
withAnimation(reduceMotion ? .none : .spring(duration: 0.3)) {
|
|
185
|
+
isExpanded.toggle()
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Conditional animation modifier
|
|
190
|
+
.animation(reduceMotion ? .none : .easeInOut, value: isExpanded)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Accessibility labels and hints
|
|
194
|
+
```swift
|
|
195
|
+
Button(action: delete) {
|
|
196
|
+
Image(systemName: "trash")
|
|
197
|
+
}
|
|
198
|
+
.accessibilityLabel("Delete item")
|
|
199
|
+
.accessibilityHint("Double tap to permanently delete this item")
|
|
200
|
+
|
|
201
|
+
// Group related elements
|
|
202
|
+
HStack {
|
|
203
|
+
Text(user.name)
|
|
204
|
+
Text(user.email).foregroundStyle(.secondary)
|
|
205
|
+
}
|
|
206
|
+
.accessibilityElement(children: .combine)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### LazyVStack inside ScrollView (for custom layouts)
|
|
210
|
+
```swift
|
|
211
|
+
ScrollView {
|
|
212
|
+
LazyVStack(spacing: 12) {
|
|
213
|
+
ForEach(items) { item in
|
|
214
|
+
ItemCard(item: item)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
.padding(.horizontal, 16)
|
|
218
|
+
}
|
|
219
|
+
// Use List for interactive rows; LazyVStack for custom card layouts
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Conditional view without branching in body
|
|
223
|
+
```swift
|
|
224
|
+
// Prefer computed properties over inline if/else trees
|
|
225
|
+
private var contentView: some View {
|
|
226
|
+
if items.isEmpty {
|
|
227
|
+
return AnyView(EmptyStateView())
|
|
228
|
+
}
|
|
229
|
+
return AnyView(ItemGrid(items: items))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Even better — use @ViewBuilder
|
|
233
|
+
@ViewBuilder
|
|
234
|
+
private var contentView: some View {
|
|
235
|
+
if items.isEmpty {
|
|
236
|
+
EmptyStateView()
|
|
237
|
+
} else {
|
|
238
|
+
ItemGrid(items: items)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Sheet / fullScreenCover
|
|
244
|
+
```swift
|
|
245
|
+
@State private var showingDetail = false
|
|
246
|
+
@State private var selectedItem: Item?
|
|
247
|
+
|
|
248
|
+
.sheet(item: $selectedItem) { item in
|
|
249
|
+
ItemDetailView(item: item)
|
|
250
|
+
.presentationDetents([.medium, .large])
|
|
251
|
+
.presentationDragIndicator(.visible)
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Best Practices by Category
|
|
258
|
+
|
|
259
|
+
### Views
|
|
260
|
+
- Keep `body` under ~40 lines — extract sub-views or use `@ViewBuilder` helpers
|
|
261
|
+
- Prefer computed properties for conditional logic over inline ternaries in body
|
|
262
|
+
- Use `Group { }` to apply modifiers to multiple views without adding layout container
|
|
263
|
+
- `ViewModifier` for reusable style bundles (card style, section header style)
|
|
264
|
+
|
|
265
|
+
### State
|
|
266
|
+
- `@State` is private — never pass it across views directly; use `@Binding` for child writes
|
|
267
|
+
- `@StateObject` owns the object — use when the view creates the ViewModel
|
|
268
|
+
- `@ObservedObject` does NOT own — use for injected ViewModels (won't survive view recreation)
|
|
269
|
+
- `@Observable` (iOS 17+) is simpler: no `@Published` needed, just mark class `@Observable`
|
|
270
|
+
- Avoid storing the same state in multiple places — single source of truth
|
|
271
|
+
|
|
272
|
+
### Navigation
|
|
273
|
+
- `NavigationStack` replaces `NavigationView` (iOS 16+)
|
|
274
|
+
- Use typed `NavigationPath` for programmatic deep linking
|
|
275
|
+
- `.navigationDestination` decouples routing from row UI
|
|
276
|
+
- For tab apps: `TabView` at root, `NavigationStack` inside each tab
|
|
277
|
+
|
|
278
|
+
### Lists & Performance
|
|
279
|
+
- `List` for interactive rows — cell reuse built in
|
|
280
|
+
- `LazyVStack` in `ScrollView` for custom card UIs
|
|
281
|
+
- `ForEach` requires `Identifiable` items or explicit `id:` parameter
|
|
282
|
+
- Avoid heavy computation in row `body` — cache in ViewModel
|
|
283
|
+
|
|
284
|
+
### Async
|
|
285
|
+
- `.task { }` for view-lifecycle async work — cancels automatically on disappear
|
|
286
|
+
- `.task(id:)` re-runs when the id value changes (replaces `.onChange` + `Task`)
|
|
287
|
+
- `async let` for parallel fetches within a single task
|
|
288
|
+
|
|
289
|
+
### Forms
|
|
290
|
+
- `textContentType` on every text field — enables autofill
|
|
291
|
+
- `keyboardType` matches expected input (`.emailAddress`, `.numberPad`, `.URL`)
|
|
292
|
+
- `.submitLabel(.next)` and `FocusState` for keyboard tab order
|
|
293
|
+
- Disable submit button (`disabled(!isValid)`) — never rely only on server validation
|
|
294
|
+
|
|
295
|
+
### Animation
|
|
296
|
+
- `withAnimation` for state-driven animations
|
|
297
|
+
- `.matchedGeometryEffect` for hero-style transitions between views
|
|
298
|
+
- Always check `accessibilityReduceMotion` before animating
|
|
299
|
+
- `.transition(.scale.combined(with: .opacity))` for enter/exit
|
|
300
|
+
|
|
301
|
+
### Accessibility
|
|
302
|
+
- Every icon-only button needs `.accessibilityLabel`
|
|
303
|
+
- `.accessibilityHint` for non-obvious actions
|
|
304
|
+
- `.accessibilityElement(children: .combine)` for grouped content
|
|
305
|
+
- Test with VoiceOver on device — simulator is insufficient
|
|
306
|
+
- Support Dynamic Type — never override `.font` with fixed sizes
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Common Anti-Patterns
|
|
311
|
+
|
|
312
|
+
1. `@StateObject` for injected objects — use `@ObservedObject`; `@StateObject` creates/owns the instance and will recreate it
|
|
313
|
+
2. `@ObservableObject` + `@Published` for new iOS 17+ code — use `@Observable` macro (cleaner, faster)
|
|
314
|
+
3. `.onAppear + Task { }` — use `.task { }` instead (auto-cancels on disappear, preventing data races)
|
|
315
|
+
4. Force-unwrapping optionals in views — use `if let` or `guard let`; crashes are not recoverable in SwiftUI previews
|
|
316
|
+
5. Complex logic in `body` — extract to computed properties or ViewModels; body re-evaluates frequently
|
|
317
|
+
6. Missing `accessibilityLabel` on icon buttons — VoiceOver reads "button" with no context
|
|
318
|
+
7. `NavigationView` in new code — deprecated; use `NavigationStack`
|
|
319
|
+
8. Storing `@EnvironmentObject` locally with `@State` — breaks injection; access via `@EnvironmentObject` directly
|
|
320
|
+
9. Deeply nested closures in body — extract to `@ViewBuilder` functions
|
|
321
|
+
10. Ignoring `task` cancellation — long tasks should check `Task.isCancelled`
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Performance Checklist
|
|
326
|
+
|
|
327
|
+
- [ ] `.task` not `.onAppear + Task { }` for async loading
|
|
328
|
+
- [ ] `@Observable` (iOS 17+) for ViewModels — finer-grained updates than `@ObservableObject`
|
|
329
|
+
- [ ] `LazyVStack`/`LazyHStack` inside `ScrollView` for long custom content
|
|
330
|
+
- [ ] `List` for interactive rows (reuses cells natively), `ScrollView + LazyVStack` for custom cards
|
|
331
|
+
- [ ] `accessibilityReduceMotion` check before any auto-animation
|
|
332
|
+
- [ ] `Equatable` conformance on views with complex bodies to skip unnecessary redraws
|
|
333
|
+
- [ ] `.id(item.id)` on list items when reordering is possible
|
|
334
|
+
- [ ] Profile in Instruments (SwiftUI template) — not just in simulator
|
|
335
|
+
- [ ] `nonisolated` on pure functions to avoid main-actor overhead
|
|
336
|
+
- [ ] `@MainActor` on ViewModels that update UI — prevents threading bugs
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# Three.js / React Three Fiber UI/UX Guidelines
|
|
2
|
+
|
|
3
|
+
## When to read this
|
|
4
|
+
Use when building WebGL scenes, 3D product viewers, interactive data visualization, creative canvases, or scroll-driven 3D experiences with Three.js or React Three Fiber (R3F).
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Recommended Libraries
|
|
9
|
+
|
|
10
|
+
| Library | Purpose | Install |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| three | Core WebGL renderer | `npm install three` |
|
|
13
|
+
| @react-three/fiber | React renderer for Three.js | `npm install @react-three/fiber` |
|
|
14
|
+
| @react-three/drei | R3F helpers (OrbitControls, Environment, Text…) | `npm install @react-three/drei` |
|
|
15
|
+
| @react-three/postprocessing | Post-processing (bloom, SSR, depth of field) | `npm install @react-three/postprocessing` |
|
|
16
|
+
| GSAP | Animation timelines + ScrollTrigger | `npm install gsap` |
|
|
17
|
+
| Leva | Debug panel for scene parameters | `npm install leva` |
|
|
18
|
+
| @types/three | TypeScript types | `npm install -D @types/three` |
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Style Recommendations by Use Case
|
|
23
|
+
|
|
24
|
+
| Use Case | Visual Style | Camera | Lighting |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| Product showcase | Dark bg, clean geometry, rim lighting | Orbit / turntable | 3-point: key + fill + rim |
|
|
27
|
+
| Interactive art / portfolio | Aurora/Neon, full-screen canvas | Free orbit | HDR environment map |
|
|
28
|
+
| Scroll-driven narrative | Cinematic, fog, depth | GSAP ScrollTrigger path | Directional + ambient |
|
|
29
|
+
| Data visualization | Minimal, muted palette, labeled axes | Orthographic or fixed perspective | Flat ambient only |
|
|
30
|
+
| Game / immersive | Full-screen, minimal HUD | First-person or follow cam | Dynamic point lights |
|
|
31
|
+
| SaaS hero / landing | Subtle 3D accent, no full-screen | Fixed, no user control | Soft ambient + directional |
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Two Approaches
|
|
36
|
+
|
|
37
|
+
### Vanilla Three.js (imperative)
|
|
38
|
+
Best for: standalone canvas, full control, no React.
|
|
39
|
+
|
|
40
|
+
### React Three Fiber (declarative)
|
|
41
|
+
Best for: integrating 3D into a React/Next.js app, sharing state with UI, composable scene graphs.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Top UX Patterns
|
|
46
|
+
|
|
47
|
+
### 1. Core Renderer Setup (Vanilla)
|
|
48
|
+
|
|
49
|
+
```javascript
|
|
50
|
+
import * as THREE from 'three';
|
|
51
|
+
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
52
|
+
|
|
53
|
+
const scene = new THREE.Scene();
|
|
54
|
+
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
55
|
+
camera.position.set(0, 1, 5);
|
|
56
|
+
|
|
57
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
58
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
59
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // NEVER skip the cap
|
|
60
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
61
|
+
renderer.toneMappingExposure = 1.0;
|
|
62
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
63
|
+
document.body.appendChild(renderer.domElement);
|
|
64
|
+
|
|
65
|
+
// Accessibility
|
|
66
|
+
renderer.domElement.setAttribute('role', 'img');
|
|
67
|
+
renderer.domElement.setAttribute('aria-label', 'Interactive 3D scene. Drag to rotate, scroll to zoom.');
|
|
68
|
+
|
|
69
|
+
const controls = new OrbitControls(camera, renderer.domElement);
|
|
70
|
+
controls.enableDamping = true;
|
|
71
|
+
|
|
72
|
+
const clock = new THREE.Clock();
|
|
73
|
+
renderer.setAnimationLoop(() => {
|
|
74
|
+
const dt = clock.getDelta(); // call ONCE per frame — never call getDelta() again this frame
|
|
75
|
+
controls.update();
|
|
76
|
+
renderer.render(scene, camera);
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 2. Core Setup (React Three Fiber)
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
|
84
|
+
import { OrbitControls, Environment, useGLTF } from '@react-three/drei';
|
|
85
|
+
import { useRef } from 'react';
|
|
86
|
+
import * as THREE from 'three';
|
|
87
|
+
|
|
88
|
+
function RotatingBox() {
|
|
89
|
+
const ref = useRef<THREE.Mesh>(null);
|
|
90
|
+
useFrame((_, delta) => {
|
|
91
|
+
if (ref.current) ref.current.rotation.y += delta * 0.5; // delta = framerate-independent
|
|
92
|
+
});
|
|
93
|
+
return (
|
|
94
|
+
<mesh ref={ref} castShadow>
|
|
95
|
+
<boxGeometry args={[1, 1, 1]} />
|
|
96
|
+
<meshStandardMaterial color="#2563EB" roughness={0.3} metalness={0.1} />
|
|
97
|
+
</mesh>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default function Scene() {
|
|
102
|
+
return (
|
|
103
|
+
<Canvas
|
|
104
|
+
shadows
|
|
105
|
+
camera={{ position: [0, 1, 5], fov: 75 }}
|
|
106
|
+
gl={{ antialias: true, toneMapping: THREE.ACESFilmicToneMapping }}
|
|
107
|
+
aria-label="Interactive 3D scene"
|
|
108
|
+
>
|
|
109
|
+
<ambientLight intensity={0.4} />
|
|
110
|
+
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
|
|
111
|
+
<RotatingBox />
|
|
112
|
+
<OrbitControls enableDamping />
|
|
113
|
+
<Environment preset="sunset" />
|
|
114
|
+
</Canvas>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 3. Delta-Time Animation (framerate-independent)
|
|
120
|
+
|
|
121
|
+
```javascript
|
|
122
|
+
// BAD — speed varies with frame rate (2× faster on 120Hz vs 60Hz)
|
|
123
|
+
mesh.rotation.y += 0.01;
|
|
124
|
+
|
|
125
|
+
// GOOD — consistent speed regardless of frame rate
|
|
126
|
+
const dt = clock.getDelta(); // called once at top of animate()
|
|
127
|
+
mesh.rotation.y += dt * 0.8;
|
|
128
|
+
|
|
129
|
+
// Lerp for smooth follow/easing
|
|
130
|
+
camera.position.x += (targetX - camera.position.x) * 0.05;
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 4. Responsive Canvas
|
|
134
|
+
|
|
135
|
+
```javascript
|
|
136
|
+
// ResizeObserver for container-aware sizing (not window.resize)
|
|
137
|
+
const ro = new ResizeObserver(entries => {
|
|
138
|
+
const { width, height } = entries[0].contentRect;
|
|
139
|
+
renderer.setSize(width, height);
|
|
140
|
+
camera.aspect = width / height;
|
|
141
|
+
camera.updateProjectionMatrix();
|
|
142
|
+
});
|
|
143
|
+
ro.observe(canvas.parentElement); // observe container, not window
|
|
144
|
+
|
|
145
|
+
// Touch support for mobile
|
|
146
|
+
canvas.addEventListener('touchmove', e => {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
const t = e.touches[0];
|
|
149
|
+
mouse.x = (t.clientX / canvas.clientWidth) * 2 - 1;
|
|
150
|
+
mouse.y = -(t.clientY / canvas.clientHeight) * 2 + 1;
|
|
151
|
+
}, { passive: false });
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 5. Pause Render Loop When Tab Hidden
|
|
155
|
+
|
|
156
|
+
```javascript
|
|
157
|
+
// Use setAnimationLoop as the driver — it can be paused
|
|
158
|
+
renderer.setAnimationLoop(animate);
|
|
159
|
+
|
|
160
|
+
document.addEventListener('visibilitychange', () => {
|
|
161
|
+
if (document.hidden) renderer.setAnimationLoop(null); // pause — saves battery
|
|
162
|
+
else renderer.setAnimationLoop(animate); // resume
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### 6. InstancedMesh for 50+ Repeated Objects
|
|
167
|
+
|
|
168
|
+
```javascript
|
|
169
|
+
const COUNT = 500;
|
|
170
|
+
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
|
|
171
|
+
const matrix = new THREE.Matrix4();
|
|
172
|
+
|
|
173
|
+
for (let i = 0; i < COUNT; i++) {
|
|
174
|
+
matrix.setPosition(
|
|
175
|
+
(Math.random() - 0.5) * 20,
|
|
176
|
+
(Math.random() - 0.5) * 20,
|
|
177
|
+
(Math.random() - 0.5) * 20
|
|
178
|
+
);
|
|
179
|
+
mesh.setMatrixAt(i, matrix);
|
|
180
|
+
}
|
|
181
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
182
|
+
scene.add(mesh);
|
|
183
|
+
// Result: 1 draw call instead of 500
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### 7. GLTF Model Loading
|
|
187
|
+
|
|
188
|
+
```javascript
|
|
189
|
+
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
190
|
+
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
|
191
|
+
|
|
192
|
+
const dracoLoader = new DRACOLoader();
|
|
193
|
+
dracoLoader.setDecoderPath('/draco/');
|
|
194
|
+
|
|
195
|
+
const loader = new GLTFLoader();
|
|
196
|
+
loader.setDRACOLoader(dracoLoader);
|
|
197
|
+
|
|
198
|
+
loader.load('model.glb', (gltf) => {
|
|
199
|
+
gltf.scene.traverse(child => {
|
|
200
|
+
if (child.isMesh) {
|
|
201
|
+
child.castShadow = true;
|
|
202
|
+
child.receiveShadow = true;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
scene.add(gltf.scene);
|
|
206
|
+
}, undefined, (error) => {
|
|
207
|
+
console.error('Failed to load model:', error);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// R3F version:
|
|
211
|
+
function Model() {
|
|
212
|
+
const { scene } = useGLTF('/model.glb');
|
|
213
|
+
return <primitive object={scene} />;
|
|
214
|
+
}
|
|
215
|
+
useGLTF.preload('/model.glb');
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### 8. Scroll-Driven Camera with GSAP ScrollTrigger
|
|
219
|
+
|
|
220
|
+
```javascript
|
|
221
|
+
import gsap from 'gsap';
|
|
222
|
+
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
|
223
|
+
|
|
224
|
+
gsap.registerPlugin(ScrollTrigger); // must call before use
|
|
225
|
+
|
|
226
|
+
gsap.to(camera.position, {
|
|
227
|
+
x: 3,
|
|
228
|
+
y: 1,
|
|
229
|
+
z: 2,
|
|
230
|
+
ease: 'none',
|
|
231
|
+
scrollTrigger: {
|
|
232
|
+
trigger: '.canvas-wrapper',
|
|
233
|
+
start: 'top top',
|
|
234
|
+
end: 'bottom bottom',
|
|
235
|
+
scrub: 1, // 1-second lag for cinematic smoothness
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### 9. Particle System
|
|
241
|
+
|
|
242
|
+
```javascript
|
|
243
|
+
const COUNT = 3000; // safe mobile baseline — profile before raising
|
|
244
|
+
const geometry = new THREE.BufferGeometry();
|
|
245
|
+
const positions = new Float32Array(COUNT * 3);
|
|
246
|
+
|
|
247
|
+
for (let i = 0; i < COUNT * 3; i++) {
|
|
248
|
+
positions[i] = (Math.random() - 0.5) * 20;
|
|
249
|
+
}
|
|
250
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
251
|
+
|
|
252
|
+
const particles = new THREE.Points(
|
|
253
|
+
geometry,
|
|
254
|
+
new THREE.PointsMaterial({ size: 0.05, color: 0xffffff, sizeAttenuation: true })
|
|
255
|
+
);
|
|
256
|
+
scene.add(particles);
|
|
257
|
+
|
|
258
|
+
// Animating particles — must set needsUpdate
|
|
259
|
+
function animate() {
|
|
260
|
+
const pos = geometry.attributes.position.array;
|
|
261
|
+
for (let i = 1; i < pos.length; i += 3) {
|
|
262
|
+
pos[i] += Math.sin(clock.getElapsedTime() + i) * 0.001;
|
|
263
|
+
}
|
|
264
|
+
geometry.attributes.position.needsUpdate = true; // GPU re-upload — REQUIRED
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### 10. Geometry and Material Disposal (Memory Management)
|
|
269
|
+
|
|
270
|
+
```javascript
|
|
271
|
+
// Always dispose when removing objects
|
|
272
|
+
function removeObject(mesh) {
|
|
273
|
+
mesh.geometry.dispose();
|
|
274
|
+
if (Array.isArray(mesh.material)) {
|
|
275
|
+
mesh.material.forEach(m => m.dispose());
|
|
276
|
+
} else {
|
|
277
|
+
mesh.material.dispose();
|
|
278
|
+
}
|
|
279
|
+
if (mesh.material.map) mesh.material.map.dispose();
|
|
280
|
+
scene.remove(mesh);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// R3F: dispose in useEffect cleanup
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
return () => {
|
|
286
|
+
geometry.dispose();
|
|
287
|
+
material.dispose();
|
|
288
|
+
};
|
|
289
|
+
}, []);
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Best Practices by Category
|
|
295
|
+
|
|
296
|
+
### Scene Setup
|
|
297
|
+
- Always `setPixelRatio(Math.min(devicePixelRatio, 2))` — beyond 2 is invisible but doubles GPU load
|
|
298
|
+
- `antialias: true` must be set in constructor — cannot be changed after
|
|
299
|
+
- Use `ACESFilmicToneMapping` for perceptually accurate colors
|
|
300
|
+
- `SRGBColorSpace` for output — prevents washed-out colors
|
|
301
|
+
|
|
302
|
+
### Animation
|
|
303
|
+
- `clock.getDelta()` exactly **once** per `animate()` frame — store in `dt`, reuse it
|
|
304
|
+
- All motion multiplied by `dt` — framerate-independent at 30fps, 60fps, 120fps
|
|
305
|
+
- Use `renderer.setAnimationLoop()` not recursive `requestAnimationFrame` — enables pause
|
|
306
|
+
- Lerp (`value += (target - value) * alpha`) for organic easing without libraries
|
|
307
|
+
- GSAP timelines for multi-step sequences; `scrub` for scroll-driven camera
|
|
308
|
+
|
|
309
|
+
### Performance
|
|
310
|
+
- `InstancedMesh` for 50+ identical objects — 1 draw call vs N
|
|
311
|
+
- `LOD` (Level of Detail) for objects at varying distances
|
|
312
|
+
- `FogExp2` for atmospheric depth + implicitly culls far objects
|
|
313
|
+
- `BufferGeometry` + `Points` for particles — never individual `Mesh` objects
|
|
314
|
+
- Particle ceiling: 3,000 safe baseline; 50,000+ drops frames on mid-range mobile
|
|
315
|
+
|
|
316
|
+
### Memory
|
|
317
|
+
- Dispose geometry, material, and textures when removing objects
|
|
318
|
+
- Never create new geometries/materials inside `animate()` — allocates each frame
|
|
319
|
+
- Reuse geometry and material instances across objects
|
|
320
|
+
|
|
321
|
+
### Responsive
|
|
322
|
+
- `ResizeObserver` on container (not `window resize`) — fires on any container resize
|
|
323
|
+
- Use `canvas.clientWidth/Height` not `window.innerWidth/Height` for contained canvases
|
|
324
|
+
- Touch events alongside mouse events for mobile interactivity
|
|
325
|
+
|
|
326
|
+
### Accessibility
|
|
327
|
+
- `role="img"` + descriptive `aria-label` on canvas — screen readers get context
|
|
328
|
+
- Gate all auto-animation on `prefers-reduced-motion` — track changes with `addEventListener`
|
|
329
|
+
- Provide keyboard alternative for any pointer-only interactions
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## Common Anti-Patterns
|
|
334
|
+
|
|
335
|
+
| Anti-Pattern | Why It's Wrong | Fix |
|
|
336
|
+
|---|---|---|
|
|
337
|
+
| `devicePixelRatio` without cap | 3× GPU cost on retina displays, no visual gain | `Math.min(devicePixelRatio, 2)` |
|
|
338
|
+
| `getDelta()` called twice per frame | Second call always returns ~0 | Call once, store in `dt` |
|
|
339
|
+
| No `geometry.dispose()` | Memory leak — GPU VRAM never freed | Dispose on removal |
|
|
340
|
+
| `window.innerWidth` for contained canvas | Wrong dimensions in flex/grid layouts | `ResizeObserver` on container |
|
|
341
|
+
| 500 individual `Mesh` for particles | 500 draw calls per frame | `Points` + `BufferGeometry` |
|
|
342
|
+
| No `prefers-reduced-motion` check | Causes vestibular disorders | Gate all auto-animation |
|
|
343
|
+
| GSAP without `registerPlugin` | TypeError: ScrollTrigger is not a constructor | `gsap.registerPlugin(ScrollTrigger)` |
|
|
344
|
+
| Inline `requestAnimationFrame` (self-referencing) | Cannot be paused | `renderer.setAnimationLoop()` |
|
|
345
|
+
| Fixed `+= 0.01` animation | 2× faster on 120Hz | Multiply by `clock.getDelta()` |
|
|
346
|
+
| Missing `needsUpdate = true` | Particle positions frozen on GPU | Set after mutating buffer array |
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## Performance Checklist
|
|
351
|
+
|
|
352
|
+
- [ ] `setPixelRatio(Math.min(devicePixelRatio, 2))`
|
|
353
|
+
- [ ] `getDelta()` called once at top of `animate()`, reused as `dt`
|
|
354
|
+
- [ ] All animations multiplied by `dt` (framerate-independent)
|
|
355
|
+
- [ ] `renderer.setAnimationLoop()` as loop driver (pauseable)
|
|
356
|
+
- [ ] `visibilitychange` pauses loop on hidden tab
|
|
357
|
+
- [ ] `dispose()` geometry + material + textures on removal
|
|
358
|
+
- [ ] `InstancedMesh` for 50+ identical objects
|
|
359
|
+
- [ ] Particle count ≤ 3,000 (test on mobile before raising)
|
|
360
|
+
- [ ] `needsUpdate = true` after mutating `BufferAttribute` arrays
|
|
361
|
+
- [ ] `ResizeObserver` on container (not `window resize`)
|
|
362
|
+
- [ ] `prefers-reduced-motion` gating all auto-animation
|
|
363
|
+
- [ ] `role="img"` + `aria-label` on canvas element
|
|
364
|
+
- [ ] GSAP `registerPlugin(ScrollTrigger)` before any ScrollTrigger use
|
|
365
|
+
- [ ] `scrub: 1` (not `onEnter`) for scroll-driven camera paths
|
|
366
|
+
- [ ] Production: Vite + `npm install three` for tree-shaking
|