diff --git a/.changeset/animation-initial.md b/.changeset/animation-initial.md
new file mode 100644
index 000000000..27e38e3ad
--- /dev/null
+++ b/.changeset/animation-initial.md
@@ -0,0 +1,21 @@
+---
+"@solid-primitives/animation": minor
+---
+
+New package. Provides reactive and imperative wrappers for the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) (WAAPI). All primitives follow the `make*` / `create*` convention: `make*` is imperative and returns immediately, `create*` is a reactive wrapper that re-runs on dependency change and cancels on owner disposal.
+
+- `makeAnimate(el, keyframes, options?)` — thin wrapper around `element.animate()`
+- `createAnimate(target, keyframes, options?)` — reactive `makeAnimate`; re-runs whenever target, keyframes, or options change
+- `makeScrollAnimation(el, keyframes, options?)` — scroll-driven animation via `ScrollTimeline`
+- `createScrollAnimation(target, keyframes, options?)` — reactive `makeScrollAnimation`
+- `makeViewAnimation(el, keyframes, options?)` — viewport-driven animation via `ViewTimeline`; defaults `rangeStart`/`rangeEnd` to the entry phase so initially-visible elements animate correctly
+- `createViewAnimation(target, keyframes, options?)` — reactive `makeViewAnimation`
+- `makeFlip(el, options?)` — FLIP layout animation; `snapshot()` before DOM change, `flip()` after
+- `makeStagger(els, keyframes, options?)` — staggered WAAPI animation across a list of elements with per-element delay offset
+- `createStagger(targets, keyframes, options?)` — reactive `makeStagger`
+- `makeAnimationGroup(animations)` — coordinates a static list of `Animation` objects as a unit; forwards `play`, `pause`, `cancel`, `reverse`, and `finish` to all simultaneously
+- `createAnimationGroup(animations)` — reactive `makeAnimationGroup`; re-derives the group whenever the accessor returns a new list
+- `makeMotionPath(el, path, options?)` — animates an element along a CSS `offset-path` using WAAPI
+- `createMotionPath(target, path, options?)` — reactive `makeMotionPath`
+- `makeSequence(factories)` — chains animation factories into a sequential playlist; each factory is called lazily when its predecessor finishes
+- `createPresenceAnimation(target, show, options)` — manages mount/unmount lifecycle with WAAPI enter/exit animations; element stays mounted until its exit animation completes
diff --git a/README.md b/README.md
index defbbd395..c8dcade70 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,13 @@
[](https://primitives2.solidjs.community)
[](https://pnpm.io/)
+<<<<<<< HEAD
+[](https://vitest.dev)
+[](https://dash.deno.com/playground/combined-npm-downloads)
+
+=======
[](https://vitest.dev)
+>>>>>>> next
Solid Primitives is a project dedicated to building high-quality, community-contributed primitives for Solid 2.0. Every utility is thoroughly tested, continuously maintained, and reviewed against a consistent quality bar before it lands in the repository. Our aim is to extend Solid's primary and secondary primitives with a well-rounded set of tertiary primitives.
While Solid Primitives isn't officially maintained by the SolidJS Core Team, it's managed by members of the SolidJS core and ecosystem teams. This separation lets the core library iterate independently while keeping Solid Primitives in sync with where Solid is headed.
@@ -142,10 +148,18 @@ See the [CHANGELOG](https://github.com/solidjs-community/solid-primitives/tree/n
|[sensors](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[makeAccelerometer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makeaccelerometer) [createAccelerometer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#createaccelerometer) [makeGyroscope](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makegyroscope) [createGyroscope](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#creategyroscope) [makeSensor](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makesensor) [createSensor](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#createsensor) [makeCompass](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makecompass) [createCompass](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#createcompass) [makeBattery](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makebattery) [createBattery](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#createbattery)|[](https://bundlephobia.com/package/@solid-primitives/sensors)|[](https://www.npmjs.com/package/@solid-primitives/sensors)|✓|
|[vibrate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[isVibrationSupported](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#isvibrationsupported) [makeVibrate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#makevibrate) [createVibrate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#createvibrate) [frequencyToPattern](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#frequencytopattern) [makePulse](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#makepulse) [createPulse](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#createpulse)|[](https://bundlephobia.com/package/@solid-primitives/vibrate)|[](https://www.npmjs.com/package/@solid-primitives/vibrate)|✓|
|
*Animation*
|
+<<<<<<< HEAD
+|[animation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createAnimate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createanimate) [createScrollAnimation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createscrollanimation) [createViewAnimation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createviewanimation) [makeFlip](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#makeflip) [createStagger](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createstagger) [createAnimationGroup](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createanimationgroup)|[](https://bundlephobia.com/package/@solid-primitives/animation)|[](https://www.npmjs.com/package/@solid-primitives/animation)||
+|[presence](https://github.com/solidjs-community/solid-primitives/tree/main/packages/presence#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createPresence](https://github.com/solidjs-community/solid-primitives/tree/main/packages/presence#createpresence)|[](https://bundlephobia.com/package/@solid-primitives/presence)|[](https://www.npmjs.com/package/@solid-primitives/presence)|✓|
+|[raf](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createRAF](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createraf) [createMs](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createms) [targetFPS](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#targetfps)|[](https://bundlephobia.com/package/@solid-primitives/raf)|[](https://www.npmjs.com/package/@solid-primitives/raf)|✓|
+|[spring](https://github.com/solidjs-community/solid-primitives/tree/main/packages/spring#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createSpring](https://github.com/solidjs-community/solid-primitives/tree/main/packages/spring#createspring) [createDerivedSpring](https://github.com/solidjs-community/solid-primitives/tree/main/packages/spring#createderivedspring)|[](https://bundlephobia.com/package/@solid-primitives/spring)|[](https://www.npmjs.com/package/@solid-primitives/spring)|✓|
+|[transition-group](https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createSwitchTransition](https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#createswitchtransition) [createListTransition](https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#createlisttransition)|[](https://bundlephobia.com/package/@solid-primitives/transition-group)|[](https://www.npmjs.com/package/@solid-primitives/transition-group)|✓|
+=======
|[presence](https://github.com/solidjs-community/solid-primitives/tree/main/packages/presence#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createPresence](https://github.com/solidjs-community/solid-primitives/tree/main/packages/presence#createpresence)|[](https://bundlephobia.com/package/@solid-primitives/presence)|[](https://www.npmjs.com/package/@solid-primitives/presence)|✓|
|[raf](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createRAF](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createraf) [createMs](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createms) [targetFPS](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#targetfps)|[](https://bundlephobia.com/package/@solid-primitives/raf)|[](https://www.npmjs.com/package/@solid-primitives/raf)|✓|
|[spring](https://github.com/solidjs-community/solid-primitives/tree/main/packages/spring#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createSpring](https://github.com/solidjs-community/solid-primitives/tree/main/packages/spring#createspring) [createDerivedSpring](https://github.com/solidjs-community/solid-primitives/tree/main/packages/spring#createderivedspring)|[](https://bundlephobia.com/package/@solid-primitives/spring)|[](https://www.npmjs.com/package/@solid-primitives/spring)|✓|
|[transition-group](https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createSwitchTransition](https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#createswitchtransition) [createListTransition](https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#createlisttransition)|[](https://bundlephobia.com/package/@solid-primitives/transition-group)|[](https://www.npmjs.com/package/@solid-primitives/transition-group)|✓|
+>>>>>>> next
|[tween](https://github.com/solidjs-community/solid-primitives/tree/main/packages/tween#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createTween](https://github.com/solidjs-community/solid-primitives/tree/main/packages/tween#createtween)|[](https://bundlephobia.com/package/@solid-primitives/tween)|[](https://www.npmjs.com/package/@solid-primitives/tween)|✓|
diff --git a/deno.lock b/deno.lock
index 36a3baa9d..faa9c528a 100644
--- a/deno.lock
+++ b/deno.lock
@@ -2118,12 +2118,6 @@
"react"
]
},
- "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1": {
- "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
- "dependencies": [
- "@tybys/wasm-util"
- ]
- },
"@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.2_@emnapi+runtime@1.11.2": {
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dependencies": [
@@ -2503,7 +2497,7 @@
"dependencies": [
"@emnapi/core@1.11.1",
"@emnapi/runtime@1.11.1",
- "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1"
+ "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.9.2_@emnapi+runtime@1.9.2"
],
"cpu": ["wasm32"]
},
@@ -2789,7 +2783,7 @@
"dependencies": [
"@emnapi/core@1.11.1",
"@emnapi/runtime@1.11.1",
- "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1"
+ "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.9.2_@emnapi+runtime@1.9.2"
],
"cpu": ["wasm32"]
},
@@ -8812,6 +8806,13 @@
]
}
},
+ "packages/animation": {
+ "packageJson": {
+ "dependencies": [
+ "npm:solid-js@2.0.0-beta.34"
+ ]
+ }
+ },
"packages/async": {
"packageJson": {
"dependencies": [
diff --git a/packages/animation/CHANGELOG.md b/packages/animation/CHANGELOG.md
new file mode 100644
index 000000000..02a02c3fd
--- /dev/null
+++ b/packages/animation/CHANGELOG.md
@@ -0,0 +1,10 @@
+# @solid-primitives/animation
+
+## 0.0.1
+
+### Minor Changes
+
+- Initial release. WAAPI-based animation primitives for SolidJS: `makeAnimate`, `createAnimate`,
+ `makeScrollAnimation`, `createScrollAnimation`, `makeViewAnimation`, `createViewAnimation`,
+ `makeFlip`, `makeStagger`, `createStagger`, `makeAnimationGroup`, `createAnimationGroup`,
+ `makeMotionPath`, `createMotionPath`, `makeSequence`, `createPresenceAnimation`.
diff --git a/packages/animation/LICENSE b/packages/animation/LICENSE
new file mode 100644
index 000000000..38b41d975
--- /dev/null
+++ b/packages/animation/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Solid Primitives Working Group
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/packages/animation/README.md b/packages/animation/README.md
new file mode 100644
index 000000000..0870338d5
--- /dev/null
+++ b/packages/animation/README.md
@@ -0,0 +1,437 @@
+
+
+
+
+# @solid-primitives/animation
+
+[](https://www.npmjs.com/package/@solid-primitives/animation)
+[](https://github.com/solidjs-community/solid-primitives#contribution-process)
+[](https://vitest.dev)
+
+Solid primitives for the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) (WAAPI).
+
+## Installation
+
+```bash
+npm install @solid-primitives/animation
+# or
+pnpm add @solid-primitives/animation
+```
+
+## Primitives
+
+| Primitive | Description |
+|---|---|
+| [`makeAnimate`](#makeanimate--createanimate) | Imperative `element.animate()` wrapper |
+| [`createAnimate`](#makeanimate--createanimate) | Reactive `makeAnimate` |
+| [`makeScrollAnimation`](#makescrollanimation--createscrollanimation) | Scroll-driven animation via `ScrollTimeline` |
+| [`createScrollAnimation`](#makescrollanimation--createscrollanimation) | Reactive `makeScrollAnimation` |
+| [`makeViewAnimation`](#makeviewanimation--createviewanimation) | Viewport-driven animation via `ViewTimeline` |
+| [`createViewAnimation`](#makeviewanimation--createviewanimation) | Reactive `makeViewAnimation` |
+| [`makeFlip`](#makeflip) | FLIP layout animation |
+| [`makeStagger`](#makestagger--createstagger) | Staggered animations across a list of elements |
+| [`createStagger`](#makestagger--createstagger) | Reactive `makeStagger` |
+| [`makeAnimationGroup`](#makeanimationgroup--createanimationgroup) | Coordinate multiple animations as a unit |
+| [`createAnimationGroup`](#makeanimationgroup--createanimationgroup) | Reactive `makeAnimationGroup` |
+| [`makeMotionPath`](#makemotionpath--createmotionpath) | Animate an element along a CSS Motion Path |
+| [`createMotionPath`](#makemotionpath--createmotionpath) | Reactive `makeMotionPath` |
+| [`makeSequence`](#makesequence) | Chain animation factories into a sequential playlist |
+| [`createPresenceAnimation`](#createpresenceanimation) | Mount/unmount lifecycle with WAAPI enter/exit animations |
+
+---
+
+## `makeAnimate` / `createAnimate`
+
+`makeAnimate` is a thin wrapper around `element.animate()` with TypeScript types. `createAnimate` replays the animation whenever `target`, `keyframes`, or `options` change reactively, and cancels it when the owner disposes.
+
+```ts
+// Imperative
+const anim = makeAnimate(el, [{ opacity: 0 }, { opacity: 1 }], { duration: 300 });
+anim.pause();
+
+// Reactive
+const anim = createAnimate(
+ () => ref,
+ [{ opacity: 0 }, { opacity: 1 }],
+ { duration: 300, fill: "forwards" },
+);
+// anim() is the current Animation instance, or undefined while ref is unset
+anim()?.pause();
+```
+
+```ts
+function makeAnimate(
+ el: Element,
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ options?: KeyframeAnimationOptions,
+): Animation
+
+function createAnimate(
+ target: Accessor,
+ keyframes: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor
+```
+
+---
+
+## `makeScrollAnimation` / `createScrollAnimation`
+
+Plays a WAAPI animation whose progress is driven by scroll position via [`ScrollTimeline`](https://developer.mozilla.org/en-US/docs/Web/API/ScrollTimeline). No scroll listeners or RAF loops needed.
+
+```ts
+// Fade + rise as the user scrolls down the page
+const anim = createScrollAnimation(
+ () => ref,
+ [{ opacity: 0, transform: "translateY(20px)" }, { opacity: 1, transform: "none" }],
+ { fill: "both" },
+);
+
+// Tie progress to a specific scroll container
+const anim = createScrollAnimation(() => ref, keyframes, {
+ fill: "both",
+ source: scrollContainerEl,
+ axis: "block",
+});
+```
+
+```ts
+type ScrollAnimationOptions = Omit & {
+ source?: Element; // scroll container — defaults to document root scroller
+ axis?: "block" | "inline" | "x" | "y";
+};
+
+function makeScrollAnimation(
+ el: Element,
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ options?: ScrollAnimationOptions,
+): Animation
+
+function createScrollAnimation(
+ target: Accessor,
+ keyframes: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor
+```
+
+---
+
+## `makeViewAnimation` / `createViewAnimation`
+
+Plays a WAAPI animation whose progress is driven by an element's intersection with the scroll port via [`ViewTimeline`](https://developer.mozilla.org/en-US/docs/Web/API/ViewTimeline). Replaces the IntersectionObserver + class-toggle pattern.
+
+```ts
+// Animate the element itself as it enters the viewport
+const anim = createViewAnimation(
+ () => ref,
+ [{ opacity: 0, transform: "translateY(16px)" }, { opacity: 1, transform: "none" }],
+ { fill: "both" },
+);
+
+// Observe a different element than the one being animated
+const anim = createViewAnimation(() => animatedEl, keyframes, {
+ fill: "both",
+ subject: triggerEl,
+ inset: "0px 0px -100px 0px",
+});
+```
+
+```ts
+type ViewAnimationOptions = Omit & {
+ subject?: Element; // element to observe — defaults to target
+ axis?: "block" | "inline" | "x" | "y";
+ inset?: string | string[]; // shrinks/expands the intersection root
+ rangeStart?: string; // default: "entry 0%" — element starts entering the scroll port
+ rangeEnd?: string; // default: "entry 100%" — element has fully entered the scroll port
+};
+
+function makeViewAnimation(
+ el: Element,
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ options?: ViewAnimationOptions,
+): Animation
+
+function createViewAnimation(
+ target: Accessor,
+ keyframes: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor
+```
+
+---
+
+## `makeFlip`
+
+FLIP (First–Last–Invert–Play) layout animation. Call `snapshot()` before the DOM change to record the element's current geometry, then call `flip()` after to animate from the old position/size to the new one.
+
+```tsx
+let el!: HTMLUListElement;
+const { snapshot, flip } = makeFlip(el, { duration: 300, easing: "ease" });
+
+const handleReorder = () => {
+ snapshot();
+ setItems(prev => [...prev].reverse()); // DOM updates synchronously
+ flip();
+};
+
+return
...
;
+```
+
+`flip()` is a no-op if `snapshot()` was never called or if the geometry didn't change. It resets the captured rect after each call, so a second `flip()` without a new `snapshot()` is always a no-op.
+
+> **Note:** geometry is measured via `getBoundingClientRect` (viewport coordinates). Elements inside `position: fixed` or `position: absolute` ancestors may need coordinate adjustment.
+
+```ts
+function makeFlip(
+ el: Element,
+ options?: KeyframeAnimationOptions,
+): { snapshot: () => void; flip: () => Animation | undefined }
+```
+
+---
+
+## `makeStagger` / `createStagger`
+
+Applies a WAAPI animation to a list of elements with a per-element delay offset. The `stagger` option is added on top of the base `delay`.
+
+```ts
+// Imperative — animate a static list of elements
+makeStagger(listItems, [{ opacity: 0 }, { opacity: 1 }], {
+ duration: 400,
+ stagger: 60,
+});
+
+// Reactive — re-runs (cancelling previous animations) when the target list changes
+const itemRefs: HTMLLIElement[] = [];
+
+const anims = createStagger(
+ () => itemRefs,
+ [{ opacity: 0, transform: "translateY(8px)" }, { opacity: 1, transform: "none" }],
+ { duration: 400, stagger: 60, easing: "ease-out" },
+);
+```
+
+```ts
+type StaggerOptions = KeyframeAnimationOptions & {
+ stagger?: number; // ms added per element on top of `delay`
+};
+
+function makeStagger(
+ els: Element[],
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ options?: StaggerOptions,
+): Animation[]
+
+function createStagger(
+ targets: Accessor<(Element | null | undefined)[]>,
+ keyframes: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor
+```
+
+---
+
+## `makeAnimationGroup` / `createAnimationGroup`
+
+Coordinates a list of `Animation` objects as a single unit. All five control methods are forwarded to every non-null animation simultaneously. Pairs naturally with `makeAnimate` and `makeStagger`.
+
+`makeAnimationGroup` takes a static array. `createAnimationGroup` takes an accessor and re-derives the group whenever the list changes — each control method always operates on the most recent set of animations.
+
+```ts
+// Imperative — static list
+const header = makeAnimate(headerEl, fadeIn, { duration: 300 });
+const body = makeAnimate(bodyEl, fadeIn, { duration: 300, delay: 100 });
+const footer = makeAnimate(footerEl, fadeIn, { duration: 300, delay: 200 });
+
+const group = makeAnimationGroup([header, body, footer]);
+
+group.pause();
+group.play();
+group.cancel();
+```
+
+```tsx
+// Reactive — list changes when items() changes
+const itemRefs: HTMLLIElement[] = [];
+const [items, setItems] = createSignal(data);
+
+const anims = createStagger(
+ () => itemRefs,
+ [{ opacity: 0 }, { opacity: 1 }],
+ { duration: 300, stagger: 40 },
+);
+
+// group.play() / pause() always targets the animations from the latest render
+const group = createAnimationGroup(anims);
+
+return (
+
+
+
+ {(item, i) =>
{item.name}
}
+
+
+);
+```
+
+```ts
+type AnimationGroupControls = {
+ play: () => void;
+ pause: () => void;
+ cancel: () => void;
+ reverse: () => void;
+ finish: () => void;
+};
+
+function makeAnimationGroup(
+ animations: (Animation | null | undefined)[],
+): AnimationGroupControls
+
+function createAnimationGroup(
+ animations: Accessor<(Animation | null | undefined)[]>,
+): AnimationGroupControls
+```
+
+---
+
+## `makeMotionPath` / `createMotionPath`
+
+Animates an element along a [CSS Motion Path](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_motion_path) using WAAPI — sets `offset-path` and `offset-rotate` on the element and animates `offsetDistance` from `0%` to `100%`. The path/rotation styles are left in place after the animation so `fill: "forwards"` works correctly.
+
+```ts
+// Imperative — path is an SVG path string, passed to path("…")
+const anim = makeMotionPath(dotEl, "M0,0 C50,100 150,0 200,100", {
+ duration: 2000,
+ fill: "forwards",
+});
+
+// Any valid offset-path value also works, e.g. a shape function
+makeMotionPath(dotEl, "circle(50%)", { duration: 1500, iterations: Infinity });
+
+// Reactive — re-runs whenever target, path, or options change
+const anim = createMotionPath(
+ () => dotRef,
+ () => currentPath(),
+ { duration: 2000, rotate: "auto" },
+);
+anim()?.pause();
+```
+
+```ts
+type MotionPathOptions = KeyframeAnimationOptions & {
+ rotate?: string; // offset-rotate — "auto", "0deg", "reverse", etc. Default: "auto"
+};
+
+function makeMotionPath(
+ el: HTMLElement,
+ path: string,
+ options?: MotionPathOptions,
+): Animation
+
+function createMotionPath(
+ target: Accessor,
+ path: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor
+```
+
+---
+
+## `makeSequence`
+
+Chains animation factories into a sequential playlist: each factory is called and its animation allowed to finish before the next factory runs. Factories are invoked **lazily** — each is called only when its turn arrives, so animations are created and started just in time rather than all upfront. A factory returning `null`/`undefined` skips that step without breaking the chain.
+
+Calling `play()` while a sequence is already running discards the current run and starts fresh from the beginning.
+
+```ts
+const seq = makeSequence([
+ () => makeAnimate(headerEl, fadeIn, { duration: 300 }),
+ () => makeAnimate(bodyEl, slideIn, { duration: 400 }),
+ () => makeAnimate(footerEl, fadeIn, { duration: 300 }),
+]);
+
+seq.play(); // header → body → footer, each starts after the last finishes
+seq.cancel(); // stops immediately
+seq.play(); // restart from the beginning
+```
+
+```ts
+type AnimationFactory = () => Animation | null | undefined;
+
+type SequenceControls = {
+ play: () => void; // starts from the first factory, discarding any in-progress run
+ cancel: () => void; // stops the sequence; the currently-playing animation is cancelled
+};
+
+function makeSequence(factories: AnimationFactory[]): SequenceControls
+```
+
+---
+
+## `createPresenceAnimation`
+
+Manages mount/unmount lifecycle with WAAPI enter and exit animations. Pass a `target` ref accessor, a `show` signal, and enter/exit keyframes. The returned `isMounted` accessor should gate the element's presence in the DOM — the element stays mounted until its exit animation finishes.
+
+Exit keyframes default to the enter keyframes reversed. If `show` toggles back to `true` while an exit is in progress, the exit is cancelled and the enter restarts.
+
+```tsx
+const [show, setShow] = createSignal(false);
+let el!: HTMLDivElement;
+
+const { isMounted } = createPresenceAnimation(() => el, show, {
+ enter: [
+ { opacity: 0, transform: "translateY(8px)" },
+ { opacity: 1, transform: "none" },
+ ],
+ enterOptions: { duration: 250, easing: "ease-out" },
+ // exit defaults to reversed enter — fade out and slide down
+});
+
+return (
+ <>
+
+
+
Hello
+
+ >
+);
+```
+
+```tsx
+// Separate enter and exit keyframes + options
+const { isMounted } = createPresenceAnimation(() => el, show, {
+ enter: [{ opacity: 0, transform: "scale(0.95)" }, { opacity: 1, transform: "none" }],
+ exit: [{ opacity: 1, transform: "none" }, { opacity: 0, transform: "scale(0.95)" }],
+ enterOptions: { duration: 200, easing: "ease-out" },
+ exitOptions: { duration: 150, easing: "ease-in" },
+});
+```
+
+```ts
+type PresenceAnimationOptions = {
+ enter: Keyframe[] | PropertyIndexedKeyframes | null;
+ exit?: Keyframe[] | PropertyIndexedKeyframes | null; // defaults to reversed enter
+ enterOptions?: KeyframeAnimationOptions;
+ exitOptions?: KeyframeAnimationOptions; // defaults to enterOptions
+ initialEnter?: boolean; // animate on first mount (default: false)
+};
+
+function createPresenceAnimation(
+ target: Accessor,
+ show: MaybeAccessor,
+ options: PresenceAnimationOptions,
+): { isMounted: Accessor }
+```
+
+---
+
+## Changelog
+
+See [CHANGELOG.md](./CHANGELOG.md)
+
+## Related
+
+- [`@solid-primitives/presence`](https://www.npmjs.com/package/@solid-primitives/presence) — mount/unmount lifecycle coordination for CSS transitions
+- [`@solid-primitives/transition-group`](https://www.npmjs.com/package/@solid-primitives/transition-group) — `` for lists
+- [`@solid-primitives/spring`](https://www.npmjs.com/package/@solid-primitives/spring) — spring-physics value interpolation
+- [`@solid-primitives/tween`](https://www.npmjs.com/package/@solid-primitives/tween) — tween value interpolation
diff --git a/packages/animation/deno.jsonc b/packages/animation/deno.jsonc
new file mode 100644
index 000000000..e780b5572
--- /dev/null
+++ b/packages/animation/deno.jsonc
@@ -0,0 +1,24 @@
+{
+ "name": "@solid-primitives/animation",
+ "version": "1.0.0-next.0",
+ "description": "SolidJS primitives for the Web Animations API (WAAPI) — reactive wrappers for element.animate, scroll timelines, view timelines, FLIP, stagger, and animation groups.",
+ "license": "MIT",
+ "exports": "./src/index.ts",
+ "publish": {
+ "include": [
+ "README.md",
+ "LICENSE",
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "package.json"
+ ],
+ "exclude": [
+ "dist",
+ "dev",
+ "test",
+ "node_modules",
+ "vitest.config.ts",
+ "tsconfig.json"
+ ]
+ }
+}
diff --git a/packages/animation/package.json b/packages/animation/package.json
new file mode 100644
index 000000000..db61e43b0
--- /dev/null
+++ b/packages/animation/package.json
@@ -0,0 +1,78 @@
+{
+ "name": "@solid-primitives/animation",
+ "version": "1.0.0-next.0",
+ "description": "SolidJS primitives for the Web Animations API (WAAPI) — reactive wrappers for element.animate, scroll timelines, view timelines, FLIP, stagger, and animation groups.",
+ "license": "MIT",
+ "homepage": "https://primitives.solidjs.community/package/animation",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/solidjs-community/solid-primitives.git"
+ },
+ "primitive": {
+ "name": "animation",
+ "stage": 0,
+ "list": [
+ "makeAnimate",
+ "createAnimate",
+ "makeScrollAnimation",
+ "createScrollAnimation",
+ "makeViewAnimation",
+ "createViewAnimation",
+ "makeFlip",
+ "makeStagger",
+ "createStagger",
+ "makeAnimationGroup",
+ "createAnimationGroup",
+ "makeMotionPath",
+ "createMotionPath",
+ "makeSequence",
+ "createPresenceAnimation"
+ ],
+ "category": "Animation"
+ },
+ "files": [
+ "dist"
+ ],
+ "private": false,
+ "sideEffects": false,
+ "type": "module",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "browser": {},
+ "exports": {
+ "import": {
+ "@solid-primitives/source": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "keywords": [
+ "animation",
+ "animate",
+ "waapi",
+ "web-animations",
+ "scroll-timeline",
+ "view-timeline",
+ "flip",
+ "stagger",
+ "solid",
+ "solidjs"
+ ],
+ "scripts": {
+ "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts",
+ "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts",
+ "vitest": "vitest -c ../../configs/vitest.config.ts",
+ "test": "pnpm run vitest",
+ "test:ssr": "pnpm run vitest --mode ssr"
+ },
+ "dependencies": {
+ "@solid-primitives/utils": "workspace:^"
+ },
+ "peerDependencies": {
+ "solid-js": "catalog:peer"
+ },
+ "typesVersions": {},
+ "devDependencies": {
+ "solid-js": "catalog:"
+ }
+}
diff --git a/packages/animation/src/animate.ts b/packages/animation/src/animate.ts
new file mode 100644
index 000000000..339c3cb11
--- /dev/null
+++ b/packages/animation/src/animate.ts
@@ -0,0 +1,41 @@
+import { createSignal, createEffect, type Accessor } from "solid-js";
+import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils";
+
+/** Plays a WAAPI animation on `el` and returns the `Animation` instance. */
+export function makeAnimate(
+ el: Element,
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ options?: KeyframeAnimationOptions,
+): Animation {
+ return el.animate(keyframes, options);
+}
+
+/**
+ * Reactive wrapper around {@link makeAnimate}. Re-runs (cancelling the prior
+ * animation) whenever `target`, `keyframes`, or `options` change. Cancels
+ * automatically when the owner scope is disposed.
+ */
+export function createAnimate(
+ target: Accessor,
+ keyframes: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor {
+ const getKf = asAccessor(keyframes);
+ const getOpts = typeof options === "function" ? options : () => options;
+ const [animation, setAnimation] = createSignal(undefined, INTERNAL_OPTIONS);
+
+ createEffect(
+ () => ({ el: target(), kf: getKf(), opts: getOpts() }),
+ ({ el, kf, opts }) => {
+ if (!el) {
+ setAnimation(undefined);
+ return;
+ }
+ const anim = makeAnimate(el, kf, opts);
+ setAnimation(anim);
+ return () => anim.cancel();
+ },
+ );
+
+ return animation;
+}
diff --git a/packages/animation/src/animation-group.ts b/packages/animation/src/animation-group.ts
new file mode 100644
index 000000000..3b6e85c51
--- /dev/null
+++ b/packages/animation/src/animation-group.ts
@@ -0,0 +1,49 @@
+import { createMemo, type Accessor } from "solid-js";
+
+export type AnimationGroupControls = {
+ play: () => void;
+ pause: () => void;
+ cancel: () => void;
+ reverse: () => void;
+ finish: () => void;
+};
+
+/**
+ * Coordinates a static list of `Animation` objects as a single unit.
+ * Each control method is forwarded to all non-null animations simultaneously.
+ *
+ * Pairs naturally with {@link makeAnimate} and {@link makeStagger}.
+ */
+export function makeAnimationGroup(
+ animations: (Animation | null | undefined)[],
+): AnimationGroupControls {
+ const all = animations.filter((a): a is Animation => a != null);
+ return {
+ play: () => all.forEach(a => a.play()),
+ pause: () => all.forEach(a => a.pause()),
+ cancel: () => all.forEach(a => a.cancel()),
+ reverse: () => all.forEach(a => a.reverse()),
+ finish: () => all.forEach(a => a.finish()),
+ };
+}
+
+/**
+ * Reactive wrapper around {@link makeAnimationGroup}. Re-derives the group
+ * controls whenever the `animations` accessor returns a new list.
+ *
+ * Each method on the returned object always operates on the most recent set of
+ * animations — calling `play()` after the list changes will target the new
+ * animations, not the old ones.
+ */
+export function createAnimationGroup(
+ animations: Accessor<(Animation | null | undefined)[]>,
+): AnimationGroupControls {
+ const group = createMemo(() => makeAnimationGroup(animations()));
+ return {
+ play: () => group().play(),
+ pause: () => group().pause(),
+ cancel: () => group().cancel(),
+ reverse: () => group().reverse(),
+ finish: () => group().finish(),
+ };
+}
diff --git a/packages/animation/src/flip.ts b/packages/animation/src/flip.ts
new file mode 100644
index 000000000..e064bd792
--- /dev/null
+++ b/packages/animation/src/flip.ts
@@ -0,0 +1,42 @@
+/**
+ * FLIP (First–Last–Invert–Play) layout animation using WAAPI.
+ *
+ * Call `snapshot()` immediately before the DOM change, then `flip()` immediately
+ * after. `flip()` reads the new geometry, inverts the delta, and plays the
+ * animation from the old position/size to the new one.
+ *
+ * Note: geometry is measured in viewport coordinates via `getBoundingClientRect`.
+ * Elements inside `position: fixed/absolute` ancestors will need their own
+ * coordinate adjustment.
+ */
+export function makeFlip(
+ el: Element,
+ options?: KeyframeAnimationOptions,
+): { snapshot: () => void; flip: () => Animation | undefined } {
+ let rect: DOMRect | undefined;
+
+ return {
+ snapshot() {
+ rect = el.getBoundingClientRect();
+ },
+ flip() {
+ if (!rect) return;
+ const next = el.getBoundingClientRect();
+ const prev = rect;
+ rect = undefined;
+ if (next.width === 0 || next.height === 0) return;
+ const dx = prev.left - next.left;
+ const dy = prev.top - next.top;
+ const sx = prev.width / next.width;
+ const sy = prev.height / next.height;
+ if (dx === 0 && dy === 0 && sx === 1 && sy === 1) return;
+ return el.animate(
+ [
+ { transformOrigin: "top left", transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` },
+ { transformOrigin: "top left", transform: "none" },
+ ],
+ options,
+ );
+ },
+ };
+}
diff --git a/packages/animation/src/index.ts b/packages/animation/src/index.ts
new file mode 100644
index 000000000..0b4736e44
--- /dev/null
+++ b/packages/animation/src/index.ts
@@ -0,0 +1,9 @@
+export * from "./animate.ts";
+export * from "./scroll-animation.ts";
+export * from "./view-animation.ts";
+export * from "./flip.ts";
+export * from "./stagger.ts";
+export * from "./animation-group.ts";
+export * from "./motion-path.ts";
+export * from "./sequence.ts";
+export * from "./presence-animation.ts";
diff --git a/packages/animation/src/motion-path.ts b/packages/animation/src/motion-path.ts
new file mode 100644
index 000000000..e483ea22e
--- /dev/null
+++ b/packages/animation/src/motion-path.ts
@@ -0,0 +1,61 @@
+import { createSignal, createEffect, type Accessor } from "solid-js";
+import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils";
+
+export type MotionPathOptions = KeyframeAnimationOptions & {
+ /**
+ * CSS `offset-rotate` value controlling element orientation along the path.
+ * Pass `"auto"` to rotate with the path tangent, `"0deg"` to keep the
+ * element's original orientation, or any CSS angle / `"reverse"`.
+ * Default: `"auto"`.
+ */
+ rotate?: string;
+};
+
+/**
+ * Animates `el` along a CSS Motion Path using WAAPI. Sets `offset-path` and
+ * `offset-rotate` on the element as a side effect; these are left in place
+ * after the animation so `fill: "forwards"` works correctly.
+ *
+ * @param path SVG path string passed to `path("…")`, or any valid
+ * `offset-path` value (e.g. `"circle(50%)"`, `"ray(45deg)"`)
+ */
+export function makeMotionPath(
+ el: HTMLElement,
+ path: string,
+ options?: MotionPathOptions,
+): Animation {
+ const { rotate = "auto", ...animOptions } = options ?? {};
+ el.style.offsetPath = path.includes("(") ? path : `path("${path}")`;
+ el.style.offsetRotate = rotate;
+ el.style.offsetAnchor = "center";
+ return el.animate(
+ [{ offsetDistance: "0%" }, { offsetDistance: "100%" }],
+ animOptions,
+ );
+}
+
+/**
+ * Reactive wrapper around {@link makeMotionPath}. Re-runs whenever
+ * `target`, `path`, or `options` change.
+ */
+export function createMotionPath(
+ target: Accessor,
+ path: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor {
+ const getPath = asAccessor(path);
+ const getOpts = typeof options === "function" ? options : () => options;
+ const [animation, setAnimation] = createSignal(undefined, INTERNAL_OPTIONS);
+
+ createEffect(
+ () => ({ el: target(), path: getPath(), opts: getOpts() }),
+ ({ el, path, opts }) => {
+ if (!el) { setAnimation(undefined); return; }
+ const anim = makeMotionPath(el, path, opts);
+ setAnimation(anim);
+ return () => anim.cancel();
+ },
+ );
+
+ return animation;
+}
diff --git a/packages/animation/src/presence-animation.ts b/packages/animation/src/presence-animation.ts
new file mode 100644
index 000000000..c227bfaf1
--- /dev/null
+++ b/packages/animation/src/presence-animation.ts
@@ -0,0 +1,130 @@
+import { createSignal, createEffect, untrack, type Accessor } from "solid-js";
+import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils";
+
+export type PresenceAnimationOptions = {
+ /** Keyframes played when the element enters. */
+ enter: Keyframe[] | PropertyIndexedKeyframes | null;
+ /**
+ * Keyframes played when the element exits.
+ * Defaults to the enter keyframes in reverse.
+ */
+ exit?: Keyframe[] | PropertyIndexedKeyframes | null;
+ /** WAAPI options for the enter animation. */
+ enterOptions?: KeyframeAnimationOptions;
+ /**
+ * WAAPI options for the exit animation.
+ * Defaults to `enterOptions`.
+ */
+ exitOptions?: KeyframeAnimationOptions;
+ /**
+ * Play the enter animation on the initial mount when `show` is already
+ * `true`. Defaults to `false`.
+ */
+ initialEnter?: boolean;
+};
+
+function reverseKeyframes(
+ kf: Keyframe[] | PropertyIndexedKeyframes | null,
+): Keyframe[] | PropertyIndexedKeyframes | null {
+ if (!kf) return kf;
+ if (Array.isArray(kf)) return [...kf].reverse();
+ const reversed: PropertyIndexedKeyframes = {};
+ for (const key in kf as PropertyIndexedKeyframes) {
+ const val = (kf as PropertyIndexedKeyframes)[key];
+ (reversed as Record)[key] = Array.isArray(val) ? [...val].reverse() : val;
+ }
+ return reversed;
+}
+
+// ─── createPresenceAnimation ─────────────────────────────────────────────────
+
+/**
+ * Manages mount/unmount lifecycle with WAAPI enter and exit animations.
+ *
+ * `isMounted` should gate the element's presence in the DOM (e.g. as the
+ * `when` prop of ``). The enter animation plays after the element
+ * mounts; the exit animation plays on the element before it is removed, and
+ * the element stays in the DOM until that animation completes.
+ *
+ * If `show` toggles back to `true` while an exit animation is in progress,
+ * the exit is cancelled and the enter animation restarts.
+ *
+ * @example
+ * ```tsx
+ * const [show, setShow] = createSignal(false);
+ * let el!: HTMLDivElement;
+ *
+ * const { isMounted } = createPresenceAnimation(() => el, show, {
+ * enter: [{ opacity: 0, transform: "translateY(8px)" }, { opacity: 1, transform: "none" }],
+ * enterOptions: { duration: 250, easing: "ease-out", fill: "both" },
+ * exitOptions: { duration: 180, easing: "ease-in", fill: "forwards" },
+ * });
+ *
+ * return (
+ * <>
+ *
+ *
+ *
Hello
+ *
+ * >
+ * );
+ * ```
+ */
+export function createPresenceAnimation(
+ target: Accessor,
+ show: MaybeAccessor,
+ options: PresenceAnimationOptions,
+): { isMounted: Accessor } {
+ const getShow = asAccessor(show);
+ const [isMounted, setIsMounted] = createSignal(untrack(getShow), INTERNAL_OPTIONS);
+
+ let enterGen = 0;
+
+ const scheduleEnter = () => {
+ const gen = ++enterGen;
+ queueMicrotask(() => {
+ if (gen !== enterGen) return;
+ const el = untrack(target);
+ if (!el) return;
+ el.animate(options.enter, options.enterOptions);
+ });
+ };
+
+ if (options.initialEnter && untrack(getShow)) {
+ scheduleEnter();
+ }
+
+ createEffect(
+ () => getShow(),
+ shouldShow => {
+ if (shouldShow) {
+ setIsMounted(true);
+ scheduleEnter();
+ } else {
+ enterGen++;
+ const el = untrack(target);
+ if (!el) {
+ setIsMounted(false);
+ return;
+ }
+ const exitKf = options.exit ?? reverseKeyframes(options.enter);
+ const anim = el.animate(exitKf, options.exitOptions ?? options.enterOptions);
+ let done = false;
+ anim.addEventListener(
+ "finish",
+ () => {
+ done = true;
+ setIsMounted(false);
+ },
+ { once: true },
+ );
+ return () => {
+ if (!done) anim.cancel();
+ };
+ }
+ },
+ { defer: true },
+ );
+
+ return { isMounted };
+}
diff --git a/packages/animation/src/scroll-animation.ts b/packages/animation/src/scroll-animation.ts
new file mode 100644
index 000000000..b45625ded
--- /dev/null
+++ b/packages/animation/src/scroll-animation.ts
@@ -0,0 +1,56 @@
+import { createSignal, createEffect, type Accessor } from "solid-js";
+import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils";
+
+type ScrollAxis = "block" | "inline" | "x" | "y";
+declare const ScrollTimeline: new (options?: { source?: Element; axis?: ScrollAxis }) => AnimationTimeline;
+
+export type ScrollAnimationOptions = Omit & {
+ /** Scrolling container. Defaults to the document root scroller. */
+ source?: Element;
+ axis?: ScrollAxis;
+};
+
+/** Plays a scroll-driven WAAPI animation on `el` via `ScrollTimeline`. */
+export function makeScrollAnimation(
+ el: Element,
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ options?: ScrollAnimationOptions,
+): Animation {
+ const { source, axis, ...animOptions } = options ?? {};
+ return el.animate(keyframes, {
+ ...animOptions,
+ timeline: new ScrollTimeline({
+ ...(source !== undefined && { source }),
+ ...(axis !== undefined && { axis }),
+ }),
+ });
+}
+
+/**
+ * Reactive wrapper around {@link makeScrollAnimation}. Re-runs whenever
+ * `target`, `keyframes`, or `options` change.
+ */
+export function createScrollAnimation(
+ target: Accessor,
+ keyframes: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor {
+ const getKf = asAccessor(keyframes);
+ const getOpts = typeof options === "function" ? options : () => options;
+ const [animation, setAnimation] = createSignal(undefined, INTERNAL_OPTIONS);
+
+ createEffect(
+ () => ({ el: target(), kf: getKf(), opts: getOpts() }),
+ ({ el, kf, opts }) => {
+ if (!el) {
+ setAnimation(undefined);
+ return;
+ }
+ const anim = makeScrollAnimation(el, kf, opts);
+ setAnimation(anim);
+ return () => anim.cancel();
+ },
+ );
+
+ return animation;
+}
diff --git a/packages/animation/src/sequence.ts b/packages/animation/src/sequence.ts
new file mode 100644
index 000000000..63544aa8b
--- /dev/null
+++ b/packages/animation/src/sequence.ts
@@ -0,0 +1,62 @@
+/** A zero-argument factory that creates an `Animation` when called. */
+export type AnimationFactory = () => Animation | null | undefined;
+
+export type SequenceControls = {
+ /** Starts the sequence from the first factory. Discards any in-progress run. */
+ play: () => void;
+ /** Stops the sequence. The currently-playing animation is cancelled. */
+ cancel: () => void;
+};
+
+/**
+ * Chains animation factories into a sequential playlist: each factory is
+ * called and its animation allowed to finish before the next factory runs.
+ *
+ * Factories are invoked **lazily** — each is called only when its turn
+ * arrives, so animations are created and started just in time rather than
+ * all at once upfront. Passing `null`/`undefined` from a factory skips that
+ * step without breaking the chain.
+ *
+ * Calling `play()` while a sequence is running discards the current run and
+ * starts fresh from the beginning.
+ *
+ * @example
+ * ```ts
+ * const seq = makeSequence([
+ * () => makeAnimate(headerEl, fadeIn, { duration: 300 }),
+ * () => makeAnimate(bodyEl, slideIn, { duration: 400 }),
+ * () => makeAnimate(footerEl, fadeIn, { duration: 300 }),
+ * ]);
+ *
+ * seq.play(); // header → body → footer, each starts after the last finishes
+ * seq.cancel(); // stops immediately
+ * seq.play(); // restart from the beginning
+ * ```
+ */
+export function makeSequence(factories: AnimationFactory[]): SequenceControls {
+ let generation = 0;
+ let current: Animation | null = null;
+
+ return {
+ play() {
+ current?.cancel();
+ const gen = ++generation;
+ let i = 0;
+
+ const next = () => {
+ if (gen !== generation || i >= factories.length) return;
+ const anim = factories[i++]!();
+ if (!anim) { next(); return; }
+ current = anim;
+ anim.addEventListener("finish", next, { once: true });
+ };
+
+ next();
+ },
+ cancel() {
+ generation++;
+ current?.cancel();
+ current = null;
+ },
+ };
+}
diff --git a/packages/animation/src/stagger.ts b/packages/animation/src/stagger.ts
new file mode 100644
index 000000000..6265f0853
--- /dev/null
+++ b/packages/animation/src/stagger.ts
@@ -0,0 +1,43 @@
+import { createSignal, createEffect, type Accessor } from "solid-js";
+import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils";
+
+export type StaggerOptions = KeyframeAnimationOptions & {
+ /** Additional delay in milliseconds between each element, stacked on top of `delay`. */
+ stagger?: number;
+};
+
+/** Plays a staggered WAAPI animation across `els`, returning all `Animation` instances. */
+export function makeStagger(
+ els: Element[],
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ options?: StaggerOptions,
+): Animation[] {
+ const { stagger = 0, ...animOptions } = options ?? {};
+ const baseDelay = typeof animOptions.delay === "number" ? animOptions.delay : 0;
+ return els.map((el, i) => el.animate(keyframes, { ...animOptions, delay: baseDelay + i * stagger }));
+}
+
+/**
+ * Reactive wrapper around {@link makeStagger}. Re-runs (cancelling previous
+ * animations) whenever `targets`, `keyframes`, or `options` change reactively.
+ */
+export function createStagger(
+ targets: Accessor<(Element | null | undefined)[]>,
+ keyframes: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor {
+ const getKf = asAccessor(keyframes);
+ const getOpts = typeof options === "function" ? options : () => options;
+ const [animations, setAnimations] = createSignal([], INTERNAL_OPTIONS);
+
+ createEffect(
+ () => ({ els: targets(), kf: getKf(), opts: getOpts() }),
+ ({ els, kf, opts }) => {
+ const anims = makeStagger(els.filter((el): el is Element => el != null), kf, opts);
+ setAnimations(anims);
+ return () => anims.forEach(a => a.cancel());
+ },
+ );
+
+ return animations;
+}
diff --git a/packages/animation/src/view-animation.ts b/packages/animation/src/view-animation.ts
new file mode 100644
index 000000000..aa157be1c
--- /dev/null
+++ b/packages/animation/src/view-animation.ts
@@ -0,0 +1,79 @@
+import { createSignal, createEffect, type Accessor } from "solid-js";
+import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils";
+
+type ScrollAxis = "block" | "inline" | "x" | "y";
+declare const ViewTimeline: new (options: {
+ subject: Element;
+ axis?: ScrollAxis;
+ inset?: string | string[];
+}) => AnimationTimeline;
+
+export type ViewAnimationOptions = Omit & {
+ /**
+ * The element whose intersection with the scroll port drives the timeline.
+ * Defaults to `target` itself.
+ */
+ subject?: Element;
+ axis?: ScrollAxis;
+ inset?: string | string[];
+ /**
+ * Start of the animation range within the ViewTimeline.
+ * Defaults to `"entry 0%"` (element starts entering the scroll port).
+ * Accepts any CSS `` string, e.g. `"cover 0%"`.
+ */
+ rangeStart?: string;
+ /**
+ * End of the animation range within the ViewTimeline.
+ * Defaults to `"entry 100%"` (element has fully entered the scroll port).
+ * Accepts any CSS `` string, e.g. `"cover 100%"`.
+ */
+ rangeEnd?: string;
+};
+
+/** Plays a viewport-driven WAAPI animation on `el` via `ViewTimeline`. */
+export function makeViewAnimation(
+ el: Element,
+ keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
+ options?: ViewAnimationOptions,
+): Animation {
+ const { subject, axis, inset, rangeStart = "entry 0%", rangeEnd = "entry 100%", ...animOptions } = options ?? {};
+ return el.animate(keyframes, {
+ rangeStart,
+ rangeEnd,
+ ...animOptions,
+ timeline: new ViewTimeline({
+ subject: subject ?? el,
+ ...(axis !== undefined && { axis }),
+ ...(inset !== undefined && { inset }),
+ }),
+ } as KeyframeAnimationOptions);
+}
+
+/**
+ * Reactive wrapper around {@link makeViewAnimation}. Re-runs whenever
+ * `target`, `keyframes`, or `options` change.
+ */
+export function createViewAnimation(
+ target: Accessor,
+ keyframes: MaybeAccessor,
+ options?: MaybeAccessor,
+): Accessor {
+ const getKf = asAccessor(keyframes);
+ const getOpts = typeof options === "function" ? options : () => options;
+ const [animation, setAnimation] = createSignal(undefined, INTERNAL_OPTIONS);
+
+ createEffect(
+ () => ({ el: target(), kf: getKf(), opts: getOpts() }),
+ ({ el, kf, opts }) => {
+ if (!el) {
+ setAnimation(undefined);
+ return;
+ }
+ const anim = makeViewAnimation(el, kf, opts);
+ setAnimation(anim);
+ return () => anim.cancel();
+ },
+ );
+
+ return animation;
+}
diff --git a/packages/animation/stories/animation.stories.tsx b/packages/animation/stories/animation.stories.tsx
new file mode 100644
index 000000000..510aa8c94
--- /dev/null
+++ b/packages/animation/stories/animation.stories.tsx
@@ -0,0 +1,1156 @@
+import { createEffect, createSignal, For, onSettled, Show } from "solid-js";
+import preview from "../../../.storybook/preview.js";
+import {
+ createAnimate,
+ makeScrollAnimation,
+ makeViewAnimation,
+ makeFlip,
+ makeStagger,
+ makeAnimationGroup,
+ makeAnimate,
+ makeMotionPath,
+ makeSequence,
+ createPresenceAnimation,
+} from "@solid-primitives/animation";
+import readme from "../README.md?raw";
+import { Button, ButtonRow, Container, Section, StatRow } from "../../../.storybook/ui/index.js";
+
+const meta = preview.meta({
+ title: "Animation/Animation",
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: { component: readme },
+ },
+ },
+});
+
+export default meta;
+
+const ACCENT_COLORS = ["#6366f1", "#ec4899", "#f59e0b", "#10b981", "#3b82f6"] as const;
+
+export const Animate = meta.story({
+ name: "Animate an element",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`createAnimate` re-runs the animation whenever `target`, `keyframes`, or `options` change. " +
+ "Tap a swatch to change the hue mid-animation — the keyframes accessor re-evaluates and the " +
+ "animation restarts. The returned `Animation` accessor gives direct playback control.",
+ },
+ },
+ },
+ render: () => {
+ let boxRef!: HTMLDivElement;
+ const [target, setTarget] = createSignal(null);
+ const [color, setColor] = createSignal(ACCENT_COLORS[0]);
+ const [playState, setPlayState] = createSignal("idle");
+
+ const anim = createAnimate(
+ target,
+ () => [
+ { transform: "scale(1)", background: color() },
+ { transform: "scale(1.3)", background: color(), filter: "brightness(1.3)" },
+ { transform: "scale(1)", background: color() },
+ ],
+ { duration: 1200, iterations: Infinity, easing: "ease-in-out" },
+ );
+
+ createEffect(
+ () => anim(),
+ a => {
+ if (!a) {
+ setPlayState("idle");
+ return;
+ }
+ setPlayState(a.playState);
+ const update = () => setPlayState(a.playState);
+ a.addEventListener("play", update);
+ a.addEventListener("pause", update);
+ a.addEventListener("cancel", update);
+ a.addEventListener("finish", update);
+ return () => {
+ a.removeEventListener("play", update);
+ a.removeEventListener("pause", update);
+ a.removeEventListener("cancel", update);
+ a.removeEventListener("finish", update);
+ };
+ },
+ );
+
+ onSettled(() => {
+ setTarget(boxRef);
+ });
+
+ return (
+
+