solidjs-motion 0.1.0 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ All notable changes to `solidjs-motion` / `@solidjs-motion/motion` are documente
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.2] — 2026-05-20
9
+
10
+ ### Fixed
11
+
12
+ - **Drag now coexists with `initial` / `animate` / `exit`.** A draggable
13
+ element with an entrance animation (`<motion.X drag initial={{x:-300}}
14
+ animate={{x:0}}>`) used to break in two ways: animate's `x`/`y` never
15
+ reached the DOM, and on pointerdown the element snapped back to the
16
+ initial position. Three linked fixes:
17
+ - Drag's exclusion of `x`/`y` from the gesture-state winners is now
18
+ gated on `active.whileDrag` (pointer-engaged) instead of
19
+ `dragEnabled` (configured), matching motion-react. Animate's `x`/`y`
20
+ flow normally while drag is configured but idle; drag only claims
21
+ them during active interaction.
22
+ - The diff effect's removed-key fallback no longer reverts `x`/`y` to
23
+ `initial` when drag activates and excludes them. Drag *claiming* a
24
+ key is not the same as removing it.
25
+ - `handlePanStart` now syncs the x/y MotionValues to the element's
26
+ current visible translate (parsed from `getComputedStyle` +
27
+ `el.style.transform`) before capturing `dragStart`. motion's
28
+ `animate(el, target)` interpolates style.transform via WAAPI but
29
+ doesn't keep the visualElement's MVs in sync, so the MV would still
30
+ hold the entrance start value when drag began.
31
+
32
+ ## [0.1.1] — 2026-05-20
33
+
34
+ ### Docs
35
+
36
+ - Expanded README with a 12-recipe "Recipes" section covering the full v0.1
37
+ surface: reactive `useMotion`, `<motion.X>` proxy + variant cascade,
38
+ `motion.create` HOC, MotionValues + `createTransform`/`createSpring`,
39
+ `createScroll`/`createInView`/`createTemplate`, `<Presence>` (single +
40
+ list + `mode="wait"`), drag with constraints, `<MotionConfig>` +
41
+ `createReducedMotion`.
42
+
8
43
  ## [0.1.0] — 2026-05-20
9
44
 
10
45
  First public release. Five phases of the port plan land together: the canonical
package/README.md CHANGED
@@ -46,6 +46,297 @@ export function Card() {
46
46
  with motion's initial styles; user refs and motion's ref both fire; the initial style is
47
47
  serialized into SSR HTML so the first paint is flicker-free.
48
48
 
49
+ ## Recipes
50
+
51
+ ### 1. Reactive options
52
+
53
+ Pass a function to `useMotion` to track Solid signals inside the target.
54
+
55
+ ```tsx
56
+ import { useMotion } from "solidjs-motion"
57
+ import { createSignal } from "solid-js"
58
+
59
+ export function Toggle() {
60
+ const [open, setOpen] = createSignal(false)
61
+ const motion = useMotion(() => ({
62
+ animate: { rotate: open() ? 180 : 0 },
63
+ transition: { duration: 0.3 },
64
+ }))
65
+ return (
66
+ <button onClick={() => setOpen((p) => !p)} {...motion()}>
67
+
68
+ </button>
69
+ )
70
+ }
71
+ ```
72
+
73
+ ### 2. `<motion.X>` proxy with variants
74
+
75
+ Every HTML/SVG tag is reachable off `motion`. Variant labels on the parent cascade to
76
+ descendants through `m.Provider` (auto-installed by the proxy).
77
+
78
+ ```tsx
79
+ import { motion } from "solidjs-motion"
80
+
81
+ const variants = {
82
+ rest: { y: 0, scale: 1 },
83
+ lift: { y: -8, scale: 1.04 },
84
+ }
85
+
86
+ export function Card() {
87
+ return (
88
+ <motion.article animate="rest" hover="lift" variants={variants}>
89
+ <motion.h2 variants={variants}>Inherits lift on hover</motion.h2>
90
+ </motion.article>
91
+ )
92
+ }
93
+ ```
94
+
95
+ ### 3. `motion.create(Component)` HOC
96
+
97
+ Wrap a custom component to make it motion-aware. The wrapped component must spread its
98
+ props (including `ref`) onto a single DOM-element root.
99
+
100
+ ```tsx
101
+ import { motion } from "solidjs-motion"
102
+ import type { ComponentProps } from "solid-js"
103
+
104
+ function Button(props: ComponentProps<"button">) {
105
+ return <button {...props} class={`btn ${props.class ?? ""}`} />
106
+ }
107
+
108
+ const MotionButton = motion.create(Button)
109
+
110
+ export function Stage() {
111
+ return (
112
+ <MotionButton hover={{ scale: 1.05 }} press={{ scale: 0.95 }}>
113
+ Press me
114
+ </MotionButton>
115
+ )
116
+ }
117
+ ```
118
+
119
+ ### 4. MotionValues + `createTransform`
120
+
121
+ MotionValues are the source of truth for animated state. They're both Solid Accessors and
122
+ upstream `MotionValue`s — drop them straight into `style`.
123
+
124
+ ```tsx
125
+ import { motion, createMotionValue, createTransform } from "solidjs-motion"
126
+
127
+ export function FadeSlider() {
128
+ const x = createMotionValue(0)
129
+ const opacity = createTransform(x, [-100, 0, 100], [0, 1, 0])
130
+ return (
131
+ <motion.div
132
+ drag="x"
133
+ dragConstraints={{ left: -100, right: 100 }}
134
+ style={{ x, opacity }}
135
+ >
136
+ Drag me
137
+ </motion.div>
138
+ )
139
+ }
140
+ ```
141
+
142
+ ### 5. Spring-smoothed pointer
143
+
144
+ `createSpring` mirrors any numeric input with physics smoothing.
145
+
146
+ ```tsx
147
+ import { motion, createMotionValue, createSpring } from "solidjs-motion"
148
+ import { onCleanup, onMount } from "solid-js"
149
+
150
+ export function Cursor() {
151
+ const x = createMotionValue(0)
152
+ const y = createMotionValue(0)
153
+ const sx = createSpring(x, { stiffness: 200, damping: 30 })
154
+ const sy = createSpring(y, { stiffness: 200, damping: 30 })
155
+
156
+ onMount(() => {
157
+ const move = (e: PointerEvent) => {
158
+ x.set(e.clientX)
159
+ y.set(e.clientY)
160
+ }
161
+ window.addEventListener("pointermove", move)
162
+ onCleanup(() => window.removeEventListener("pointermove", move))
163
+ })
164
+
165
+ return <motion.div class="cursor" style={{ x: sx, y: sy }} />
166
+ }
167
+ ```
168
+
169
+ ### 6. Scroll-linked progress bar
170
+
171
+ ```tsx
172
+ import { motion, createScroll, createTransform } from "solidjs-motion"
173
+
174
+ export function ProgressBar() {
175
+ const { scrollYProgress } = createScroll()
176
+ const width = createTransform(scrollYProgress, [0, 1], ["0%", "100%"])
177
+ return <motion.div class="progress" style={{ width }} />
178
+ }
179
+ ```
180
+
181
+ ### 7. Viewport-triggered fade-in
182
+
183
+ ```tsx
184
+ import { motion } from "solidjs-motion"
185
+
186
+ export function FadeInOnce() {
187
+ return (
188
+ <motion.section
189
+ initial={{ opacity: 0, y: 40 }}
190
+ inView={{ opacity: 1, y: 0 }}
191
+ inViewOptions={{ once: true, margin: "0px 0px -10% 0px" }}
192
+ >
193
+ Comes in once, stays.
194
+ </motion.section>
195
+ )
196
+ }
197
+ ```
198
+
199
+ ### 8. `createTemplate` for interpolated strings
200
+
201
+ Build a `MotionValue<string>` from interpolated MVs/Accessors — feed it to any string-valued
202
+ CSS property.
203
+
204
+ ```tsx
205
+ import { motion, createMotionValue, createTemplate } from "solidjs-motion"
206
+
207
+ export function GradientBox() {
208
+ const angle = createMotionValue(0)
209
+ const background = createTemplate`linear-gradient(${angle}deg, #f0f, #0ff)`
210
+ return (
211
+ <motion.div
212
+ hover={{ rotate: 360 }}
213
+ transition={{ duration: 2 }}
214
+ style={{ background }}
215
+ />
216
+ )
217
+ }
218
+ ```
219
+
220
+ ### 9. `<Presence>` for exit animations
221
+
222
+ Wrap a conditionally-rendered child to animate its exit before unmount.
223
+
224
+ ```tsx
225
+ import { motion, Presence } from "solidjs-motion"
226
+ import { Show, createSignal } from "solid-js"
227
+
228
+ export function Drawer() {
229
+ const [open, setOpen] = createSignal(false)
230
+ return (
231
+ <>
232
+ <button onClick={() => setOpen((p) => !p)}>Toggle</button>
233
+ <Presence>
234
+ <Show when={open()}>
235
+ <motion.aside
236
+ initial={{ x: -300 }}
237
+ animate={{ x: 0 }}
238
+ exit={{ x: -300 }}
239
+ transition={{ duration: 0.25 }}
240
+ >
241
+ Drawer content
242
+ </motion.aside>
243
+ </Show>
244
+ </Presence>
245
+ </>
246
+ )
247
+ }
248
+ ```
249
+
250
+ ### 10. `mode="wait"` + list exits
251
+
252
+ `mode="wait"` plays the outgoing child's `exit` fully before the incoming child enters.
253
+ For lists, `Presence` wraps a `<For>` and animates add/remove together.
254
+
255
+ ```tsx
256
+ import { motion, Presence } from "solidjs-motion"
257
+ import { For, Show, createSignal } from "solid-js"
258
+
259
+ export function Tabs() {
260
+ const [tab, setTab] = createSignal("a")
261
+ return (
262
+ <Presence mode="wait">
263
+ <Show when={tab()} keyed>
264
+ {(t) => (
265
+ <motion.div
266
+ initial={{ opacity: 0, y: 8 }}
267
+ animate={{ opacity: 1, y: 0 }}
268
+ exit={{ opacity: 0, y: -8 }}
269
+ >
270
+ Tab {t}
271
+ </motion.div>
272
+ )}
273
+ </Show>
274
+ </Presence>
275
+ )
276
+ }
277
+
278
+ export function Notifications(props: { items: () => string[] }) {
279
+ return (
280
+ <Presence>
281
+ <For each={props.items()}>
282
+ {(msg) => (
283
+ <motion.li
284
+ initial={{ opacity: 0, x: -16 }}
285
+ animate={{ opacity: 1, x: 0 }}
286
+ exit={{ opacity: 0, x: 16 }}
287
+ >
288
+ {msg}
289
+ </motion.li>
290
+ )}
291
+ </For>
292
+ </Presence>
293
+ )
294
+ }
295
+ ```
296
+
297
+ ### 11. Drag with constraints
298
+
299
+ `dragConstraints` accepts numeric bounds or a parent ref. `dragElastic` controls overshoot.
300
+
301
+ ```tsx
302
+ import { motion } from "solidjs-motion"
303
+
304
+ export function DraggableCard() {
305
+ let bounds!: HTMLDivElement
306
+ return (
307
+ <div ref={bounds} class="bounds">
308
+ <motion.div
309
+ drag
310
+ dragConstraints={bounds}
311
+ dragElastic={0.2}
312
+ whileDrag={{ scale: 1.05 }}
313
+ >
314
+ Drag inside
315
+ </motion.div>
316
+ </div>
317
+ )
318
+ }
319
+ ```
320
+
321
+ ### 12. `<MotionConfig>` + reduced motion
322
+
323
+ `<MotionConfig>` flows defaults (transition, reduced-motion mode, CSP nonce) to descendants.
324
+ `createReducedMotion()` reads the system preference directly.
325
+
326
+ ```tsx
327
+ import { MotionConfig, createReducedMotion, motion } from "solidjs-motion"
328
+
329
+ export function App() {
330
+ const reduced = createReducedMotion()
331
+ return (
332
+ <MotionConfig reducedMotion="user" transition={{ duration: 0.4, ease: "easeOut" }}>
333
+ <motion.div animate={{ x: 100 }}>Honors `prefers-reduced-motion`</motion.div>
334
+ <p>System reduced-motion: {String(reduced())}</p>
335
+ </MotionConfig>
336
+ )
337
+ }
338
+ ```
339
+
49
340
  ## Roadmap
50
341
 
51
342
  ### Shipped
package/dist/index.js CHANGED
@@ -871,6 +871,91 @@ function ensureVisualElement(el) {
871
871
  return ve;
872
872
  }
873
873
  /**
874
+ * Parse the visible translate from an element's transform — reads both
875
+ * `getComputedStyle(el).transform` (which real browsers normalize to
876
+ * matrix form) AND `el.style.transform` (which preserves the raw syntax
877
+ * motion-dom's writer emits, e.g. `translate3d(50px, 0px, 0)`). Used by
878
+ * drag's pan-start to sync the x/y MotionValues to what the user is
879
+ * actually seeing.
880
+ *
881
+ * Why both sources: motion's `animate(el, target)` interpolates style.
882
+ * transform via WAAPI but DOESN'T update the VE's MVs during the tween,
883
+ * so after `initial: {x:-300} → animate: {x:0}` the MV still holds -300.
884
+ * Reading the current transform recovers the truth. We prefer computed
885
+ * (post-animation, post-WAAPI-commit value) and fall back to inline
886
+ * (covers jsdom + cases where motion's writer wrote inline but the
887
+ * browser hasn't run a style-resolve pass yet).
888
+ *
889
+ * Supported syntaxes:
890
+ * - `"none"` / empty → {0, 0}
891
+ * - `matrix(a, b, c, d, tx, ty)`
892
+ * - `matrix3d(..., tx, ty, ...)`
893
+ * - `translateX(Npx)` / `translateY(Npx)` / `translate(tx, ty)`
894
+ * - `translate3d(tx, ty, tz)`
895
+ * - Any of the above mixed with other transform functions (regex-
896
+ * based extraction picks just the translate components).
897
+ */
898
+ function readVisibleTranslate(el) {
899
+ const fromString = (transform) => {
900
+ if (!transform || transform === "none") return null;
901
+ if (transform.startsWith("matrix3d(")) {
902
+ const values = transform.slice(9, -1).split(",");
903
+ const tx = Number.parseFloat(values[12] ?? "0");
904
+ const ty = Number.parseFloat(values[13] ?? "0");
905
+ if (!Number.isFinite(tx) && !Number.isFinite(ty)) return null;
906
+ return {
907
+ x: Number.isFinite(tx) ? tx : 0,
908
+ y: Number.isFinite(ty) ? ty : 0
909
+ };
910
+ }
911
+ if (transform.startsWith("matrix(")) {
912
+ const values = transform.slice(7, -1).split(",");
913
+ const tx = Number.parseFloat(values[4] ?? "0");
914
+ const ty = Number.parseFloat(values[5] ?? "0");
915
+ if (!Number.isFinite(tx) && !Number.isFinite(ty)) return null;
916
+ return {
917
+ x: Number.isFinite(tx) ? tx : 0,
918
+ y: Number.isFinite(ty) ? ty : 0
919
+ };
920
+ }
921
+ let x = 0;
922
+ let y = 0;
923
+ let found = false;
924
+ const translate3d = transform.match(/translate3d\(\s*([-\d.]+)px\s*,\s*([-\d.]+)px/);
925
+ if (translate3d) {
926
+ x = Number.parseFloat(translate3d[1] ?? "0");
927
+ y = Number.parseFloat(translate3d[2] ?? "0");
928
+ found = true;
929
+ } else {
930
+ const translate2d = transform.match(/translate\(\s*([-\d.]+)px\s*(?:,\s*([-\d.]+)px)?/);
931
+ if (translate2d) {
932
+ x = Number.parseFloat(translate2d[1] ?? "0");
933
+ y = Number.parseFloat(translate2d[2] ?? "0");
934
+ found = true;
935
+ }
936
+ const translateX = transform.match(/translateX\(\s*([-\d.]+)px/);
937
+ if (translateX) {
938
+ x = Number.parseFloat(translateX[1] ?? "0");
939
+ found = true;
940
+ }
941
+ const translateY = transform.match(/translateY\(\s*([-\d.]+)px/);
942
+ if (translateY) {
943
+ y = Number.parseFloat(translateY[1] ?? "0");
944
+ found = true;
945
+ }
946
+ }
947
+ if (!found) return null;
948
+ return {
949
+ x: Number.isFinite(x) ? x : 0,
950
+ y: Number.isFinite(y) ? y : 0
951
+ };
952
+ };
953
+ return fromString(getComputedStyle(el).transform) ?? fromString(el.style.transform) ?? {
954
+ x: 0,
955
+ y: 0
956
+ };
957
+ }
958
+ /**
874
959
  * Compute the `touch-action` CSS value for an element being dragged.
875
960
  * Disabling touch-action prevents the browser from interpreting the gesture
876
961
  * as a scroll. Axis-locked drags leave the unused axis available for scroll
@@ -1008,14 +1093,27 @@ function createDrag(el, getOpts, setActive) {
1008
1093
  for (const ctrl of momentumControls) ctrl.stop();
1009
1094
  momentumControls = [];
1010
1095
  }
1011
- const handlePanStart = (event, info) => {
1096
+ const handlePanStart = (event, info, mvIsAuthoritative = false) => {
1012
1097
  if (!isDragEnabled()) return;
1013
1098
  stopMomentum();
1014
1099
  const ve = ensureVisualElement(el);
1015
1100
  xMV = ve.getValue("x", 0);
1016
1101
  yMV = ve.getValue("y", 0);
1017
- dragStartX = xMV.get();
1018
- dragStartY = yMV.get();
1102
+ const axis = getOpts().drag;
1103
+ if (mvIsAuthoritative) {
1104
+ dragStartX = xMV.get();
1105
+ dragStartY = yMV.get();
1106
+ } else {
1107
+ const visible = readVisibleTranslate(el);
1108
+ if (axis !== "y") {
1109
+ if (visible.x !== xMV.get()) xMV.set(visible.x);
1110
+ dragStartX = visible.x;
1111
+ } else dragStartX = xMV.get();
1112
+ if (axis !== "x") {
1113
+ if (visible.y !== yMV.get()) yMV.set(visible.y);
1114
+ dragStartY = visible.y;
1115
+ } else dragStartY = yMV.get();
1116
+ }
1019
1117
  sessionBounds = resolveConstraints(getOpts().dragConstraints, el, dragStartX, dragStartY);
1020
1118
  savedUserSelect = document.body.style.userSelect;
1021
1119
  savedTouchAction = el.style.touchAction;
@@ -1184,7 +1282,7 @@ function createDrag(el, getOpts, setActive) {
1184
1282
  x: 0,
1185
1283
  y: 0
1186
1284
  }
1187
- });
1285
+ }, Boolean(options.snapToCursor));
1188
1286
  const sessionStartPoint = {
1189
1287
  x: event.clientX,
1190
1288
  y: event.clientY
@@ -1493,7 +1591,7 @@ function createGestureStateMachine(deps) {
1493
1591
  });
1494
1592
  const winners = createMemo(() => {
1495
1593
  const targets = stateTargets();
1496
- const dragEnabled = Boolean(getOpts().drag);
1594
+ const dragActive = active.whileDrag;
1497
1595
  const out = {};
1498
1596
  for (const stateName of PRIORITY_HIGH_TO_LOW) {
1499
1597
  if (!isStateActive(stateName, active, parentVariantCtx)) continue;
@@ -1502,7 +1600,7 @@ function createGestureStateMachine(deps) {
1502
1600
  for (const key in target) {
1503
1601
  if (key === "transition") continue;
1504
1602
  if (key in out) continue;
1505
- if (!active.exit && dragEnabled && (key === "x" || key === "y")) continue;
1603
+ if (!active.exit && dragActive && (key === "x" || key === "y")) continue;
1506
1604
  out[key] = {
1507
1605
  value: target[key],
1508
1606
  transition: target.transition,
@@ -1545,6 +1643,7 @@ function createGestureStateMachine(deps) {
1545
1643
  }
1546
1644
  for (const key in lastApplied) {
1547
1645
  if (key in next) continue;
1646
+ if (active.whileDrag && (key === "x" || key === "y")) continue;
1548
1647
  const initialValue = initialTarget && key in initialTarget ? initialTarget[key] : void 0;
1549
1648
  changes[key] = initialValue !== void 0 ? initialValue : getMotionDefault(key);
1550
1649
  }