@remix-run/ui 0.2.0 → 0.3.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.
Files changed (232) hide show
  1. package/dist/animation/demos/animation.demo.d.ts +5 -0
  2. package/dist/animation/demos/animation.demo.js +92 -0
  3. package/dist/animation/demos/animation.demo.js.map +1 -0
  4. package/dist/animation/demos/aspect-ratio.d.ts +2 -0
  5. package/dist/animation/demos/aspect-ratio.js +69 -0
  6. package/dist/animation/demos/aspect-ratio.js.map +1 -0
  7. package/dist/animation/demos/bouncy-switch.d.ts +2 -0
  8. package/dist/animation/demos/bouncy-switch.js +36 -0
  9. package/dist/animation/demos/bouncy-switch.js.map +1 -0
  10. package/dist/animation/demos/color-interpolation.d.ts +1 -0
  11. package/dist/animation/demos/color-interpolation.js +53 -0
  12. package/dist/animation/demos/color-interpolation.js.map +1 -0
  13. package/dist/animation/demos/cube.d.ts +2 -0
  14. package/dist/animation/demos/cube.js +86 -0
  15. package/dist/animation/demos/cube.js.map +1 -0
  16. package/dist/animation/demos/default-animate.d.ts +2 -0
  17. package/dist/animation/demos/default-animate.js +89 -0
  18. package/dist/animation/demos/default-animate.js.map +1 -0
  19. package/dist/animation/demos/drag-release.d.ts +24 -0
  20. package/dist/animation/demos/drag-release.js +96 -0
  21. package/dist/animation/demos/drag-release.js.map +1 -0
  22. package/dist/animation/demos/enter.d.ts +1 -0
  23. package/dist/animation/demos/enter.js +19 -0
  24. package/dist/animation/demos/enter.js.map +1 -0
  25. package/dist/animation/demos/exit.d.ts +2 -0
  26. package/dist/animation/demos/exit.js +58 -0
  27. package/dist/animation/demos/exit.js.map +1 -0
  28. package/dist/animation/demos/flip-toggle.d.ts +2 -0
  29. package/dist/animation/demos/flip-toggle.js +37 -0
  30. package/dist/animation/demos/flip-toggle.js.map +1 -0
  31. package/dist/animation/demos/hold-to-confirm.d.ts +13 -0
  32. package/dist/animation/demos/hold-to-confirm.js +211 -0
  33. package/dist/animation/demos/hold-to-confirm.js.map +1 -0
  34. package/dist/animation/demos/html-content.d.ts +2 -0
  35. package/dist/animation/demos/html-content.js +30 -0
  36. package/dist/animation/demos/html-content.js.map +1 -0
  37. package/dist/animation/demos/interruptible-keyframes.d.ts +1 -0
  38. package/dist/animation/demos/interruptible-keyframes.js +48 -0
  39. package/dist/animation/demos/interruptible-keyframes.js.map +1 -0
  40. package/dist/animation/demos/keyframes.d.ts +1 -0
  41. package/dist/animation/demos/keyframes.js +24 -0
  42. package/dist/animation/demos/keyframes.js.map +1 -0
  43. package/dist/animation/demos/material-ripple.d.ts +2 -0
  44. package/dist/animation/demos/material-ripple.js +127 -0
  45. package/dist/animation/demos/material-ripple.js.map +1 -0
  46. package/dist/animation/demos/mixin-presence-list.d.ts +2 -0
  47. package/dist/animation/demos/mixin-presence-list.js +88 -0
  48. package/dist/animation/demos/mixin-presence-list.js.map +1 -0
  49. package/dist/animation/demos/mixin-reclaim.d.ts +2 -0
  50. package/dist/animation/demos/mixin-reclaim.js +90 -0
  51. package/dist/animation/demos/mixin-reclaim.js.map +1 -0
  52. package/dist/animation/demos/multi-state-badge.d.ts +2 -0
  53. package/dist/animation/demos/multi-state-badge.js +231 -0
  54. package/dist/animation/demos/multi-state-badge.js.map +1 -0
  55. package/dist/animation/demos/press.d.ts +2 -0
  56. package/dist/animation/demos/press.js +63 -0
  57. package/dist/animation/demos/press.js.map +1 -0
  58. package/dist/animation/demos/reordering.d.ts +2 -0
  59. package/dist/animation/demos/reordering.js +50 -0
  60. package/dist/animation/demos/reordering.js.map +1 -0
  61. package/dist/animation/demos/rolling-square.d.ts +2 -0
  62. package/dist/animation/demos/rolling-square.js +42 -0
  63. package/dist/animation/demos/rolling-square.js.map +1 -0
  64. package/dist/animation/demos/rotate.d.ts +1 -0
  65. package/dist/animation/demos/rotate.js +18 -0
  66. package/dist/animation/demos/rotate.js.map +1 -0
  67. package/dist/animation/demos/shared-layout.d.ts +2 -0
  68. package/dist/animation/demos/shared-layout.js +114 -0
  69. package/dist/animation/demos/shared-layout.js.map +1 -0
  70. package/dist/animation/demos/spring.demo.d.ts +6 -0
  71. package/dist/animation/demos/spring.demo.js +304 -0
  72. package/dist/animation/demos/spring.demo.js.map +1 -0
  73. package/dist/animation/demos/transition-options.d.ts +1 -0
  74. package/dist/animation/demos/transition-options.js +21 -0
  75. package/dist/animation/demos/transition-options.js.map +1 -0
  76. package/dist/animation/spring.d.ts +22 -0
  77. package/dist/animation/spring.js +22 -0
  78. package/dist/animation/spring.js.map +1 -1
  79. package/dist/components/accordion/demos/card.demo.d.ts +1 -0
  80. package/dist/components/accordion/demos/card.demo.js +1 -0
  81. package/dist/components/accordion/demos/card.demo.js.map +1 -1
  82. package/dist/components/accordion/demos/multiple.demo.d.ts +1 -0
  83. package/dist/components/accordion/demos/multiple.demo.js +1 -0
  84. package/dist/components/accordion/demos/multiple.demo.js.map +1 -1
  85. package/dist/components/accordion/demos/overview.demo.d.ts +1 -0
  86. package/dist/components/accordion/demos/overview.demo.js +1 -0
  87. package/dist/components/accordion/demos/overview.demo.js.map +1 -1
  88. package/dist/components/anchor/anchor.d.ts +8 -1
  89. package/dist/components/anchor/anchor.js +30 -11
  90. package/dist/components/anchor/anchor.js.map +1 -1
  91. package/dist/components/breadcrumbs/{demos/basic.demo.d.ts → basic.demo.d.ts} +1 -0
  92. package/dist/components/breadcrumbs/{demos/basic.demo.js → basic.demo.js} +1 -0
  93. package/dist/components/breadcrumbs/basic.demo.js.map +1 -0
  94. package/dist/components/breadcrumbs/{demos/separator.demo.d.ts → separator.demo.d.ts} +1 -0
  95. package/dist/components/breadcrumbs/{demos/separator.demo.js → separator.demo.js} +1 -0
  96. package/dist/components/breadcrumbs/separator.demo.js.map +1 -0
  97. package/dist/components/button/demos/aliases.demo.d.ts +1 -0
  98. package/dist/components/button/demos/aliases.demo.js +1 -0
  99. package/dist/components/button/demos/aliases.demo.js.map +1 -1
  100. package/dist/components/button/demos/basic.demo.d.ts +1 -0
  101. package/dist/components/button/demos/basic.demo.js +1 -0
  102. package/dist/components/button/demos/basic.demo.js.map +1 -1
  103. package/dist/components/button/demos/states.demo.d.ts +1 -0
  104. package/dist/components/button/demos/states.demo.js +1 -0
  105. package/dist/components/button/demos/states.demo.js.map +1 -1
  106. package/dist/components/combobox/{demos/overview.demo.d.ts → overview.demo.d.ts} +1 -0
  107. package/dist/components/combobox/{demos/overview.demo.js → overview.demo.js} +1 -0
  108. package/dist/components/combobox/overview.demo.js.map +1 -0
  109. package/dist/components/listbox/{demos/overview.demo.d.ts → overview.demo.d.ts} +1 -0
  110. package/dist/components/listbox/{demos/overview.demo.js → overview.demo.js} +1 -0
  111. package/dist/components/listbox/overview.demo.js.map +1 -0
  112. package/dist/components/menu/demos/bubbling.demo.d.ts +1 -0
  113. package/dist/components/menu/demos/bubbling.demo.js +1 -0
  114. package/dist/components/menu/demos/bubbling.demo.js.map +1 -1
  115. package/dist/components/menu/demos/context-trigger.demo.d.ts +7 -0
  116. package/dist/components/menu/demos/context-trigger.demo.js +87 -0
  117. package/dist/components/menu/demos/context-trigger.demo.js.map +1 -0
  118. package/dist/components/menu/demos/overview.demo.d.ts +1 -0
  119. package/dist/components/menu/demos/overview.demo.js +1 -0
  120. package/dist/components/menu/demos/overview.demo.js.map +1 -1
  121. package/dist/components/menu/menu.d.ts +3 -0
  122. package/dist/components/menu/menu.js +55 -0
  123. package/dist/components/menu/menu.js.map +1 -1
  124. package/dist/components/popover/{demos/overview.demo.d.ts → overview.demo.d.ts} +1 -0
  125. package/dist/components/popover/{demos/overview.demo.js → overview.demo.js} +1 -0
  126. package/dist/components/popover/overview.demo.js.map +1 -0
  127. package/dist/components/popover/popover.d.ts +2 -2
  128. package/dist/components/popover/popover.js +11 -4
  129. package/dist/components/popover/popover.js.map +1 -1
  130. package/dist/components/select/{demos/deconstructed.demo.d.ts → deconstructed.demo.d.ts} +1 -0
  131. package/dist/components/select/{demos/deconstructed.demo.js → deconstructed.demo.js} +1 -0
  132. package/dist/components/select/deconstructed.demo.js.map +1 -0
  133. package/dist/components/select/{demos/overview.demo.d.ts → overview.demo.d.ts} +1 -0
  134. package/dist/components/select/{demos/overview.demo.js → overview.demo.js} +1 -0
  135. package/dist/components/select/overview.demo.js.map +1 -0
  136. package/dist/components/select/select.js +1 -1
  137. package/dist/components/select/select.js.map +1 -1
  138. package/dist/runtime/client-entries.d.ts +1 -2
  139. package/dist/runtime/client-entries.js.map +1 -1
  140. package/dist/runtime/component.d.ts +2 -2
  141. package/dist/runtime/component.js +1 -1
  142. package/dist/runtime/component.js.map +1 -1
  143. package/dist/runtime/demos/basic.demo.d.ts +6 -0
  144. package/dist/runtime/demos/basic.demo.js +16 -0
  145. package/dist/runtime/demos/basic.demo.js.map +1 -0
  146. package/dist/runtime/demos/controlled-uncontrolled-values.demo.d.ts +6 -0
  147. package/dist/runtime/demos/controlled-uncontrolled-values.demo.js +77 -0
  148. package/dist/runtime/demos/controlled-uncontrolled-values.demo.js.map +1 -0
  149. package/dist/runtime/demos/keyed-list.demo.d.ts +6 -0
  150. package/dist/runtime/demos/keyed-list.demo.js +62 -0
  151. package/dist/runtime/demos/keyed-list.demo.js.map +1 -0
  152. package/dist/runtime/demos/readme.demo.d.ts +5 -0
  153. package/dist/runtime/demos/readme.demo.js +396 -0
  154. package/dist/runtime/demos/readme.demo.js.map +1 -0
  155. package/dist/runtime/diff-dom.js +194 -21
  156. package/dist/runtime/diff-dom.js.map +1 -1
  157. package/dist/runtime/frame.d.ts +7 -0
  158. package/dist/runtime/frame.js +176 -41
  159. package/dist/runtime/frame.js.map +1 -1
  160. package/dist/runtime/jsx.d.ts +2 -3
  161. package/dist/runtime/jsx.js.map +1 -1
  162. package/dist/server/stream.js +3 -2
  163. package/dist/server/stream.js.map +1 -1
  164. package/dist/style/style.js +27 -10
  165. package/dist/style/style.js.map +1 -1
  166. package/package.json +3 -3
  167. package/src/animation/demos/animation.demo.tsx +194 -0
  168. package/src/animation/demos/aspect-ratio.tsx +120 -0
  169. package/src/animation/demos/bouncy-switch.tsx +47 -0
  170. package/src/animation/demos/color-interpolation.tsx +73 -0
  171. package/src/animation/demos/cube.tsx +116 -0
  172. package/src/animation/demos/default-animate.tsx +129 -0
  173. package/src/animation/demos/drag-release.ts +118 -0
  174. package/src/animation/demos/enter.tsx +22 -0
  175. package/src/animation/demos/exit.tsx +79 -0
  176. package/src/animation/demos/flip-toggle.tsx +46 -0
  177. package/src/animation/demos/hold-to-confirm.tsx +317 -0
  178. package/src/animation/demos/html-content.tsx +41 -0
  179. package/src/animation/demos/interruptible-keyframes.tsx +62 -0
  180. package/src/animation/demos/keyframes.tsx +29 -0
  181. package/src/animation/demos/material-ripple.tsx +158 -0
  182. package/src/animation/demos/mixin-presence-list.tsx +121 -0
  183. package/src/animation/demos/mixin-reclaim.tsx +125 -0
  184. package/src/animation/demos/multi-state-badge.tsx +426 -0
  185. package/src/animation/demos/press.tsx +67 -0
  186. package/src/animation/demos/reordering.tsx +65 -0
  187. package/src/animation/demos/rolling-square.tsx +57 -0
  188. package/src/animation/demos/rotate.tsx +21 -0
  189. package/src/animation/demos/shared-layout.tsx +211 -0
  190. package/src/animation/demos/spring.demo.tsx +385 -0
  191. package/src/animation/demos/transition-options.tsx +24 -0
  192. package/src/animation/spring.ts +22 -0
  193. package/src/components/accordion/demos/card.demo.tsx +1 -0
  194. package/src/components/accordion/demos/multiple.demo.tsx +1 -0
  195. package/src/components/accordion/demos/overview.demo.tsx +1 -0
  196. package/src/components/anchor/README.md +11 -3
  197. package/src/components/anchor/anchor.ts +53 -12
  198. package/src/components/breadcrumbs/{demos/basic.demo.tsx → basic.demo.tsx} +1 -0
  199. package/src/components/breadcrumbs/{demos/separator.demo.tsx → separator.demo.tsx} +1 -0
  200. package/src/components/button/demos/aliases.demo.tsx +1 -0
  201. package/src/components/button/demos/basic.demo.tsx +1 -0
  202. package/src/components/button/demos/states.demo.tsx +1 -0
  203. package/src/components/combobox/{demos/overview.demo.tsx → overview.demo.tsx} +1 -0
  204. package/src/components/listbox/{demos/overview.demo.tsx → overview.demo.tsx} +1 -0
  205. package/src/components/menu/README.md +26 -2
  206. package/src/components/menu/demos/bubbling.demo.tsx +1 -0
  207. package/src/components/menu/demos/context-trigger.demo.tsx +133 -0
  208. package/src/components/menu/demos/overview.demo.tsx +1 -0
  209. package/src/components/menu/menu.tsx +73 -1
  210. package/src/components/popover/{demos/overview.demo.tsx → overview.demo.tsx} +1 -0
  211. package/src/components/popover/popover.ts +18 -9
  212. package/src/components/select/{demos/deconstructed.demo.tsx → deconstructed.demo.tsx} +1 -0
  213. package/src/components/select/{demos/overview.demo.tsx → overview.demo.tsx} +1 -0
  214. package/src/components/select/select.tsx +1 -1
  215. package/src/runtime/client-entries.ts +1 -5
  216. package/src/runtime/component.ts +3 -3
  217. package/src/runtime/demos/basic.demo.tsx +21 -0
  218. package/src/runtime/demos/controlled-uncontrolled-values.demo.tsx +177 -0
  219. package/src/runtime/demos/keyed-list.demo.tsx +94 -0
  220. package/src/runtime/demos/readme.demo.tsx +702 -0
  221. package/src/runtime/diff-dom.ts +270 -20
  222. package/src/runtime/frame.ts +218 -41
  223. package/src/runtime/jsx.ts +6 -10
  224. package/src/server/stream.ts +7 -2
  225. package/src/style/style.ts +29 -10
  226. package/dist/components/breadcrumbs/demos/basic.demo.js.map +0 -1
  227. package/dist/components/breadcrumbs/demos/separator.demo.js.map +0 -1
  228. package/dist/components/combobox/demos/overview.demo.js.map +0 -1
  229. package/dist/components/listbox/demos/overview.demo.js.map +0 -1
  230. package/dist/components/popover/demos/overview.demo.js.map +0 -1
  231. package/dist/components/select/demos/deconstructed.demo.js.map +0 -1
  232. package/dist/components/select/demos/overview.demo.js.map +0 -1
@@ -0,0 +1,385 @@
1
+ import { addEventListeners, css, on, ref, type Handle } from '@remix-run/ui'
2
+
3
+ import { dragVelocityEvents } from './drag-release.ts'
4
+ import { spring, type SpringPreset } from '@remix-run/ui/animation'
5
+
6
+ interface TrailPoint {
7
+ x: number
8
+ y: number
9
+ time: number
10
+ }
11
+
12
+ function PointerTrail(handle: Handle) {
13
+ let canvas: HTMLCanvasElement
14
+ let points: TrailPoint[] = []
15
+ let isDown = false
16
+ let releaseTime = 0
17
+ let animationId: number | null = null
18
+
19
+ let maxAge = 150 // ms - how long points stay in the trail while dragging
20
+ let fadeDuration = 800 // ms - how long the trail fades after release
21
+
22
+ function draw() {
23
+ if (!canvas) return
24
+ let ctx = canvas.getContext('2d')
25
+ if (!ctx) return
26
+
27
+ let now = performance.now()
28
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
29
+
30
+ // Calculate overall opacity based on release time
31
+ let overallOpacity = 1
32
+ if (!isDown && releaseTime > 0) {
33
+ let elapsed = now - releaseTime
34
+ overallOpacity = Math.max(0, 1 - elapsed / fadeDuration)
35
+ if (overallOpacity <= 0) {
36
+ points = []
37
+ animationId = null
38
+ return
39
+ }
40
+ }
41
+
42
+ // Filter out old points while dragging
43
+ if (isDown) {
44
+ points = points.filter((p) => now - p.time < maxAge)
45
+ }
46
+
47
+ if (points.length < 2) {
48
+ if (isDown || overallOpacity > 0) {
49
+ animationId = requestAnimationFrame(draw)
50
+ } else {
51
+ animationId = null
52
+ }
53
+ return
54
+ }
55
+
56
+ // Draw the trail as a tapered path
57
+ ctx.beginPath()
58
+ ctx.lineCap = 'round'
59
+ ctx.lineJoin = 'round'
60
+
61
+ for (let i = 1; i < points.length; i++) {
62
+ let prev = points[i - 1]
63
+ let curr = points[i]
64
+
65
+ // Age-based opacity (older = more transparent)
66
+ let age = isDown ? (now - prev.time) / maxAge : 0
67
+ let segmentOpacity = (1 - age) * overallOpacity
68
+
69
+ // Thickness tapers from thin (old) to thick (new)
70
+ let progress = i / (points.length - 1)
71
+ let thickness = 2 + progress * 8
72
+
73
+ ctx.beginPath()
74
+ ctx.moveTo(prev.x, prev.y)
75
+ ctx.lineTo(curr.x, curr.y)
76
+ ctx.strokeStyle = `rgba(14, 165, 233, ${segmentOpacity * 0.6})`
77
+ ctx.lineWidth = thickness
78
+ ctx.stroke()
79
+ }
80
+
81
+ // Draw a red glow at the end to show direction
82
+ if (points.length > 0) {
83
+ let last = points[points.length - 1]
84
+ let gradient = ctx.createRadialGradient(last.x, last.y, 0, last.x, last.y, 20)
85
+ gradient.addColorStop(0, `rgba(239, 68, 68, ${overallOpacity * 0.6})`)
86
+ gradient.addColorStop(1, 'rgba(239, 68, 68, 0)')
87
+ ctx.fillStyle = gradient
88
+ ctx.beginPath()
89
+ ctx.arc(last.x, last.y, 20, 0, Math.PI * 2)
90
+ ctx.fill()
91
+ }
92
+
93
+ animationId = requestAnimationFrame(draw)
94
+ }
95
+
96
+ function startDrawing() {
97
+ if (!animationId) {
98
+ animationId = requestAnimationFrame(draw)
99
+ }
100
+ }
101
+
102
+ if (typeof document !== 'undefined') {
103
+ addEventListeners(document, handle.signal, {
104
+ pointerdown(event) {
105
+ if (!(event.target as HTMLElement).closest('.draggable')) return
106
+ isDown = true
107
+ releaseTime = 0
108
+ points = [{ x: event.clientX, y: event.clientY, time: performance.now() }]
109
+ startDrawing()
110
+ },
111
+
112
+ pointermove(event) {
113
+ if (!isDown) return
114
+ points.push({ x: event.clientX, y: event.clientY, time: performance.now() })
115
+ },
116
+
117
+ pointerup() {
118
+ if (!isDown) return
119
+ isDown = false
120
+ releaseTime = performance.now()
121
+ },
122
+
123
+ pointercancel() {
124
+ isDown = false
125
+ releaseTime = performance.now()
126
+ },
127
+ })
128
+ }
129
+
130
+ return () => (
131
+ <canvas
132
+ mix={[
133
+ ref((node) => {
134
+ canvas = node
135
+ canvas.width = window.innerWidth
136
+ canvas.height = window.innerHeight
137
+ }),
138
+ css({
139
+ position: 'absolute',
140
+ inset: 0,
141
+ pointerEvents: 'none',
142
+ zIndex: 5,
143
+ }),
144
+ ]}
145
+ />
146
+ )
147
+ }
148
+
149
+ /**
150
+ * @name Spring Drag Release
151
+ * @description A full-screen spring physics playground with click targets and drag-release velocity.
152
+ */
153
+ export default function SpringDemo(handle: Handle) {
154
+ let viewportWidth = globalThis.window?.innerWidth ?? 1000
155
+ let viewportHeight = globalThis.window?.innerHeight ?? 700
156
+
157
+ // Target circle position (click to move)
158
+ let targetX = viewportWidth / 2
159
+ let targetY = viewportHeight / 2
160
+
161
+ // Draggable circle state
162
+ let dragX = viewportWidth / 2 - 150
163
+ let dragY = viewportHeight / 2
164
+ let isDragging = false
165
+ let isAnimating = false
166
+
167
+ // Current spring transitions (separate for X and Y to capture 2D velocity)
168
+ let transitionX = ''
169
+ let transitionY = ''
170
+
171
+ let selectedPreset: SpringPreset = 'bouncy'
172
+
173
+ // Get default spring transition for target circle
174
+ let springValue = spring(selectedPreset)
175
+
176
+ if (typeof document !== 'undefined') {
177
+ addEventListeners(document, handle.signal, {
178
+ click(event) {
179
+ // Ignore clicks on controls or when dragging
180
+ if ((event.target as HTMLElement).closest('.controls')) return
181
+ if ((event.target as HTMLElement).closest('.draggable')) return
182
+
183
+ targetX = event.clientX
184
+ targetY = event.clientY
185
+ handle.update()
186
+ },
187
+ })
188
+ }
189
+
190
+ return () => (
191
+ <div
192
+ mix={[
193
+ css({
194
+ position: 'fixed',
195
+ inset: 0,
196
+ backgroundColor: '#1a1a2e',
197
+ cursor: 'crosshair',
198
+ overflow: 'hidden',
199
+ }),
200
+ ]}
201
+ >
202
+ {/* Pointer trail */}
203
+ <PointerTrail />
204
+
205
+ {/* Target circle */}
206
+ <div
207
+ mix={[
208
+ css({
209
+ position: 'absolute',
210
+ width: '80px',
211
+ height: '80px',
212
+ borderRadius: '50%',
213
+ backgroundColor: 'transparent',
214
+ border: '3px dashed rgba(233, 69, 96, 0.5)',
215
+ transform: 'translate(-50%, -50%)',
216
+ pointerEvents: 'none',
217
+ }),
218
+ ]}
219
+ style={{
220
+ left: `${targetX}px`,
221
+ top: `${targetY}px`,
222
+ transition: `left ${springValue}, top ${springValue}`,
223
+ }}
224
+ />
225
+
226
+ {/* Draggable circle */}
227
+ <div
228
+ className="draggable"
229
+ mix={[
230
+ css({
231
+ position: 'absolute',
232
+ width: '60px',
233
+ height: '60px',
234
+ borderRadius: '50%',
235
+ backgroundColor: '#0ea5e9',
236
+ boxShadow: '0 0 20px rgba(14, 165, 233, 0.5)',
237
+ transform: 'translate(-50%, -50%)',
238
+ userSelect: 'none',
239
+ touchAction: 'none',
240
+ zIndex: 10,
241
+ }),
242
+ on('transitionend', () => {
243
+ isAnimating = false
244
+ handle.update()
245
+ }),
246
+ on('pointerdown', (event) => {
247
+ event.preventDefault()
248
+ isDragging = true
249
+ isAnimating = false
250
+ handle.update()
251
+ }),
252
+ on('pointermove', (event) => {
253
+ if (!isDragging) return
254
+ dragX = event.clientX
255
+ dragY = event.clientY
256
+ handle.update()
257
+ }),
258
+ dragVelocityEvents(),
259
+ on(dragVelocityEvents.release, (event) => {
260
+ isDragging = false
261
+
262
+ // Calculate distance to target on each axis
263
+ let distX = targetX - dragX
264
+ let distY = targetY - dragY
265
+
266
+ if (Math.abs(distX) < 1 && Math.abs(distY) < 1) {
267
+ handle.update()
268
+ return
269
+ }
270
+
271
+ // Create separate springs for X and Y with their own normalized velocities
272
+ // normalizedVelocity = velocity / distance (with sign!) for each axis
273
+ // The sign matters: positive = moving toward target, negative = moving away
274
+ if (Math.abs(distX) >= 1) {
275
+ let normalizedVelocityX = event.velocityX / distX
276
+ normalizedVelocityX = Math.max(-20, Math.min(20, normalizedVelocityX))
277
+ transitionX = String(spring(selectedPreset, { velocity: normalizedVelocityX }))
278
+ } else {
279
+ transitionX = String(spring(selectedPreset))
280
+ }
281
+
282
+ if (Math.abs(distY) >= 1) {
283
+ let normalizedVelocityY = event.velocityY / distY
284
+ normalizedVelocityY = Math.max(-20, Math.min(20, normalizedVelocityY))
285
+ transitionY = String(spring(selectedPreset, { velocity: normalizedVelocityY }))
286
+ } else {
287
+ transitionY = String(spring(selectedPreset))
288
+ }
289
+
290
+ // Animate to target
291
+ isAnimating = true
292
+ dragX = targetX
293
+ dragY = targetY
294
+ handle.update()
295
+ }),
296
+ ]}
297
+ style={{
298
+ left: `${dragX}px`,
299
+ top: `${dragY}px`,
300
+ cursor: isDragging ? 'grabbing' : 'grab',
301
+ transition: isAnimating ? `left ${transitionX}, top ${transitionY}` : 'none',
302
+ }}
303
+ />
304
+
305
+ {/* Controls */}
306
+ <div
307
+ className="controls"
308
+ mix={[
309
+ css({
310
+ position: 'absolute',
311
+ top: '20px',
312
+ left: '50%',
313
+ transform: 'translateX(-50%)',
314
+ display: 'flex',
315
+ gap: '4px',
316
+ padding: '4px',
317
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
318
+ borderRadius: '8px',
319
+ fontFamily: 'system-ui, sans-serif',
320
+ fontSize: '14px',
321
+ cursor: 'default',
322
+ }),
323
+ ]}
324
+ >
325
+ {(Object.keys(spring.presets) as SpringPreset[]).map((preset) => (
326
+ <label
327
+ key={preset}
328
+ mix={[
329
+ css({
330
+ display: 'flex',
331
+ alignItems: 'center',
332
+ gap: '6px',
333
+ padding: '8px 12px',
334
+ borderRadius: '6px',
335
+ color: '#fff',
336
+ cursor: 'pointer',
337
+ transition: 'background-color 150ms ease',
338
+ '&:hover': {
339
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
340
+ },
341
+ '&:has(input:checked)': {
342
+ backgroundColor: 'rgba(14, 165, 233, 0.3)',
343
+ },
344
+ }),
345
+ ]}
346
+ >
347
+ <input
348
+ type="radio"
349
+ name="spring-preset"
350
+ value={preset}
351
+ checked={selectedPreset === preset}
352
+ mix={[
353
+ css({ accentColor: '#0ea5e9' }),
354
+ on('change', () => {
355
+ selectedPreset = preset
356
+ springValue = spring(selectedPreset)
357
+ handle.update()
358
+ }),
359
+ ]}
360
+ />
361
+ {preset}
362
+ </label>
363
+ ))}
364
+ </div>
365
+
366
+ {/* Instructions */}
367
+ <div
368
+ mix={[
369
+ css({
370
+ position: 'absolute',
371
+ bottom: '20px',
372
+ left: '50%',
373
+ transform: 'translateX(-50%)',
374
+ color: '#ffffff80',
375
+ fontFamily: 'system-ui, sans-serif',
376
+ fontSize: '14px',
377
+ textAlign: 'center',
378
+ }),
379
+ ]}
380
+ >
381
+ Click to move target • Drag the blue circle and release to see velocity-based spring
382
+ </div>
383
+ </div>
384
+ )
385
+ }
@@ -0,0 +1,24 @@
1
+ import { css } from '@remix-run/ui'
2
+ import { animateEntrance } from '@remix-run/ui/animation'
3
+
4
+ export function TransitionOptions() {
5
+ return () => (
6
+ <div
7
+ mix={[
8
+ css({
9
+ width: 100,
10
+ height: 100,
11
+ borderRadius: '50%',
12
+ backgroundColor: '#9911ff',
13
+ }),
14
+ animateEntrance({
15
+ opacity: 0,
16
+ transform: 'scale(0.5)',
17
+ duration: 800,
18
+ delay: 500,
19
+ easing: 'cubic-bezier(0, 0.71, 0.2, 1.01)',
20
+ }),
21
+ ]}
22
+ />
23
+ )
24
+ }
@@ -144,6 +144,28 @@ function transition(
144
144
  return properties.map((p) => `${p} ${s}`).join(', ')
145
145
  }
146
146
 
147
+ /**
148
+ * Create a spring-physics animation iterator for CSS transitions and WAAPI.
149
+ *
150
+ * The returned iterator can be:
151
+ * - Iterated to get position values (0→1)
152
+ * - Spread into WAAPI options: `element.animate(keyframes, { ...spring() })`
153
+ * - Stringified for CSS: `` `transform ${spring()}` ``
154
+ *
155
+ * @example
156
+ * // Named preset
157
+ * let s = spring('bouncy')
158
+ * element.style.transition = `transform ${s}`
159
+ *
160
+ * @example
161
+ * // Custom parameters
162
+ * let s = spring({ duration: 500, bounce: 0.2 })
163
+ * element.animate(keyframes, { ...s })
164
+ *
165
+ * @example
166
+ * // Transition helper for multiple properties
167
+ * element.style.transition = spring.transition(['transform', 'opacity'], 'snappy')
168
+ */
147
169
  export const spring: SpringFunction = Object.assign(createSpring, {
148
170
  transition,
149
171
  presets,
@@ -10,6 +10,7 @@ import { theme } from '@remix-run/ui/theme'
10
10
  /**
11
11
  * @name Accordion in a Card
12
12
  * @description The Accordion can sit inside a card when the surrounding surface needs stronger grouping.
13
+ * @layout center
13
14
  * @order 3
14
15
  */
15
16
  export default function Example() {
@@ -11,6 +11,7 @@ import { theme } from '@remix-run/ui/theme'
11
11
  * @order 2
12
12
  * @name Accordion Multiple Open
13
13
  * @description Multiple mode allows several sections to stay open simultaneously, useful for operational checklists and dashboards.
14
+ * @layout center
14
15
  */
15
16
  export default function Example() {
16
17
  return () => (
@@ -10,6 +10,7 @@ import { theme } from '@remix-run/ui/theme'
10
10
  /**
11
11
  * @name Accordion Overview
12
12
  * @description A single-open disclosure list that keeps settings, billing, or notification rules in one calm section.
13
+ * @layout center
13
14
  * @order 1
14
15
  */
15
16
  export default function Example() {
@@ -1,6 +1,6 @@
1
1
  # anchor
2
2
 
3
- `anchor` positions a floating element against an anchor element and keeps it constrained to the viewport. Use it for custom floating surfaces that need placement, flipping, offsets, and optional relative alignment.
3
+ `anchor` positions a floating element against an anchor element or viewport coordinates and keeps it constrained to the viewport. Use it for custom floating surfaces that need placement, flipping, offsets, and optional relative alignment.
4
4
 
5
5
  ## Usage
6
6
 
@@ -49,11 +49,19 @@ popover.addEventListener('beforetoggle', (event) => {
49
49
  })
50
50
  ```
51
51
 
52
+ Anchor to coordinates when the surface should open at a pointer location.
53
+
54
+ ```tsx
55
+ let cleanup = anchor(popover, { x: event.clientX, y: event.clientY }, { placement: 'bottom-start' })
56
+ ```
57
+
52
58
  ## `anchor.*`
53
59
 
54
- - `anchor(floatingElement, anchorElement, options)`: positions `floatingElement` against `anchorElement`, starts animation-frame polling for geometry changes, and returns a cleanup function.
60
+ - `anchor(floatingElement, anchorTarget, options)`: positions `floatingElement` against an element or coordinate target, starts animation-frame polling for geometry changes, and returns a cleanup function.
55
61
  - `AnchorOptions`: placement, inset, relative alignment, and offset options.
62
+ - `AnchorPoint`: viewport coordinate target with `x`, `y`, and optional `width`/`height`.
56
63
  - `AnchorPlacement`: exported placement names for the main sides and top/bottom start/end alignment.
64
+ - `AnchorTarget`: an `HTMLElement` or `AnchorPoint`.
57
65
 
58
66
  ## Placements
59
67
 
@@ -149,5 +157,5 @@ anchor(listbox, trigger, {
149
157
  - Oversized inset surfaces with `relativeTo` preserve alignment by scrolling the nearest scrollable descendant when possible.
150
158
  - `offset`, `offsetX`, and `offsetY` may be numbers or functions that receive the floating element.
151
159
  - `relativeTo` lets a surface align to an inner element, which is useful for selected options inside popovers.
152
- - `anchor` polls on animation frames for anchor or floating geometry changes and repositions when either changes.
160
+ - `anchor` polls on animation frames for anchor target or floating geometry changes and repositions when either changes.
153
161
  - The returned cleanup function cancels animation-frame polling.
@@ -24,6 +24,15 @@ export type AnchorPlacement =
24
24
 
25
25
  type AnchorOffsetValue = number | ((floating: HTMLElement) => number)
26
26
 
27
+ export interface AnchorPoint {
28
+ height?: number
29
+ width?: number
30
+ x: number
31
+ y: number
32
+ }
33
+
34
+ export type AnchorTarget = HTMLElement | AnchorPoint
35
+
27
36
  export type AnchorOptions = {
28
37
  placement?: ExtendedAnchorPlacement
29
38
  inset?: boolean
@@ -125,6 +134,39 @@ function resolveOffsetValue(offset: AnchorOffsetValue | undefined, floating: HTM
125
134
  return offset ?? 0
126
135
  }
127
136
 
137
+ function isAnchorPoint(value: unknown): value is AnchorPoint {
138
+ if (typeof value !== 'object' || value === null) {
139
+ return false
140
+ }
141
+
142
+ if (!('x' in value) || !('y' in value)) {
143
+ return false
144
+ }
145
+
146
+ let width = 'width' in value ? value.width : undefined
147
+ let height = 'height' in value ? value.height : undefined
148
+
149
+ return (
150
+ typeof value.x === 'number' &&
151
+ typeof value.y === 'number' &&
152
+ (width === undefined || typeof width === 'number') &&
153
+ (height === undefined || typeof height === 'number')
154
+ )
155
+ }
156
+
157
+ function readAnchorRect(anchorTarget: AnchorTarget) {
158
+ if (anchorTarget instanceof HTMLElement) {
159
+ return anchorTarget.getBoundingClientRect()
160
+ }
161
+
162
+ return new DOMRect(
163
+ anchorTarget.x,
164
+ anchorTarget.y,
165
+ anchorTarget.width ?? 0,
166
+ anchorTarget.height ?? 0,
167
+ )
168
+ }
169
+
128
170
  function calculatePosition(
129
171
  placement: ExtendedAnchorPlacement,
130
172
  anchorRect: DOMRect,
@@ -468,7 +510,7 @@ function getFlippedPlacement(
468
510
  placementHeight: number,
469
511
  collisionWidth: number,
470
512
  collisionHeight: number,
471
- anchor: HTMLElement,
513
+ anchorIsAbsolutePositioned: boolean,
472
514
  inset: boolean,
473
515
  offset: number,
474
516
  offsetX: number,
@@ -476,11 +518,9 @@ function getFlippedPlacement(
476
518
  relativeOffsetX?: number | null,
477
519
  relativeOffsetY?: number | null,
478
520
  ) {
479
- let computedStyle = getComputedStyle(anchor)
480
- let isAbsolutePositioned = computedStyle.position !== 'fixed'
481
521
  let hasScroll = window.scrollY > 0
482
522
 
483
- if (placement.startsWith('top') && isAbsolutePositioned && hasScroll) {
523
+ if (placement.startsWith('top') && anchorIsAbsolutePositioned && hasScroll) {
484
524
  if (anchorRect.top - placementHeight < 0) {
485
525
  return placement
486
526
  }
@@ -641,7 +681,7 @@ function solveRelativeAlignmentAxis(options: {
641
681
 
642
682
  export function anchor(
643
683
  floating: HTMLElement,
644
- anchorElement: HTMLElement,
684
+ anchorTarget: AnchorTarget,
645
685
  options: AnchorOptions = {},
646
686
  ): () => void {
647
687
  let lastAnchorRect: DOMRect
@@ -651,8 +691,8 @@ export function anchor(
651
691
  throw new TypeError('anchor() expected a floating HTMLElement')
652
692
  }
653
693
 
654
- if (!(anchorElement instanceof HTMLElement)) {
655
- throw new TypeError('anchor() expected an anchor HTMLElement')
694
+ if (!(anchorTarget instanceof HTMLElement) && !isAnchorPoint(anchorTarget)) {
695
+ throw new TypeError('anchor() expected an anchor HTMLElement or coordinates')
656
696
  }
657
697
 
658
698
  let {
@@ -664,12 +704,13 @@ export function anchor(
664
704
  offsetY: rawOffsetY = 0,
665
705
  } = options
666
706
 
667
- let isFixed =
668
- floating.hasAttribute('popover') || getComputedStyle(anchorElement).position === 'fixed'
707
+ let anchorIsAbsolutePositioned =
708
+ anchorTarget instanceof HTMLElement && getComputedStyle(anchorTarget).position !== 'fixed'
709
+ let isFixed = floating.hasAttribute('popover') || !anchorIsAbsolutePositioned
669
710
  let animationFrameId = 0
670
711
 
671
712
  function updatePosition(
672
- anchorRect = anchorElement.getBoundingClientRect(),
713
+ anchorRect = readAnchorRect(anchorTarget),
673
714
  naturalDimensions = getFloatingDimensions(floating, relativeTo, {
674
715
  ignoreInlineMaxSize: true,
675
716
  }),
@@ -695,7 +736,7 @@ export function anchor(
695
736
  placementHeight,
696
737
  naturalDimensions.width,
697
738
  naturalDimensions.height,
698
- anchorElement,
739
+ anchorIsAbsolutePositioned,
699
740
  inset,
700
741
  offset,
701
742
  offsetX,
@@ -872,7 +913,7 @@ export function anchor(
872
913
  updatePosition()
873
914
 
874
915
  function pollForPositionChanges() {
875
- let currentRect = anchorElement.getBoundingClientRect()
916
+ let currentRect = readAnchorRect(anchorTarget)
876
917
  let currentDimensions = getFloatingDimensions(floating, relativeTo)
877
918
  if (
878
919
  hasRectChanged(currentRect, lastAnchorRect) ||
@@ -3,6 +3,7 @@ import { Breadcrumbs } from '@remix-run/ui/breadcrumbs'
3
3
  /**
4
4
  * @name Breadcrumbs Basic
5
5
  * @description A basic breadcrumb trail linking back through the page hierarchy.
6
+ * @layout center
6
7
  */
7
8
  export default function Example() {
8
9
  return () => (
@@ -3,6 +3,7 @@ import { Breadcrumbs } from '@remix-run/ui/breadcrumbs'
3
3
  /**
4
4
  * @name Breadcrumbs with Separator
5
5
  * @description Pass a custom separator string to override the default chevron icon.
6
+ * @layout center
6
7
  */
7
8
  export default function Example() {
8
9
  return () => (
@@ -5,6 +5,7 @@ import { theme } from '@remix-run/ui/theme'
5
5
  /**
6
6
  * @name Button Component
7
7
  * @description The Button component wraps the low-level style primitives and accepts a tone prop for quick theming.
8
+ * @layout center
8
9
  * @order 2
9
10
  */
10
11
  export default function Example() {
@@ -6,6 +6,7 @@ import { theme } from '@remix-run/ui/theme'
6
6
  /**
7
7
  * @name Basic Button
8
8
  * @description The default button contract supports both ordinary actions and link-shaped navigation.
9
+ * @layout center
9
10
  * @order 1
10
11
  */
11
12
  export default function Example() {
@@ -6,6 +6,7 @@ import { theme } from '@remix-run/ui/theme'
6
6
  /**
7
7
  * @name Button States
8
8
  * @description Buttons support normal, disabled, and busy (loading) states using aria attributes.
9
+ * @layout center
9
10
  * @order 3
10
11
  */
11
12
  export default function Example() {
@@ -5,6 +5,7 @@ import { theme } from '@remix-run/ui/theme'
5
5
  /**
6
6
  * @name Combobox Overview
7
7
  * @description A searchable combobox with keyboard navigation. Try typing airport names or codes like ord, lax, or jfk.
8
+ * @layout center
8
9
  */
9
10
  export default function Example(handle: Handle) {
10
11
  let value: string | null = null
@@ -7,6 +7,7 @@ import { theme } from '@remix-run/ui/theme'
7
7
  /**
8
8
  * @name Listbox Overview
9
9
  * @description A keyboard-navigable listbox with selection, highlighting, and an optional flash-selection animation.
10
+ * @layout center
10
11
  */
11
12
  export default function Example(handle: Handle) {
12
13
  let value: ListboxValue = options[0]!.value