@humanspeak/svelte-motion 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -74,14 +74,16 @@ This package carefully selects its dependencies to provide a robust and maintain
74
74
 
75
75
  ### Examples
76
76
 
77
- | Motion | Demo / Route | REPL |
78
- | -------------------------------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
79
- | [React - Enter Animation](https://examples.motion.dev/react/enter-animation) | `/tests/motion/enter-animation` | [View Example](https://svelte.dev/playground/7f60c347729f4ea48b1a4590c9dedc02?version=5.38.10) |
80
- | [HTML Content (0→100 counter)](https://examples.motion.dev/react/html-content) | `/tests/motion/html-content` | [View Example](https://svelte.dev/playground/31cd72df4a3242b4b4589501a25e774f?version=5.38.10) |
81
- | [Aspect Ratio](https://examples.motion.dev/react/aspect-ratio) | `/tests/motion/aspect-ratio` | [View Example](https://svelte.dev/playground/1bf60e745fae44f5becb4c830fde9b6e?version=5.38.10) |
82
- | [Random - Shiny Button](https://www.youtube.com/watch?v=jcpLprT5F0I) by [@verse\_](https://x.com/verse_) | `/tests/random/shiny-button` | [View Example](https://svelte.dev/playground/96f9e0bf624f4396adaf06c519147450?version=5.38.10) |
83
- | [Fancy Like Button](https://github.com/DRlFTER/fancyLikeButton) | `/tests/random/fancy-like-button` | [View Example](https://svelte.dev/playground/c34b7e53d41c48b0ab1eaf21ca120c6e?version=5.38.10) |
84
- | [Keyframes (square → circle → square; scale 1→2→1)](https://motion.dev/docs/react-animation#keyframes) | `/tests/motion/keyframes` | [View Example](https://svelte.dev/playground/05595ce0db124c1cbbe4e74fda68d717?version=5.38.10) |
77
+ | Motion | Demo / Route | REPL |
78
+ | -------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------- |
79
+ | [React - Enter Animation](https://examples.motion.dev/react/enter-animation) | `/tests/motion/enter-animation` | [View Example](https://svelte.dev/playground/7f60c347729f4ea48b1a4590c9dedc02?version=5.38.10) |
80
+ | [HTML Content (0→100 counter)](https://examples.motion.dev/react/html-content) | `/tests/motion/html-content` | [View Example](https://svelte.dev/playground/31cd72df4a3242b4b4589501a25e774f?version=5.38.10) |
81
+ | [Aspect Ratio](https://examples.motion.dev/react/aspect-ratio) | `/tests/motion/aspect-ratio` | [View Example](https://svelte.dev/playground/1bf60e745fae44f5becb4c830fde9b6e?version=5.38.10) |
82
+ | [Hover + Tap (whileHover + whileTap)](https://motion.dev/docs/react?platform=react#hover-tap-animation) | `/tests/motion/hover-and-tap` | [View Example](https://svelte.dev/playground/674c7d58f2c740baa4886b01340a97ea?version=5.38.10) |
83
+ | [Random - Shiny Button](https://www.youtube.com/watch?v=jcpLprT5F0I) by [@verse\_](https://x.com/verse_) | `/tests/random/shiny-button` | [View Example](https://svelte.dev/playground/96f9e0bf624f4396adaf06c519147450?version=5.38.10) |
84
+ | [Fancy Like Button](https://github.com/DRlFTER/fancyLikeButton) | `/tests/random/fancy-like-button` | [View Example](https://svelte.dev/playground/c34b7e53d41c48b0ab1eaf21ca120c6e?version=5.38.10) |
85
+ | [Keyframes (square → circle → square; scale 1→2→1)](https://motion.dev/docs/react-animation#keyframes) | `/tests/motion/keyframes` | [View Example](https://svelte.dev/playground/05595ce0db124c1cbbe4e74fda68d717?version=5.38.10) |
86
+ | [Animated Border Gradient (conic-gradient rotate)](https://www.youtube.com/watch?v=OgQI1-9T6ZA) | `/tests/random/animated-border-gradient` | [View Example](https://svelte.dev/playground/6983a61b4c35441b8aa72a971de01a23?version=5.38.10) |
85
87
 
86
88
  ## Interactions
87
89
 
@@ -111,6 +113,13 @@ Svelte Motion now supports hover interactions via the `whileHover` prop, similar
111
113
  <motion.button whileTap={{ scale: 0.95 }} />
112
114
  ```
113
115
 
116
+ - Callbacks: `onTapStart`, `onTap`, `onTapCancel` are supported.
117
+ - Accessibility: Elements with `whileTap` are keyboard-accessible (Enter and Space).
118
+ - Enter or Space down → fires `onTapStart` and applies `whileTap` (Space prevents default scrolling)
119
+ - Enter or Space up → fires `onTap`
120
+ - Blur while key is held → fires `onTapCancel`
121
+ - `MotionContainer` sets `tabindex="0"` automatically when `whileTap` is present and no `tabindex`/`tabIndex` is provided.
122
+
114
123
  ### Animation lifecycle
115
124
 
116
125
  ```svelte
@@ -141,6 +150,91 @@ Notes:
141
150
  - Transform properties like `scale`/`rotate` are composed into a single `transform` style during SSR.
142
151
  - When `initial` is empty, the first keyframe from `animate` is used to seed SSR styles.
143
152
 
153
+ ## Utilities
154
+
155
+ ### useTime(id?)
156
+
157
+ - Returns a Svelte readable store that updates once per animation frame with elapsed milliseconds since creation.
158
+ - If you pass an `id`, calls with the same id return a shared timeline (kept in sync across components).
159
+ - SSR-safe: Returns a static `0` store when `window` is not available.
160
+
161
+ ```svelte
162
+ <script lang="ts">
163
+ import { motion, useTime } from '$lib'
164
+ import { derived } from 'svelte/store'
165
+
166
+ const time = useTime('global') // shared
167
+ const rotate = derived(time, (t) => ((t % 4000) / 4000) * 360)
168
+ </script>
169
+
170
+ <motion.div style={`rotate: ${$rotate}deg`} />
171
+ ```
172
+
173
+ ### useSpring
174
+
175
+ `useSpring` creates a readable store that animates to its latest target with a spring. You can either control it directly with `set`/`jump`, or have it follow another readable (like a time-derived value).
176
+
177
+ ```svelte
178
+ <script lang="ts">
179
+ import { useTime, useTransform, useSpring } from '$lib'
180
+
181
+ // Track another readable
182
+ const time = useTime()
183
+ const blurTarget = useTransform(() => {
184
+ const phase = ($time % 2000) / 2000
185
+ return 4 * (0.5 + 0.5 * Math.sin(phase * Math.PI * 2)) // 0..4
186
+ }, [time])
187
+ const blur = useSpring(blurTarget, { stiffness: 300 })
188
+
189
+ // Or direct control
190
+ const x = useSpring(0, { stiffness: 300 })
191
+ // x.set(100) // animates to 100
192
+ // x.jump(0) // jumps without animation
193
+ </script>
194
+
195
+ <div style={`filter: blur(${$blur}px)`} />
196
+ ```
197
+
198
+ - Accepts number or unit string (e.g., `"100vh"`) or a readable source.
199
+ - Returns a readable with `{ set, jump }` methods when used in the browser; SSR-safe on the server.
200
+ - Reference: Motion useSpring docs [motion.dev](https://motion.dev/docs/react-use-spring?platform=react).
201
+
202
+ ### useTransform
203
+
204
+ `useTransform` creates a derived readable. It supports:
205
+
206
+ - Range mapping: map a numeric source across input/output ranges with optional `{ clamp, ease, mixer }`.
207
+ - Function form: compute from one or more dependencies.
208
+
209
+ Range mapping example:
210
+
211
+ ```svelte
212
+ <script lang="ts">
213
+ import { useTime, useTransform } from '$lib'
214
+ const time = useTime()
215
+ // Map 0..4000ms to 0..360deg, unclamped to allow wrap-around
216
+ const rotate = useTransform(time, [0, 4000], [0, 360], { clamp: false })
217
+ </script>
218
+
219
+ <div style={`rotate: ${$rotate}deg`} />
220
+ ```
221
+
222
+ Function form example:
223
+
224
+ ```svelte
225
+ <script lang="ts">
226
+ import { useTransform } from '$lib'
227
+ // Given stores a and b, compute their sum
228
+ const add = (a: number, b: number) => a + b
229
+ // deps are stores; body can access them via $ syntax
230
+ const total = useTransform(() => add($a, $b), [a, b])
231
+ </script>
232
+
233
+ <span>{$total}</span>
234
+ ```
235
+
236
+ - Reference: Motion useTransform docs [motion.dev](https://motion.dev/docs/react-use-transform?platform=react).
237
+
144
238
  ## Access the underlying element (bind:ref)
145
239
 
146
240
  You can bind a ref to access the underlying DOM element rendered by a motion component:
@@ -3,7 +3,7 @@
3
3
  import type { MotionProps, MotionTransition } from '../types.js'
4
4
  import { isNotEmpty } from '../utils/objects.js'
5
5
  import { sleep } from '../utils/testing.js'
6
- import { animate } from 'motion'
6
+ import { animate, type AnimationOptions, type DOMKeyframesDefinition } from 'motion'
7
7
  import { type Snippet } from 'svelte'
8
8
  import { VOID_TAGS } from '../utils/constants.js'
9
9
  import { mergeTransitions, animateWithLifecycle } from '../utils/animation.js'
@@ -18,6 +18,7 @@
18
18
  } from '../utils/layout.js'
19
19
  import type { SvelteHTMLElements } from 'svelte/elements'
20
20
  import { mergeInlineStyles } from '../utils/style.js'
21
+ import { isNativelyFocusable } from '../utils/a11y.js'
21
22
 
22
23
  type Props = MotionProps & {
23
24
  children?: Snippet
@@ -37,10 +38,13 @@
37
38
  class: classProp,
38
39
  whileTap: whileTapProp,
39
40
  whileHover: whileHoverProp,
40
- ref: element = $bindable(null),
41
41
  onHoverStart: onHoverStartProp,
42
42
  onHoverEnd: onHoverEndProp,
43
+ onTapStart: onTapStartProp,
44
+ onTap: onTapProp,
45
+ onTapCancel: onTapCancelProp,
43
46
  layout: layoutProp,
47
+ ref: element = $bindable(null),
44
48
  ...rest
45
49
  }: Props = $props()
46
50
  let isLoaded = $state<'mounting' | 'initial' | 'ready' | 'animated'>('mounting')
@@ -54,26 +58,45 @@
54
58
  const isVoidTag = $derived(VOID_TAGS.has(tag as string))
55
59
 
56
60
  // Compute merged transition without mutating props to avoid effect write loops
57
- let mergedTransition = $derived<MotionTransition>(
61
+ const mergedTransition = $derived<MotionTransition>(
58
62
  mergeTransitions(motionConfig?.transition, transitionProp)
59
63
  )
60
64
 
65
+ // Derived attributes to keep both branches in sync (focusability, data flags, style, class)
66
+ const derivedAttrs = $derived<Record<string, unknown>>({
67
+ ...(rest as Record<string, unknown>),
68
+ ...(whileTapProp &&
69
+ !isNativelyFocusable(tag, rest as Record<string, unknown>) &&
70
+ ((rest as Record<string, unknown>)?.tabindex ??
71
+ (rest as Record<string, unknown>)?.tabIndex ??
72
+ undefined) === undefined
73
+ ? { tabindex: 0 }
74
+ : {}),
75
+ ...(isPlaywright
76
+ ? {
77
+ 'data-playwright': isPlaywright,
78
+ 'data-is-loaded': isLoaded,
79
+ 'data-path': dataPath
80
+ }
81
+ : {}),
82
+ style: mergeInlineStyles(
83
+ styleProp,
84
+ initialProp as unknown as Record<string, unknown>,
85
+ animateProp as unknown as Record<string, unknown>
86
+ ),
87
+ class: classProp
88
+ })
89
+
61
90
  const runAnimation = () => {
62
91
  if (!element || !animateProp) return
63
- const transitionAmimate: MotionTransition = mergedTransition ?? {}
92
+ const transitionAnimate: MotionTransition = mergedTransition ?? {}
64
93
  const payload = $state.snapshot(animateProp)
65
94
  animateWithLifecycle(
66
95
  element,
67
- payload as unknown as import('motion').DOMKeyframesDefinition,
68
- transitionAmimate as unknown as import('motion').AnimationOptions,
69
- (def) =>
70
- onAnimationStartProp?.(
71
- def as unknown as import('motion').DOMKeyframesDefinition | undefined
72
- ),
73
- (def) =>
74
- onAnimationCompleteProp?.(
75
- def as unknown as import('motion').DOMKeyframesDefinition | undefined
76
- )
96
+ payload as unknown as DOMKeyframesDefinition,
97
+ transitionAnimate as unknown as AnimationOptions,
98
+ (def) => onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
99
+ (def) => onAnimationCompleteProp?.(def as unknown as DOMKeyframesDefinition | undefined)
77
100
  )
78
101
  }
79
102
 
@@ -97,11 +120,7 @@
97
120
  }
98
121
  const next = measureRect(element!)
99
122
  const transforms = computeFlipTransforms(lastRect, next, layoutProp ?? false)
100
- runFlipAnimation(
101
- element!,
102
- transforms,
103
- (mergedTransition ?? {}) as import('motion').AnimationOptions
104
- )
123
+ runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
105
124
  lastRect = next
106
125
  }
107
126
 
@@ -134,7 +153,14 @@
134
153
  element!,
135
154
  (whileTapProp ?? {}) as Record<string, unknown>,
136
155
  (initialProp ?? {}) as Record<string, unknown>,
137
- (animateProp ?? {}) as Record<string, unknown>
156
+ (animateProp ?? {}) as Record<string, unknown>,
157
+ {
158
+ onTapStart: onTapStartProp,
159
+ onTap: onTapProp,
160
+ onTapCancel: onTapCancelProp,
161
+ hoverDef: (whileHoverProp ?? {}) as Record<string, unknown>,
162
+ hoverFallbackTransition: (mergedTransition ?? {}) as AnimationOptions
163
+ }
138
164
  )
139
165
  })
140
166
 
@@ -144,7 +170,7 @@
144
170
  return attachWhileHover(
145
171
  element!,
146
172
  (whileHoverProp ?? {}) as Record<string, unknown>,
147
- (mergedTransition ?? {}) as import('motion').AnimationOptions,
173
+ (mergedTransition ?? {}) as AnimationOptions,
148
174
  { onStart: onHoverStartProp, onEnd: onHoverEndProp },
149
175
  undefined,
150
176
  {
@@ -202,35 +228,9 @@
202
228
  </script>
203
229
 
204
230
  {#if isVoidTag}
205
- <svelte:element
206
- this={tag}
207
- bind:this={element}
208
- {...rest}
209
- data-playwright={isPlaywright ? isPlaywright : undefined}
210
- data-is-loaded={isPlaywright ? isLoaded : undefined}
211
- data-path={isPlaywright ? dataPath : undefined}
212
- style={mergeInlineStyles(
213
- styleProp,
214
- initialProp as unknown as Record<string, unknown>,
215
- animateProp as unknown as Record<string, unknown>
216
- )}
217
- class={classProp}
218
- />
231
+ <svelte:element this={tag} bind:this={element} {...derivedAttrs} />
219
232
  {:else}
220
- <svelte:element
221
- this={tag}
222
- bind:this={element}
223
- {...rest}
224
- data-playwright={isPlaywright ? isPlaywright : undefined}
225
- data-is-loaded={isPlaywright ? isLoaded : undefined}
226
- data-path={isPlaywright ? dataPath : undefined}
227
- style={mergeInlineStyles(
228
- styleProp,
229
- initialProp as unknown as Record<string, unknown>,
230
- animateProp as unknown as Record<string, unknown>
231
- )}
232
- class={classProp}
233
- >
233
+ <svelte:element this={tag} bind:this={element} {...derivedAttrs}>
234
234
  {#if isLoaded === 'ready'}
235
235
  {@render children?.()}
236
236
  {/if}
package/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import MotionConfig from './components/MotionConfig.svelte';
2
- import type { MotionComponents } from './html/index.js';
2
+ import type { MotionComponents } from './html/index';
3
3
  export declare const motion: MotionComponents;
4
4
  export { animate, hover } from 'motion';
5
- export type { MotionAnimate, MotionInitial, MotionTransition, MotionWhileTap } from './types.js';
5
+ export type { MotionAnimate, MotionInitial, MotionTransition, MotionWhileTap } from './types';
6
+ export { useSpring } from './utils/spring';
7
+ export { useTime } from './utils/time';
8
+ export { useTransform } from './utils/transform';
6
9
  export { MotionConfig };
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import MotionConfig from './components/MotionConfig.svelte';
2
- import * as html from './html/index.js';
2
+ import * as html from './html/index';
3
3
  // Create the motion object with all components
4
4
  export const motion = Object.fromEntries(Object.entries(html).map(([key, component]) => [key.toLowerCase(), component]));
5
5
  // Export all types
6
6
  export { animate, hover } from 'motion';
7
+ export { useSpring } from './utils/spring';
8
+ export { useTime } from './utils/time';
9
+ export { useTransform } from './utils/transform';
7
10
  export { MotionConfig };
package/dist/types.d.ts CHANGED
@@ -58,11 +58,15 @@ export type MotionWhileHover = (Record<string, unknown> & {
58
58
  /**
59
59
  * Animation lifecycle callbacks for motion components.
60
60
  */
61
- export type MotionAnimationStart = ((definition: DOMKeyframesDefinition | undefined) => void) | undefined;
62
- export type MotionAnimationComplete = ((definition: DOMKeyframesDefinition | undefined) => void) | undefined;
61
+ export type MotionAnimationStart = ((_definition: DOMKeyframesDefinition | undefined) => void) | undefined;
62
+ export type MotionAnimationComplete = ((_definition: DOMKeyframesDefinition | undefined) => void) | undefined;
63
63
  /** Hover lifecycle callbacks */
64
64
  export type MotionOnHoverStart = (() => void) | undefined;
65
65
  export type MotionOnHoverEnd = (() => void) | undefined;
66
+ /** Tap lifecycle callbacks */
67
+ export type MotionOnTapStart = (() => void) | undefined;
68
+ export type MotionOnTap = (() => void) | undefined;
69
+ export type MotionOnTapCancel = (() => void) | undefined;
66
70
  /**
67
71
  * Base motion props shared by all motion components.
68
72
  */
@@ -85,6 +89,12 @@ export type MotionProps = {
85
89
  onHoverStart?: MotionOnHoverStart;
86
90
  /** Called when a true hover gesture ends */
87
91
  onHoverEnd?: MotionOnHoverEnd;
92
+ /** Called when a tap gesture starts (pointerdown recognized) */
93
+ onTapStart?: MotionOnTapStart;
94
+ /** Called when a tap gesture ends successfully (pointerup) */
95
+ onTap?: MotionOnTap;
96
+ /** Called when a tap gesture is cancelled (pointercancel) */
97
+ onTapCancel?: MotionOnTapCancel;
88
98
  /** Inline styles */
89
99
  style?: string;
90
100
  /** CSS classes */
@@ -0,0 +1,2 @@
1
+ import type { SvelteHTMLElements } from 'svelte/elements';
2
+ export declare const isNativelyFocusable: (tag: keyof SvelteHTMLElements, attrs?: Record<string, unknown>) => boolean;
@@ -0,0 +1,20 @@
1
+ export const isNativelyFocusable = (tag, attrs = {}) => {
2
+ if (attrs.tabindex != null)
3
+ return true;
4
+ if (attrs.tabIndex != null)
5
+ return true;
6
+ if (attrs.contenteditable != null)
7
+ return true;
8
+ switch (tag) {
9
+ case 'a':
10
+ return Boolean(attrs.href);
11
+ case 'button':
12
+ case 'input':
13
+ case 'select':
14
+ case 'textarea':
15
+ case 'summary':
16
+ return true;
17
+ default:
18
+ return false;
19
+ }
20
+ };
@@ -1,4 +1,4 @@
1
- import type { AnimationOptions, DOMKeyframesDefinition } from 'motion';
1
+ import { type AnimationOptions, type DOMKeyframesDefinition } from 'motion';
2
2
  /**
3
3
  * Merge two Motion `AnimationOptions` objects without mutating the inputs.
4
4
  *
@@ -1,5 +1,5 @@
1
+ import { hasFinishedPromise, isPromiseLike } from './promise';
1
2
  import { animate } from 'motion';
2
- import { hasFinishedPromise, isPromiseLike } from './promise.js';
3
3
  /**
4
4
  * Merge two Motion `AnimationOptions` objects without mutating the inputs.
5
5
  *
@@ -1,4 +1,4 @@
1
- import type { AnimationOptions } from 'motion';
1
+ import { type AnimationOptions } from 'motion';
2
2
  /**
3
3
  * Determine whether the current environment supports true hover.
4
4
  *
@@ -1,3 +1,4 @@
1
+ import type { AnimationOptions } from 'motion';
1
2
  /**
2
3
  * Build a reset record for whileTap on pointerup.
3
4
  *
@@ -24,4 +25,10 @@ export declare const buildTapResetRecord: (initial: Record<string, unknown>, ani
24
25
  * @param animateDef Animate keyframe record.
25
26
  * @return Cleanup function to remove listeners.
26
27
  */
27
- export declare const attachWhileTap: (el: HTMLElement, whileTap: Record<string, unknown> | undefined, initial?: Record<string, unknown>, animateDef?: Record<string, unknown>) => (() => void);
28
+ export declare const attachWhileTap: (el: HTMLElement, whileTap: Record<string, unknown> | undefined, initial?: Record<string, unknown>, animateDef?: Record<string, unknown>, callbacks?: {
29
+ onTapStart?: () => void;
30
+ onTap?: () => void;
31
+ onTapCancel?: () => void;
32
+ hoverDef?: Record<string, unknown> | undefined;
33
+ hoverFallbackTransition?: AnimationOptions | undefined;
34
+ }) => (() => void);
@@ -1,3 +1,4 @@
1
+ import { isHoverCapable, splitHoverDefinition } from './hover';
1
2
  import { animate } from 'motion';
2
3
  /**
3
4
  * Build a reset record for whileTap on pointerup.
@@ -38,15 +39,139 @@ export const buildTapResetRecord = (initial, animateDef, whileTap) => {
38
39
  * @param animateDef Animate keyframe record.
39
40
  * @return Cleanup function to remove listeners.
40
41
  */
41
- export const attachWhileTap = (el, whileTap, initial, animateDef) => {
42
+ export const attachWhileTap = (el, whileTap, initial, animateDef, callbacks) => {
42
43
  if (!whileTap)
43
44
  return () => { };
44
- const handlePointerDown = () => {
45
+ let keyboardActive = false;
46
+ let activePointerId = null;
47
+ const handlePointerDown = (event) => {
48
+ // Capture pointer so we receive up/cancel even if pointer leaves the element
49
+ if (typeof event.pointerId === 'number') {
50
+ try {
51
+ if ('setPointerCapture' in el) {
52
+ el.setPointerCapture(event.pointerId);
53
+ }
54
+ }
55
+ catch {
56
+ // noop if not supported
57
+ }
58
+ activePointerId = event.pointerId;
59
+ // Attach global listeners to catch off-element releases (even if capture unsupported)
60
+ window.addEventListener('pointerup', handlePointerUp);
61
+ window.addEventListener('pointercancel', handlePointerCancel);
62
+ document.addEventListener('pointerup', handlePointerUp);
63
+ document.addEventListener('pointercancel', handlePointerCancel);
64
+ }
65
+ callbacks?.onTapStart?.();
45
66
  animate(el, whileTap);
46
67
  };
47
- const handlePointerUp = () => {
68
+ const reapplyHoverIfActive = () => {
69
+ if (!callbacks?.hoverDef)
70
+ return false;
71
+ if (!isHoverCapable())
72
+ return false;
73
+ try {
74
+ if (!el.matches(':hover'))
75
+ return false;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ const { keyframes, transition } = splitHoverDefinition(callbacks.hoverDef);
81
+ animate(el, keyframes, (transition ?? callbacks.hoverFallbackTransition));
82
+ return true;
83
+ };
84
+ const handlePointerUp = (event) => {
85
+ if (typeof event.pointerId === 'number' && activePointerId !== null) {
86
+ if (event.pointerId !== activePointerId)
87
+ return;
88
+ try {
89
+ if ('releasePointerCapture' in el)
90
+ el.releasePointerCapture(event.pointerId);
91
+ }
92
+ catch {
93
+ // noop
94
+ }
95
+ activePointerId = null;
96
+ window.removeEventListener('pointerup', handlePointerUp);
97
+ window.removeEventListener('pointercancel', handlePointerCancel);
98
+ document.removeEventListener('pointerup', handlePointerUp);
99
+ document.removeEventListener('pointercancel', handlePointerCancel);
100
+ }
101
+ callbacks?.onTap?.();
48
102
  if (!whileTap)
49
103
  return;
104
+ if (reapplyHoverIfActive())
105
+ return;
106
+ if (initial || animateDef) {
107
+ const resetRecord = buildTapResetRecord(initial ?? {}, animateDef ?? {}, whileTap ?? {});
108
+ if (Object.keys(resetRecord).length > 0) {
109
+ animate(el, resetRecord);
110
+ }
111
+ }
112
+ };
113
+ const handlePointerCancel = (event) => {
114
+ if (typeof event.pointerId === 'number' && activePointerId !== null) {
115
+ if (event.pointerId !== activePointerId)
116
+ return;
117
+ try {
118
+ if ('releasePointerCapture' in el)
119
+ el.releasePointerCapture(event.pointerId);
120
+ }
121
+ catch {
122
+ // noop
123
+ }
124
+ activePointerId = null;
125
+ window.removeEventListener('pointerup', handlePointerUp);
126
+ window.removeEventListener('pointercancel', handlePointerCancel);
127
+ document.removeEventListener('pointerup', handlePointerUp);
128
+ document.removeEventListener('pointercancel', handlePointerCancel);
129
+ }
130
+ callbacks?.onTapCancel?.();
131
+ // On cancel, also restore baseline if available
132
+ if (initial || animateDef) {
133
+ const resetRecord = buildTapResetRecord(initial ?? {}, animateDef ?? {}, whileTap ?? {});
134
+ if (Object.keys(resetRecord).length > 0) {
135
+ animate(el, resetRecord);
136
+ }
137
+ }
138
+ };
139
+ const handleKeyDown = (e) => {
140
+ if (!(e.key === 'Enter' || e.key === ' ' || e.key === 'Space'))
141
+ return;
142
+ // Prevent page scroll/activation for Space
143
+ if (e.key === ' ' || e.key === 'Space')
144
+ e.preventDefault?.();
145
+ if (keyboardActive)
146
+ return;
147
+ keyboardActive = true;
148
+ callbacks?.onTapStart?.();
149
+ animate(el, whileTap);
150
+ };
151
+ const handleKeyUp = (e) => {
152
+ if (!(e.key === 'Enter' || e.key === ' ' || e.key === 'Space'))
153
+ return;
154
+ // Prevent page scroll/activation for Space
155
+ if (e.key === ' ' || e.key === 'Space')
156
+ e.preventDefault?.();
157
+ if (!keyboardActive)
158
+ return;
159
+ keyboardActive = false;
160
+ callbacks?.onTap?.();
161
+ if (reapplyHoverIfActive())
162
+ return;
163
+ if (initial || animateDef) {
164
+ const resetRecord = buildTapResetRecord(initial ?? {}, animateDef ?? {}, whileTap ?? {});
165
+ if (Object.keys(resetRecord).length > 0) {
166
+ animate(el, resetRecord);
167
+ }
168
+ }
169
+ };
170
+ const handleBlur = () => {
171
+ if (!keyboardActive)
172
+ return;
173
+ keyboardActive = false;
174
+ callbacks?.onTapCancel?.();
50
175
  if (initial || animateDef) {
51
176
  const resetRecord = buildTapResetRecord(initial ?? {}, animateDef ?? {}, whileTap ?? {});
52
177
  if (Object.keys(resetRecord).length > 0) {
@@ -56,10 +181,20 @@ export const attachWhileTap = (el, whileTap, initial, animateDef) => {
56
181
  };
57
182
  el.addEventListener('pointerdown', handlePointerDown);
58
183
  el.addEventListener('pointerup', handlePointerUp);
59
- el.addEventListener('pointercancel', handlePointerUp);
184
+ el.addEventListener('pointercancel', handlePointerCancel);
185
+ el.addEventListener('keydown', handleKeyDown);
186
+ el.addEventListener('keyup', handleKeyUp);
187
+ el.addEventListener('blur', handleBlur);
60
188
  return () => {
61
189
  el.removeEventListener('pointerdown', handlePointerDown);
62
190
  el.removeEventListener('pointerup', handlePointerUp);
63
- el.removeEventListener('pointercancel', handlePointerUp);
191
+ el.removeEventListener('pointercancel', handlePointerCancel);
192
+ window.removeEventListener('pointerup', handlePointerUp);
193
+ window.removeEventListener('pointercancel', handlePointerCancel);
194
+ document.removeEventListener('pointerup', handlePointerUp);
195
+ document.removeEventListener('pointercancel', handlePointerCancel);
196
+ el.removeEventListener('keydown', handleKeyDown);
197
+ el.removeEventListener('keyup', handleKeyUp);
198
+ el.removeEventListener('blur', handleBlur);
64
199
  };
65
200
  };
@@ -0,0 +1,38 @@
1
+ import { type Readable } from 'svelte/store';
2
+ /**
3
+ * Spring configuration options.
4
+ *
5
+ * This is a minimal subset modeled after Motion's spring transition options.
6
+ * Values are tuned for sensible defaults, not parity.
7
+ *
8
+ * @typedef {Object} SpringOptions
9
+ * @property {number=} stiffness Spring stiffness (higher = snappier). Default 170.
10
+ * @property {number=} damping Spring damping (higher = less oscillation). Default 26.
11
+ * @property {number=} mass Mass of the object. Default 1.
12
+ * @property {number=} restDelta Threshold for absolute position delta to stop. Default 0.01.
13
+ * @property {number=} restSpeed Threshold for velocity magnitude to stop. Default 0.01.
14
+ */
15
+ export type SpringOptions = {
16
+ stiffness?: number;
17
+ damping?: number;
18
+ mass?: number;
19
+ restDelta?: number;
20
+ restSpeed?: number;
21
+ };
22
+ /**
23
+ * Creates a spring-animated readable store. The store exposes `set` to
24
+ * animate towards a target, or `jump` to immediately set the value without
25
+ * animation. When constructed with another readable store, the spring
26
+ * automatically follows it.
27
+ *
28
+ * This is SSR-safe: On the server it returns a static store and no timers run.
29
+ *
30
+ * @template T
31
+ * @param {number|string|Readable<number|string>} source Initial value or a source store to follow.
32
+ * @param {SpringOptions=} options Spring configuration.
33
+ * @returns {Readable<number|string> & { set: (v: number|string) => void; jump: (v: number|string) => void; }}
34
+ */
35
+ export declare const useSpring: (source: number | string | Readable<number | string>, options?: SpringOptions) => Readable<number | string> & {
36
+ set: (v: number | string) => void;
37
+ jump: (v: number | string) => void;
38
+ };
@@ -0,0 +1,157 @@
1
+ import { readable, writable } from 'svelte/store';
2
+ /**
3
+ * Parses a number or unit string into numeric value and unit.
4
+ * @param {number|string} v The input value.
5
+ * @returns {UnitValue} Parsed value and unit.
6
+ * @private
7
+ */
8
+ const parseUnit = (v) => {
9
+ if (typeof v === 'number')
10
+ return { value: v, unit: '' };
11
+ const match = String(v).match(/^(-?\d*\.?\d+)(.*)$/);
12
+ if (!match || !match[1])
13
+ return { value: 0, unit: '' };
14
+ const parsed = Number.parseFloat(match[1]);
15
+ if (!Number.isFinite(parsed))
16
+ return { value: 0, unit: '' };
17
+ const unit = match[2] ?? '';
18
+ return { value: parsed, unit };
19
+ };
20
+ /**
21
+ * Formats a numeric value with a unit.
22
+ * @param {number} n Numeric value.
23
+ * @param {string} unit Unit suffix.
24
+ * @returns {number|string} Number or string with unit.
25
+ * @private
26
+ */
27
+ const formatUnit = (n, unit) => (unit ? `${n}${unit}` : n);
28
+ /**
29
+ * Creates a spring-animated readable store. The store exposes `set` to
30
+ * animate towards a target, or `jump` to immediately set the value without
31
+ * animation. When constructed with another readable store, the spring
32
+ * automatically follows it.
33
+ *
34
+ * This is SSR-safe: On the server it returns a static store and no timers run.
35
+ *
36
+ * @template T
37
+ * @param {number|string|Readable<number|string>} source Initial value or a source store to follow.
38
+ * @param {SpringOptions=} options Spring configuration.
39
+ * @returns {Readable<number|string> & { set: (v: number|string) => void; jump: (v: number|string) => void; }}
40
+ */
41
+ export const useSpring = (source, options = {}) => {
42
+ if (typeof window === 'undefined') {
43
+ // Derive best-effort initial value for SSR to avoid hydration mismatch
44
+ let initial = 0;
45
+ if (typeof source === 'number' || typeof source === 'string') {
46
+ initial = source;
47
+ }
48
+ else if (source && typeof source === 'object') {
49
+ const anySource = source;
50
+ if (typeof anySource.get === 'function') {
51
+ const v = anySource.get();
52
+ if (typeof v === 'number' || typeof v === 'string')
53
+ initial = v;
54
+ }
55
+ else if (typeof anySource.value === 'number' || typeof anySource.value === 'string') {
56
+ initial = anySource.value;
57
+ }
58
+ }
59
+ const store = readable(initial, () => { });
60
+ store.set = () => { };
61
+ store.jump = () => { };
62
+ return store;
63
+ }
64
+ const { stiffness = 170, damping = 26, mass = 1, restDelta = 0.01, restSpeed = 0.01 } = options;
65
+ const state = {
66
+ current: parseUnit(typeof source === 'object' ? 0 : source),
67
+ target: parseUnit(typeof source === 'object' ? 0 : source)
68
+ };
69
+ const unit = state.current.unit || state.target.unit;
70
+ const store = writable(formatUnit(state.current.value, unit));
71
+ let raf = 0;
72
+ let lastTime = 0;
73
+ let velocity = 0;
74
+ const step = (t) => {
75
+ if (!lastTime)
76
+ lastTime = t;
77
+ // Clamp dt to a safe range to avoid instability across large time gaps
78
+ const dt = Math.min(0.1, Math.max(0.001, (t - lastTime) / 1000));
79
+ lastTime = t;
80
+ const displacement = state.current.value - state.target.value;
81
+ // Spring force based on Hooke's Law: F = -k x; damping force: -c v
82
+ const spring = -stiffness * displacement;
83
+ const damper = -damping * velocity;
84
+ const accel = (spring + damper) / mass;
85
+ velocity += accel * dt;
86
+ state.current.value += velocity * dt;
87
+ const isNoVelocity = Math.abs(velocity) <= restSpeed;
88
+ const isNoDisplacement = Math.abs(state.current.value - state.target.value) <= restDelta;
89
+ const done = isNoVelocity && isNoDisplacement;
90
+ if (done) {
91
+ state.current.value = state.target.value;
92
+ store.set(formatUnit(state.current.value, unit));
93
+ raf = 0;
94
+ lastTime = 0;
95
+ return;
96
+ }
97
+ store.set(formatUnit(state.current.value, unit));
98
+ raf = requestAnimationFrame(step);
99
+ };
100
+ const start = () => {
101
+ if (raf)
102
+ return;
103
+ raf = requestAnimationFrame(step);
104
+ };
105
+ const api = {
106
+ set: (v) => {
107
+ state.target = parseUnit(v);
108
+ start();
109
+ },
110
+ jump: (v) => {
111
+ state.current = parseUnit(v);
112
+ state.target = parseUnit(v);
113
+ velocity = 0;
114
+ store.set(formatUnit(state.current.value, state.current.unit || state.target.unit));
115
+ }
116
+ };
117
+ // If following another store, subscribe and forward values to set()
118
+ if (typeof source === 'object' && 'subscribe' in source) {
119
+ let followSource = true;
120
+ const unsub = source.subscribe((v) => api.set(v));
121
+ const wrapped = readable(formatUnit(state.current.value, unit), (set) => {
122
+ const sub = store.subscribe(set);
123
+ return () => {
124
+ sub();
125
+ unsub();
126
+ followSource = false;
127
+ if (raf)
128
+ cancelAnimationFrame(raf);
129
+ };
130
+ });
131
+ wrapped.set = (v) => {
132
+ if (followSource)
133
+ unsub();
134
+ followSource = false;
135
+ api.set(v);
136
+ };
137
+ wrapped.jump = (v) => {
138
+ if (followSource)
139
+ unsub();
140
+ followSource = false;
141
+ api.jump(v);
142
+ };
143
+ return wrapped;
144
+ }
145
+ // Standard readable wrapping internal writable
146
+ const wrapped = readable(formatUnit(state.current.value, unit), (set) => {
147
+ const sub = store.subscribe(set);
148
+ return () => {
149
+ sub();
150
+ if (raf)
151
+ cancelAnimationFrame(raf);
152
+ };
153
+ });
154
+ wrapped.set = api.set;
155
+ wrapped.jump = api.jump;
156
+ return wrapped;
157
+ };
@@ -0,0 +1,14 @@
1
+ import { type Readable } from 'svelte/store';
2
+ /**
3
+ * Returns a time store that ticks once per animation frame.
4
+ *
5
+ * - Without an `id`, returns a fresh timeline per call.
6
+ * - With an `id`, callers sharing the same id receive the same store/timeline,
7
+ * ensuring synchronized reads across components.
8
+ * - SSR-safe: Returns a static 0-valued store when `window` is unavailable.
9
+ *
10
+ * @param {string=} id Optional timeline identifier for sharing across calls.
11
+ * @returns {Readable<number>} A readable store of elapsed milliseconds.
12
+ * @see https://motion.dev/docs/react-use-time?platform=react
13
+ */
14
+ export declare const useTime: (id?: string) => Readable<number>;
@@ -0,0 +1,68 @@
1
+ import { readable } from 'svelte/store';
2
+ const SSR_ZERO = readable(0, () => { });
3
+ const sharedStores = new Map();
4
+ // Clear shared timelines on HMR dispose to avoid stale entries across hot reloads
5
+ if (import.meta &&
6
+ import.meta.hot) {
7
+ ;
8
+ import.meta.hot.dispose(() => {
9
+ sharedStores.clear();
10
+ });
11
+ }
12
+ /**
13
+ * Creates a new time store that updates once per animation frame.
14
+ *
15
+ * The store value represents elapsed milliseconds since the store was created.
16
+ * In SSR environments (no `window`), a static 0-valued store is returned.
17
+ *
18
+ * @returns {Readable<number>} A readable store of elapsed milliseconds.
19
+ * @see https://motion.dev/docs/react-use-time?platform=react
20
+ * @private
21
+ */
22
+ const createTimeStore = () => {
23
+ if (typeof window === 'undefined')
24
+ return SSR_ZERO;
25
+ return readable(0, (set) => {
26
+ const start = performance.now();
27
+ let raf = 0;
28
+ /* c8 ignore start */
29
+ const loop = (t) => {
30
+ set(t - start);
31
+ raf = requestAnimationFrame(loop);
32
+ };
33
+ /* c8 ignore stop */
34
+ raf = requestAnimationFrame(loop);
35
+ return () => cancelAnimationFrame(raf);
36
+ });
37
+ };
38
+ /**
39
+ * Returns a time store that ticks once per animation frame.
40
+ *
41
+ * - Without an `id`, returns a fresh timeline per call.
42
+ * - With an `id`, callers sharing the same id receive the same store/timeline,
43
+ * ensuring synchronized reads across components.
44
+ * - SSR-safe: Returns a static 0-valued store when `window` is unavailable.
45
+ *
46
+ * @param {string=} id Optional timeline identifier for sharing across calls.
47
+ * @returns {Readable<number>} A readable store of elapsed milliseconds.
48
+ * @see https://motion.dev/docs/react-use-time?platform=react
49
+ */
50
+ export const useTime = (id) => {
51
+ if (!id)
52
+ return createTimeStore();
53
+ if (typeof window === 'undefined')
54
+ return SSR_ZERO;
55
+ const existing = sharedStores.get(id);
56
+ if (existing)
57
+ return existing;
58
+ const base = createTimeStore();
59
+ const store = readable(0, (set) => {
60
+ const unsub = base.subscribe(set);
61
+ return () => {
62
+ unsub();
63
+ sharedStores.delete(id);
64
+ };
65
+ });
66
+ sharedStores.set(id, store);
67
+ return store;
68
+ };
@@ -0,0 +1,42 @@
1
+ import { type Readable } from 'svelte/store';
2
+ /**
3
+ * Options for range-mapping transform.
4
+ *
5
+ * - clamp: If true, clamps the input to the active segment bounds.
6
+ * - ease: A single easing function or one per segment to shape interpolation.
7
+ * - mixer: Custom mixer factory to interpolate non-numeric outputs.
8
+ *
9
+ * @see https://motion.dev/docs/react-use-transform?platform=react
10
+ */
11
+ export type TransformOptions = {
12
+ clamp?: boolean;
13
+ ease?: ((t: number) => number) | Array<(t: number) => number>;
14
+ mixer?: (from: unknown, to: unknown) => (t: number) => unknown;
15
+ };
16
+ /**
17
+ * Clamps a numeric value between two bounds, irrespective of their order.
18
+ *
19
+ * @param val Current value.
20
+ * @param a First bound.
21
+ * @param b Second bound.
22
+ * @returns Value clamped to [min(a,b), max(a,b)].
23
+ */
24
+ export declare const clampBidirectional: (val: number, a: number, b: number) => number;
25
+ /**
26
+ * Creates a derived Svelte store that transforms values.
27
+ *
28
+ * Two supported forms (API parity with Motion's useTransform):
29
+ * - Mapping form: Map a numeric source across input/output ranges.
30
+ * Example: `useTransform(src, [0, 100], [0, 1], { clamp: true })`
31
+ * - Function form: Recompute from a function based on dependency stores.
32
+ * Example: `useTransform(() => compute(), [depA, depB])`
33
+ *
34
+ * @template T
35
+ * @param {Readable<number>|(() => T)} sourceOrCompute Numeric source store (mapping form), or compute function (function form).
36
+ * @param {number[]|Readable<unknown>[]} inputOrDeps Input stops (mapping) or dependency stores (function form).
37
+ * @param {T[]=} output Output stops (mapping form only). Must match input length.
38
+ * @param {TransformOptions=} options Mapping options (mapping form only).
39
+ * @returns {Readable<T>} A derived Svelte readable store.
40
+ * @see https://motion.dev/docs/react-use-transform?platform=react
41
+ */
42
+ export declare const useTransform: <T = number>(sourceOrCompute: Readable<number> | (() => T), inputOrDeps: number[] | Readable<unknown>[], output?: T[], options?: TransformOptions) => Readable<T>;
@@ -0,0 +1,129 @@
1
+ import { derived, readable } from 'svelte/store';
2
+ /**
3
+ * Creates a linear mixer function for numeric values.
4
+ *
5
+ * @param from Starting numeric value.
6
+ * @param to Ending numeric value.
7
+ * @returns Function that linearly interpolates between from→to for progress t∈[0,1].
8
+ * @private
9
+ */
10
+ const linearMix = (from, to) => (t) => from + (to - from) * t;
11
+ /**
12
+ * Clamps a numeric value between two bounds, irrespective of their order.
13
+ *
14
+ * @param val Current value.
15
+ * @param a First bound.
16
+ * @param b Second bound.
17
+ * @returns Value clamped to [min(a,b), max(a,b)].
18
+ */
19
+ export const clampBidirectional = (val, a, b) => {
20
+ const lower = a < b ? a : b;
21
+ const upper = a < b ? b : a;
22
+ return Math.min(Math.max(val, lower), upper);
23
+ };
24
+ /**
25
+ * Finds the segment index i such that x lies between input[i] and input[i+1].
26
+ * Handles both ascending and descending input ranges.
27
+ *
28
+ * @param input Monotonic list of input stops.
29
+ * @param x Current input value.
30
+ * @returns Segment index in range [0, input.length - 2].
31
+ * @private
32
+ */
33
+ const findSegment = (input, x) => {
34
+ if (input.length < 2)
35
+ return 0;
36
+ const first = input[0];
37
+ const second = input[1];
38
+ const ascending = second > first;
39
+ if (ascending) {
40
+ if (x <= first)
41
+ return 0;
42
+ for (let i = 1; i < input.length; i++) {
43
+ const curr = input[i];
44
+ if (x <= curr)
45
+ return i - 1;
46
+ }
47
+ return input.length - 2;
48
+ }
49
+ else {
50
+ if (x >= first)
51
+ return 0;
52
+ for (let i = 1; i < input.length; i++) {
53
+ const curr = input[i];
54
+ if (x >= curr)
55
+ return i - 1;
56
+ }
57
+ return input.length - 2;
58
+ }
59
+ };
60
+ /**
61
+ * Creates a derived Svelte store that transforms values.
62
+ *
63
+ * Two supported forms (API parity with Motion's useTransform):
64
+ * - Mapping form: Map a numeric source across input/output ranges.
65
+ * Example: `useTransform(src, [0, 100], [0, 1], { clamp: true })`
66
+ * - Function form: Recompute from a function based on dependency stores.
67
+ * Example: `useTransform(() => compute(), [depA, depB])`
68
+ *
69
+ * @template T
70
+ * @param {Readable<number>|(() => T)} sourceOrCompute Numeric source store (mapping form), or compute function (function form).
71
+ * @param {number[]|Readable<unknown>[]} inputOrDeps Input stops (mapping) or dependency stores (function form).
72
+ * @param {T[]=} output Output stops (mapping form only). Must match input length.
73
+ * @param {TransformOptions=} options Mapping options (mapping form only).
74
+ * @returns {Readable<T>} A derived Svelte readable store.
75
+ * @see https://motion.dev/docs/react-use-transform?platform=react
76
+ */
77
+ export const useTransform = (sourceOrCompute, inputOrDeps, output, options = {}) => {
78
+ // Function form: (compute, deps)
79
+ if (typeof sourceOrCompute === 'function') {
80
+ const compute = sourceOrCompute;
81
+ const deps = inputOrDeps;
82
+ if (!deps || deps.length === 0)
83
+ return readable(compute());
84
+ return derived(deps, () => compute());
85
+ }
86
+ // Mapping form: (source, input, output, options)
87
+ const source = sourceOrCompute;
88
+ const input = inputOrDeps;
89
+ const out = (output ?? []);
90
+ const { clamp = true, ease, mixer } = options;
91
+ if (input.length !== out.length) {
92
+ throw new Error(`useTransform: input and output arrays must be the same length (input: ${input.length}, output: ${out.length})`);
93
+ }
94
+ const easings = Array.isArray(ease)
95
+ ? ease
96
+ : ease
97
+ ? new Array(Math.max(0, out.length - 1)).fill(ease)
98
+ : [];
99
+ return derived(source, (x) => {
100
+ if (input.length === 0)
101
+ return out[0];
102
+ if (input.length === 1)
103
+ return out[0];
104
+ const seg = findSegment(input, x);
105
+ const i0 = input[seg];
106
+ const i1 = input[seg + 1];
107
+ const o0 = out[seg];
108
+ const o1 = out[seg + 1];
109
+ // Runtime validation to avoid non-null assertions
110
+ if (i0 === undefined || i1 === undefined || o0 === undefined || o1 === undefined) {
111
+ console.warn('useTransform: Invalid segment bounds', {
112
+ seg,
113
+ inputLength: input.length,
114
+ outputLength: out.length
115
+ });
116
+ return out[0];
117
+ }
118
+ const localClamp = clamp ? clampBidirectional : (val) => val;
119
+ const progress = i0 === i1 ? 0 : (localClamp(x, i0, i1) - i0) / (i1 - i0);
120
+ const e = easings[seg];
121
+ const p = e ? e(progress) : progress;
122
+ const mix = mixer
123
+ ? mixer(o0, o1)
124
+ : typeof o0 === 'number' && typeof o1 === 'number'
125
+ ? linearMix(o0, o1)
126
+ : (_t) => (p < 0.5 ? o0 : o1);
127
+ return mix(p);
128
+ });
129
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-motion",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A lightweight animation library for Svelte 5 that provides smooth, hardware-accelerated animations. Features include spring physics, custom easing, and fluid transitions. Built on top of the motion library, it offers a simple API for creating complex animations with minimal code. Perfect for interactive UIs, micro-interactions, and engaging user experiences.",
5
5
  "keywords": [
6
6
  "svelte",