@humanspeak/svelte-motion 0.4.5 → 0.4.6

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
@@ -44,7 +44,7 @@ Goal: Framer Motion API parity for Svelte where common React examples can be tra
44
44
  | Drag (`drag`, constraints, momentum, controls, callbacks) | Supported |
45
45
  | `AnimatePresence` (`initial`, `mode`, `onExitComplete`) | Supported |
46
46
  | Layout (`layout`, `layout="position"`) | Supported (single-element FLIP) |
47
- | Shared layout (`layoutId`) | Supported |
47
+ | Shared layout (`layoutId`, `LayoutGroup`, `layoutScroll`) | Supported |
48
48
  | Pan gesture API (`whilePan`, `onPan*`) | Not yet supported |
49
49
  | `MotionConfig` parity beyond `transition` | Partial |
50
50
  | `reducedMotion`, `features`, `transformPagePoint` | Not yet supported |
@@ -280,7 +280,6 @@ Validated against current source and test suite (local run):
280
280
 
281
281
  ## Known gaps vs Framer Motion
282
282
 
283
- - No shared layout API (`layoutId`, `LayoutGroup`).
284
283
  - No pan gesture API (`whilePan`, `onPan*`).
285
284
  - `whileInView` does not yet expose Framer-style viewport options.
286
285
  - `MotionConfig` currently only provides `transition` defaults.
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import {
4
+ chainLayoutGroupId,
5
+ getLayoutGroupContext,
6
+ setLayoutGroupContext
7
+ } from './layoutGroup.context'
8
+
9
+ /**
10
+ * Scope `layoutId` shared-layout animations to a subtree.
11
+ *
12
+ * Wrap a region in `<LayoutGroup id="…">` so descendants' `layoutId`
13
+ * snapshots / consumes are prefixed with the group's id. Two groups
14
+ * containing the same `layoutId` values won't cross-animate — useful
15
+ * for repeated UI patterns (multiple tab indicators, kanban columns,
16
+ * sibling carousels) where each instance should animate independently.
17
+ *
18
+ * Mirrors framer-motion's `<LayoutGroup>` (`inherit` defaults to true
19
+ * — descendants chain onto the parent group's id, so nested groups
20
+ * yield `"parent-child"`).
21
+ *
22
+ * @prop id Stable identifier for this group's scope. When omitted,
23
+ * the LayoutGroup is a transparent grouping with no own id
24
+ * (still useful for `inherit={false}` to break out of an outer
25
+ * group's scope, e.g. an embedded widget).
26
+ * @prop inherit `true` (default) — chain onto the parent group's id.
27
+ * `'id'` — same as `true` in this implementation; accepted for
28
+ * drop-in compatibility with framer-motion examples. In
29
+ * framer-motion, `'id'` inherits the id but breaks the internal
30
+ * projection-tree group. We don't have a projection-tree group
31
+ * (our snapshot/consume registry doesn't need sibling
32
+ * coordination), so `'id'` and `true` behave identically.
33
+ * `false` — start a fresh scope, ignoring any outer LayoutGroup.
34
+ * @prop children Slot rendered inside the group context.
35
+ *
36
+ * @example
37
+ * ```svelte
38
+ * <LayoutGroup id="tabs-a">
39
+ * <Tabs />
40
+ * </LayoutGroup>
41
+ * <LayoutGroup id="tabs-b">
42
+ * <Tabs /> <!-- same layoutId values, independent animations -->
43
+ * </LayoutGroup>
44
+ * ```
45
+ *
46
+ * @see https://motion.dev/docs/react-layout-animations#scoped-layout-animations
47
+ */
48
+ const {
49
+ id,
50
+ inherit = true,
51
+ children
52
+ }: {
53
+ id?: string
54
+ inherit?: boolean | 'id'
55
+ children?: Snippet
56
+ } = $props()
57
+
58
+ // setContext is one-shot at component init, so reading `id` and `inherit`
59
+ // here captures their initial values intentionally — the scope id is
60
+ // fixed for this subtree's lifetime. The warning would only matter if we
61
+ // wanted descendants to react to prop changes, which we explicitly don't.
62
+ // svelte-ignore state_referenced_locally
63
+ const shouldInheritId = inherit === true || inherit === 'id'
64
+ const effectiveId = shouldInheritId ? chainLayoutGroupId(getLayoutGroupContext(), id) : id
65
+ setLayoutGroupContext(effectiveId)
66
+ </script>
67
+
68
+ {@render children?.()}
@@ -0,0 +1,9 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ id?: string;
4
+ inherit?: boolean | 'id';
5
+ children?: Snippet;
6
+ };
7
+ declare const LayoutGroup: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type LayoutGroup = ReturnType<typeof LayoutGroup>;
9
+ export default LayoutGroup;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Identifier for a `<LayoutGroup>` subtree. Descendants prefix their
3
+ * `layoutId` lookups with this so two `<LayoutGroup>`s containing the
4
+ * same `layoutId` values don't cross-animate.
5
+ *
6
+ * `undefined` means "no enclosing LayoutGroup" — the descendant uses
7
+ * `layoutId` verbatim against the global registry, preserving the
8
+ * existing un-grouped behaviour.
9
+ */
10
+ export type LayoutGroupContext = string | undefined;
11
+ /**
12
+ * Publish a LayoutGroup id for descendants. Called by `<LayoutGroup>`
13
+ * after computing its own (possibly inherited and chained) id.
14
+ */
15
+ export declare const setLayoutGroupContext: (id: LayoutGroupContext) => void;
16
+ /**
17
+ * Read the nearest LayoutGroup id, or `undefined` if not inside one.
18
+ *
19
+ * `_MotionContainer.svelte` reads this to prefix `layoutId` when
20
+ * snapshotting and consuming against the registry, so shared-layout
21
+ * animations stay scoped to the surrounding group.
22
+ */
23
+ export declare const getLayoutGroupContext: () => LayoutGroupContext;
24
+ /**
25
+ * Combine a parent group's id with a descendant LayoutGroup's own id
26
+ * to produce the effective scope id. Mirrors framer-motion's chaining
27
+ * (`"parent-id"` + `"-"` + `"own-id"`).
28
+ *
29
+ * Either side can be `undefined`; the result is the other one, or
30
+ * `undefined` if both are absent.
31
+ */
32
+ export declare const chainLayoutGroupId: (parent: LayoutGroupContext, own: string | undefined) => LayoutGroupContext;
33
+ /**
34
+ * Apply a LayoutGroup scope to a raw `layoutId` for registry lookups.
35
+ * Returns the un-prefixed id when no group is in scope.
36
+ */
37
+ export declare const scopeLayoutId: (groupId: LayoutGroupContext, layoutId: string) => string;
@@ -0,0 +1,41 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ const LAYOUT_GROUP_CONTEXT_KEY = Symbol('layout-group');
3
+ /**
4
+ * Publish a LayoutGroup id for descendants. Called by `<LayoutGroup>`
5
+ * after computing its own (possibly inherited and chained) id.
6
+ */
7
+ export const setLayoutGroupContext = (id) => {
8
+ setContext(LAYOUT_GROUP_CONTEXT_KEY, id);
9
+ };
10
+ /**
11
+ * Read the nearest LayoutGroup id, or `undefined` if not inside one.
12
+ *
13
+ * `_MotionContainer.svelte` reads this to prefix `layoutId` when
14
+ * snapshotting and consuming against the registry, so shared-layout
15
+ * animations stay scoped to the surrounding group.
16
+ */
17
+ export const getLayoutGroupContext = () => {
18
+ return getContext(LAYOUT_GROUP_CONTEXT_KEY);
19
+ };
20
+ /**
21
+ * Combine a parent group's id with a descendant LayoutGroup's own id
22
+ * to produce the effective scope id. Mirrors framer-motion's chaining
23
+ * (`"parent-id"` + `"-"` + `"own-id"`).
24
+ *
25
+ * Either side can be `undefined`; the result is the other one, or
26
+ * `undefined` if both are absent.
27
+ */
28
+ export const chainLayoutGroupId = (parent, own) => {
29
+ if (!parent)
30
+ return own;
31
+ if (!own)
32
+ return parent;
33
+ return `${parent}-${own}`;
34
+ };
35
+ /**
36
+ * Apply a LayoutGroup scope to a raw `layoutId` for registry lookups.
37
+ * Returns the un-prefixed id when no group is in scope.
38
+ */
39
+ export const scopeLayoutId = (groupId, layoutId) => {
40
+ return groupId ? `${groupId}::${layoutId}` : layoutId;
41
+ };
@@ -69,6 +69,7 @@
69
69
  getLayoutScrollContainerRef,
70
70
  setLayoutScrollContainer
71
71
  } from '../components/layoutScroll.context'
72
+ import { getLayoutGroupContext, scopeLayoutId } from '../components/layoutGroup.context'
72
73
 
73
74
  type Props = MotionProps & {
74
75
  children?: Snippet
@@ -147,6 +148,15 @@
147
148
  // Get layoutId registry (provided by AnimatePresence or a parent LayoutGroup)
148
149
  const layoutIdRegistry = getLayoutIdRegistry()
149
150
 
151
+ // Scope layoutId by the surrounding <LayoutGroup>, so identical
152
+ // layoutId values in two sibling groups don't cross-animate (#311).
153
+ // Undefined when no group is in scope — descendants behave exactly
154
+ // as before relative to the global registry.
155
+ const layoutGroupId = getLayoutGroupContext()
156
+ const scopedLayoutId = $derived(
157
+ layoutIdProp ? scopeLayoutId(layoutGroupId, layoutIdProp) : undefined
158
+ )
159
+
150
160
  // Capture the ancestor `layoutScroll` chain BEFORE we potentially shadow
151
161
  // the context with ourselves below — this element's own FLIP measurements
152
162
  // must resolve against the *ancestors*' scroll containers, not against
@@ -258,9 +268,9 @@
258
268
  // On cleanup (before DOM removal), push last-known rect to registry
259
269
  return () => {
260
270
  cancelAnimationFrame(rafId)
261
- if (layoutIdLastRect && layoutIdProp) {
271
+ if (layoutIdLastRect && scopedLayoutId) {
262
272
  layoutIdRegistry.snapshot(
263
- layoutIdProp,
273
+ scopedLayoutId,
264
274
  layoutIdLastRect,
265
275
  (mergedTransition ?? {}) as AnimationOptions
266
276
  )
@@ -773,9 +783,9 @@
773
783
  // Shared layout animation via layoutId.
774
784
  // On mount, consume the previous snapshot and FLIP from its position.
775
785
  $effect(() => {
776
- if (!(element && layoutIdProp && layoutIdRegistry && isLoaded === 'ready')) return
786
+ if (!(element && scopedLayoutId && layoutIdRegistry && isLoaded === 'ready')) return
777
787
 
778
- const prev = layoutIdRegistry.consume(layoutIdProp)
788
+ const prev = layoutIdRegistry.consume(scopedLayoutId)
779
789
  if (!prev) return // First appearance, no animation needed
780
790
 
781
791
  const next = measureRect(element, resolveLayoutScrollAncestors())
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import AnimatePresence from './components/AnimatePresence.svelte';
2
+ import LayoutGroup from './components/LayoutGroup.svelte';
2
3
  import MotionConfig from './components/MotionConfig.svelte';
3
4
  import PresenceChild from './components/PresenceChild.svelte';
4
5
  export { motion } from './motion';
@@ -32,7 +33,7 @@ export { stringifyStyleObject } from './utils/styleObject';
32
33
  export { styleString } from './utils/styleObject.svelte';
33
34
  export { useTime } from './utils/time';
34
35
  export { useTransform } from './utils/transform';
35
- export { AnimatePresence, MotionConfig, PresenceChild };
36
+ export { AnimatePresence, LayoutGroup, MotionConfig, PresenceChild };
36
37
  export { default as MotionA } from './html/A.svelte';
37
38
  export { default as MotionAbbr } from './html/Abbr.svelte';
38
39
  export { default as MotionAddress } from './html/Address.svelte';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import AnimatePresence from './components/AnimatePresence.svelte';
2
+ import LayoutGroup from './components/LayoutGroup.svelte';
2
3
  import MotionConfig from './components/MotionConfig.svelte';
3
4
  import PresenceChild from './components/PresenceChild.svelte';
4
5
  export { motion } from './motion';
@@ -29,7 +30,7 @@ export { stringifyStyleObject } from './utils/styleObject';
29
30
  export { styleString } from './utils/styleObject.svelte';
30
31
  export { useTime } from './utils/time';
31
32
  export { useTransform } from './utils/transform';
32
- export { AnimatePresence, MotionConfig, PresenceChild };
33
+ export { AnimatePresence, LayoutGroup, MotionConfig, PresenceChild };
33
34
  // Named component exports — tree-shakeable alternative to the `motion` object
34
35
  export { default as MotionA } from './html/A.svelte';
35
36
  export { default as MotionAbbr } from './html/Abbr.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-motion",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Framer Motion for Svelte 5. Declarative motion.<tag> components with AnimatePresence exit animations, gestures (hover, tap, drag, focus, in-view), variants, FLIP layout animations, shared-layout transitions, spring physics, and scroll-linked motion values. The drop-in Framer Motion alternative for Svelte and SvelteKit.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -114,7 +114,7 @@
114
114
  "@tailwindcss/vite": "^4.3.0",
115
115
  "@testing-library/jest-dom": "^6.9.1",
116
116
  "@testing-library/svelte": "^5.3.1",
117
- "@types/node": "^25.9.0",
117
+ "@types/node": "^25.9.1",
118
118
  "@vitest/coverage-v8": "^4.1.6",
119
119
  "eslint": "^10.4.0",
120
120
  "eslint-config-prettier": "10.1.8",
@@ -135,16 +135,16 @@
135
135
  "prettier-plugin-tailwindcss": "^0.8.0",
136
136
  "publint": "^0.3.21",
137
137
  "runed": "0.37.1",
138
- "svelte": "^5.55.7",
138
+ "svelte": "^5.55.8",
139
139
  "svelte-check": "^4.4.8",
140
140
  "svg-tags": "^1.0.0",
141
141
  "tailwind-merge": "^3.6.0",
142
142
  "tailwind-variants": "^3.2.2",
143
143
  "tailwindcss": "^4.3.0",
144
144
  "tailwindcss-animate": "^1.0.7",
145
- "tsx": "^4.22.2",
145
+ "tsx": "^4.22.3",
146
146
  "typescript": "^6.0.3",
147
- "typescript-eslint": "^8.59.3",
147
+ "typescript-eslint": "^8.59.4",
148
148
  "vite": "^8.0.13",
149
149
  "vite-tsconfig-paths": "^6.1.1",
150
150
  "vitest": "^4.1.6"