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 bf1aad04a..28c593f21 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
[](https://pnpm.io/)
[](https://vitest.dev)
-[](https://dash.deno.com/playground/combined-npm-downloads)
+[](https://dash.deno.com/playground/combined-npm-downloads)
Solid Primitives is a project dedicated to building high-quality, community-contributed primitives for SolidJS. 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.
@@ -139,6 +139,7 @@ See the [CHANGELOG](https://github.com/solidjs-community/solid-primitives/tree/n
|[orientation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/orientation#readme)|[](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[makeOrientation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/orientation#makeorientation) [createOrientation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/orientation#createorientation)|[](https://bundlephobia.com/package/@solid-primitives/orientation)|[](https://www.npmjs.com/package/@solid-primitives/orientation)|✓|
|[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*
|
+|[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)|✓|
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..742cc2784
--- /dev/null
+++ b/packages/animation/README.md
@@ -0,0 +1,357 @@
+
+
+
+
+# @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). Each primitive follows the `make` / `create`.
+## 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` |
+| [`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
+};
+
+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
+```
+
+---
+
+## `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/package.json b/packages/animation/package.json
new file mode 100644
index 000000000..042f3038d
--- /dev/null
+++ b/packages/animation/package.json
@@ -0,0 +1,80 @@
+{
+ "name": "@solid-primitives/animation",
+ "version": "0.0.1",
+ "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",
+ "createPresenceA",
+ "createPresenceB"
+ ],
+ "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": "^2.0.0-beta.15"
+ },
+ "typesVersions": {},
+ "devDependencies": {
+ "solid-js": "2.0.0-beta.15"
+ }
+}
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..c283b01b0
--- /dev/null
+++ b/packages/animation/src/index.ts
@@ -0,0 +1,9 @@
+export * from "./animate.js";
+export * from "./scroll-animation.js";
+export * from "./view-animation.js";
+export * from "./flip.js";
+export * from "./stagger.js";
+export * from "./animation-group.js";
+export * from "./motion-path.js";
+export * from "./sequence.js";
+export * from "./presence-animation.js";
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..c7a8fb614
--- /dev/null
+++ b/packages/animation/src/presence-animation.ts
@@ -0,0 +1,505 @@
+import {
+ createSignal,
+ createEffect,
+ createRenderEffect,
+ createMemo,
+ untrack,
+ isPending,
+ 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 };
+}
+
+// ─── createPresenceA ─────────────────────────────────────────────────────────
+
+/**
+ * `createPresenceA` — deferred-`isMounted` approach.
+ *
+ * The component's reactive scope stays alive during the exit animation because
+ * `isMounted` only goes `false` after the WAAPI `.finish` event fires. Gate
+ * `` on `isMounted()`, not on the raw `show` value.
+ *
+ * Uses `createRenderEffect` so animation setup runs in the render phase —
+ * before user effects in the same flush — keeping it tight with DOM updates.
+ *
+ * @example
+ * ```tsx
+ * const { isMounted, isExiting } = createPresenceA(() => el, show, {
+ * enter: [{ opacity: 0 }, { opacity: 1 }],
+ * enterOptions: { duration: 250 },
+ * exitOptions: { duration: 180 },
+ * });
+ *
+ * return (
+ *
+ *
+ * // signals stay alive during exit
+ *
+ *
+ * );
+ * ```
+ */
+export function createPresenceA(
+ target: Accessor,
+ show: MaybeAccessor,
+ options: PresenceAnimationOptions,
+): { isMounted: Accessor; isExiting: Accessor } {
+ const getShow = asAccessor(show);
+ const [isMounted, setIsMounted] = createSignal(untrack(getShow), INTERNAL_OPTIONS);
+ const [isExiting, setIsExiting] = createSignal(false, INTERNAL_OPTIONS);
+
+ let enterGen = 0;
+
+ if (options.initialEnter && untrack(getShow)) {
+ const gen = ++enterGen;
+ queueMicrotask(() => {
+ if (gen !== enterGen) return;
+ const el = untrack(target);
+ if (!el) return;
+ el.animate(options.enter, options.enterOptions);
+ });
+ }
+
+ // createRenderEffect runs its apply in the render phase, before user effects,
+ // so animation setup is tightly coupled to the DOM-update cycle.
+ // The `prev === undefined` guard skips the initial synchronous apply.
+ createRenderEffect(
+ () => getShow(),
+ (shouldShow, prev) => {
+ if (prev === undefined) return; // skip initial mount
+ if (shouldShow) {
+ setIsMounted(true);
+ setIsExiting(false);
+ const gen = ++enterGen;
+ queueMicrotask(() => {
+ if (gen !== enterGen) return;
+ const el = untrack(target);
+ if (!el) return;
+ el.animate(options.enter, options.enterOptions);
+ });
+ } else {
+ enterGen++;
+ setIsExiting(true);
+ const el = untrack(target);
+ if (!el) {
+ setIsExiting(false);
+ 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;
+ setIsExiting(false);
+ setIsMounted(false);
+ },
+ { once: true },
+ );
+ return () => {
+ if (!done) {
+ anim.cancel();
+ setIsExiting(false);
+ }
+ };
+ }
+ },
+ );
+
+ return { isMounted, isExiting };
+}
+
+// ─── createPresenceB ─────────────────────────────────────────────────────────
+
+/**
+ * `createPresenceB` — Solid 2.0 deferred-disposal approach via async signal.
+ *
+ * The component's reactive scope stays alive during the exit animation through
+ * Solid 2.0's built-in pending-signal mechanism:
+ *
+ * 1. `gate` is a **non-sync `createMemo`** (no `sync: true`).
+ * When exiting, it returns a `Promise` instead of `false`.
+ * 2. Returning a Promise causes `handleAsync` to throw `NotReadyError`,
+ * which puts `gate` into `STATUS_PENDING`.
+ * 3. `notifyStatus(STATUS_PENDING)` propagates to `gate`'s subscribers via
+ * `queuePendingNode` — **not** `insertSubs` / dirty-heap marking.
+ * 4. Show's internal `createRenderEffect` ends up in `_pendingNodes` only.
+ * It is never recomputed, so `_pendingFirstChild` is never set.
+ * 5. `commitPendingNode` finds no zombie children → the component owner is
+ * **not disposed** — all signals and event handlers stay alive. ✓
+ * 6. When the WAAPI animation finishes, the Promise resolves. `asyncWrite`
+ * sets `gate._value = undefined` and calls `insertSubs`, which makes Show
+ * recompute and finally remove the component.
+ *
+ * Use `gate()` (not the raw `show` value) as the `` gate.
+ * `createRenderEffect` is used for the enter animation so it runs in the
+ * render phase — the same phase Show uses — before any user effects.
+ *
+ * @example
+ * ```tsx
+ * const { gate, isExiting } = createPresenceB(() => el, show, {
+ * enter: [{ opacity: 0 }, { opacity: 1 }],
+ * exitOptions: { duration: 1500 },
+ * });
+ *
+ * return (
+ *
+ *
+ * // signals alive — disposed only after animation resolves
+ *
+ *
+ * );
+ * ```
+ */
+export function createPresenceB(
+ target: Accessor,
+ show: MaybeAccessor,
+ options: PresenceAnimationOptions,
+): { gate: Accessor; isExiting: Accessor } {
+ const getShow = asAccessor(show);
+ // exitAnimRunning tracks whether the WAAPI exit animation is physically playing.
+ // isExiting is derived: it's automatically false whenever getShow() is true,
+ // so no effect is needed to reset it when show flips back — the memo re-derives
+ // the correct value synchronously in the same flush as the signal write.
+ const [exitAnimRunning, setExitAnimRunning] = createSignal(false, INTERNAL_OPTIONS);
+ const isExiting = createMemo(() => !getShow() && exitAnimRunning());
+
+ let exitAnim: Animation | undefined;
+ let enterAnim: Animation | undefined;
+ let animPromise: Promise | undefined;
+ let enterGen = 0;
+ let exitGen = 0;
+ // exitCompleted prevents the gate from re-creating the exit Promise after the
+ // animation finishes. Without it, asyncWrite triggers a gate recompute where
+ // animPromise is already undefined → new Promise created → infinite exit loop.
+ let exitCompleted = false;
+ // Track whether this instance has ever been shown. Before the first mount,
+ // the gate should return plain `false` rather than a Promise — returning a
+ // Promise on a never-mounted slide would trigger handleAsync/initTransition
+ // during the initial render and can prevent other slides from appearing.
+ let hasBeenShown = untrack(getShow);
+
+ // Non-sync memo (no `sync: true`) — can return a Promise, making it
+ // STATUS_PENDING via handleAsync. When pending, notifyStatus propagates to
+ // Show's internal createRenderEffect via queuePendingNode (not insertSubs),
+ // so that render effect is never recomputed and its component owner is
+ // never touched. See JSDoc above for the full step-by-step.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const gate = createMemo((): any => {
+ const shouldShow = !!getShow();
+
+ if (shouldShow) {
+ hasBeenShown = true;
+ exitCompleted = false;
+ // Increment exitGen to cancel any pending exit microtask that hasn't
+ // fired yet. Without this, the microtask starts the exit animation even
+ // though show is already true again — causing both exit and enter
+ // animations to compete on the same element.
+ exitGen++;
+ if (exitAnim) {
+ exitAnim.cancel();
+ exitAnim = undefined;
+ }
+ // Always clear animPromise, not just when exitAnim was set. If the exit
+ // microtask hasn't run yet exitAnim is undefined, but animPromise holds
+ // the stale Promise. Clearing it here prevents asyncWrite from running
+ // after the stale microtask calls resolve().
+ animPromise = undefined;
+ return true;
+ }
+
+ // If this instance was never mounted, just return false — no exit
+ // animation is needed and no Promise should be created.
+ if (!hasBeenShown) return false;
+
+ // After a completed exit, return false cleanly so Show can dispose the
+ // component. Without this guard the gate would create a new Promise every
+ // time asyncWrite re-triggers it, looping forever.
+ if (exitCompleted) return false;
+
+ // Create the exit Promise once. Returning the same Promise object on
+ // subsequent recomputes is safe: handleAsync guards stale asyncWrite
+ // callbacks via `el._inFlight !== result`.
+ if (!animPromise) {
+ // Cancel any pending enter microtask (gen check) AND any enter animation
+ // that already started. Without the latter, a running enter animation
+ // competes with the new exit animation on the same element.
+ enterGen++;
+ if (enterAnim) {
+ enterAnim.cancel();
+ enterAnim = undefined;
+ }
+ const gen = ++exitGen;
+ animPromise = new Promise(resolve => {
+ queueMicrotask(() => {
+ // If show flipped back to true before this microtask ran, exitGen
+ // was already incremented in the shouldShow=true branch above.
+ if (gen !== exitGen) {
+ resolve(); // stale — asyncWrite is a no-op (guarded by _inFlight)
+ return;
+ }
+ const el = untrack(target);
+ if (!el) {
+ exitCompleted = true;
+ animPromise = undefined;
+ setExitAnimRunning(false);
+ resolve();
+ return;
+ }
+ setExitAnimRunning(true);
+ const exitKf = options.exit ?? reverseKeyframes(options.enter);
+ const anim = el.animate(exitKf, options.exitOptions ?? options.enterOptions);
+ exitAnim = anim;
+ anim.addEventListener(
+ "finish",
+ () => {
+ if (exitAnim === anim) exitAnim = undefined;
+ animPromise = undefined;
+ exitCompleted = true;
+ setExitAnimRunning(false);
+ resolve(); // → asyncWrite → gate._value = undefined → Show removes component
+ },
+ { once: true },
+ );
+ // When the exit animation is cancelled (show flipped true mid-exit), the
+ // finish event never fires. Listen to cancel to reset exitAnimRunning so
+ // the signal matches reality. Guard against the case where a newer exit
+ // animation is already running when this event fires.
+ anim.addEventListener(
+ "cancel",
+ () => {
+ if (exitAnim === anim) exitAnim = undefined;
+ setExitAnimRunning(false);
+ },
+ { once: true },
+ );
+ });
+ });
+ }
+
+ return animPromise;
+ }) as unknown as Accessor;
+
+ // isExiting is derived (memo), so it resets automatically when getShow() goes
+ // true — no manual reset needed. This effect only handles the enter animation.
+ createEffect(
+ () => getShow(),
+ shouldShow => {
+ if (!shouldShow) return;
+ const gen = ++enterGen;
+ queueMicrotask(() => {
+ if (gen !== enterGen) return;
+ const el = untrack(target);
+ if (!el) return;
+ enterAnim = el.animate(options.enter, options.enterOptions);
+ enterAnim.addEventListener(
+ "finish",
+ () => {
+ enterAnim = undefined;
+ },
+ { once: true },
+ );
+ });
+ },
+ { defer: true },
+ );
+
+ if (options.initialEnter && untrack(getShow)) {
+ const gen = ++enterGen;
+ queueMicrotask(() => {
+ if (gen !== enterGen) return;
+ const el = untrack(target);
+ if (!el) return;
+ enterAnim = el.animate(options.enter, options.enterOptions);
+ enterAnim.addEventListener(
+ "finish",
+ () => {
+ enterAnim = undefined;
+ },
+ { once: true },
+ );
+ });
+ }
+
+ return { gate, isExiting };
+}
+
+export function createPresenceC(
+ target: Accessor,
+ show: Accessor,
+ options: any,
+) {
+ let animationP: Promise | undefined = undefined;
+
+ const isMounted = createMemo(async () => {
+ if (show()) {
+ return true;
+ } else {
+ if (!animationP) return false;
+ await animationP;
+ return false;
+ }
+ });
+
+ const isEntered = createMemo(async () => {
+ if (show()) {
+ if (!animationP) return true;
+ await animationP;
+ return true;
+ } else {
+ return false;
+ }
+ });
+
+ const isEntering = () => isPending(isEntered);
+ const isExiting = () => isPending(isMounted);
+
+ createEffect(show, show => {
+ const anim = show
+ ? target().animate(options.enter, options.enterOptions)
+ : target().animate(options.exit, options.exitOptions ?? options.enterOptions);
+
+ animationP = new Promise(resolve => {
+ anim.addEventListener(
+ "finish",
+ () => {
+ animationP = undefined;
+ resolve();
+ },
+ { once: true },
+ );
+ });
+ return () => animationP && anim.cancel();
+ });
+
+ return { isMounted, isEntered, isEntering, isExiting };
+}
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..71e6faafa
--- /dev/null
+++ b/packages/animation/stories/animation.stories.tsx
@@ -0,0 +1,2171 @@
+import { createEffect, createSignal, For, onCleanup, onSettled, Show } from "solid-js";
+import preview from "../../../.storybook/preview.js";
+import {
+ createAnimate,
+ makeScrollAnimation,
+ makeViewAnimation,
+ makeFlip,
+ makeStagger,
+ makeAnimationGroup,
+ makeAnimate,
+ makeMotionPath,
+ makeSequence,
+ createPresenceAnimation,
+ createPresenceA,
+ createPresenceB,
+ createPresenceC,
+} 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 (
+
+
+
+ );
+};
+
+export const PresenceBStory = meta.story({
+ name: "createPresenceB — deferred disposal via async gate",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`createPresenceB` uses Solid 2.0's built-in deferred-disposal mechanism. When `show` goes " +
+ "false, the `gate` memo returns a `Promise` instead of `false`. Returning a Promise causes " +
+ "`handleAsync` to throw `NotReadyError`, putting `gate` into `STATUS_PENDING`. " +
+ "`notifyStatus` propagates to Show's internal `createRenderEffect` via `queuePendingNode` " +
+ "(not the dirty heap), so Show never recomputes and the component owner is **not disposed**. " +
+ "When the WAAPI animation finishes the Promise resolves, `asyncWrite` makes Show recompute, " +
+ "and the component is finally removed. Each slide below has its own `count` signal — " +
+ "increment it, switch slides, and observe the old value during the 1.4 s exit.",
+ },
+ },
+ },
+ render: () => {
+ const [active, setActive] = createSignal(SLIDES[0]);
+
+ return (
+
+
createPresenceB
+
+ Increment a counter, then switch slides. The old slide's count persists during its 1.4 s
+ exit — the component owner is deferred via an async gate memo.
+
+
+
+
+ {slide => (
+
+ )}
+
+
+
+ {/* Stacked so entering and exiting slides overlap during crossfade */}
+
+ {slide => }
+
+
+
+ active: {active().label}
+
+
+ );
+ },
+});
+
+// ─── createPresenceB diagnostic / stress-test ────────────────────────────────
+
+// Card defined at module level for a stable reactive scope (same pattern as SlideCardB).
+// Props receive gate/isExiting from the parent so the parent can read them for diagnostics.
+const DiagCard = (props: {
+ ref: (el: HTMLDivElement) => void;
+ label: string;
+ color: string;
+ isExiting: () => boolean;
+}) => {
+ const [count, setCount] = createSignal(0);
+ const c = props.color;
+
+ return (
+
+
+ {props.label}
+
+
+
+
+
+ {count()}
+
+
+
+
+
+
+ EXITING
+
+
+
+ );
+};
+
+export const PresenceBDiagnostic = meta.story({
+ name: "createPresenceB — rapid-toggle diagnostic",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Stress-test harness for `createPresenceB`. Click tabs rapidly or hit **Stress test** " +
+ "to fire 20 toggles at 80 ms intervals — far faster than the 1.2 s exit animation. " +
+ "The diagnostic row shows each slide's live reactive state derived from `isExiting()` " +
+ "and the `active` signal. A healthy run always has exactly one slide **active** or " +
+ "**exiting**. If both go **–** simultaneously, or a slide stays stuck as **exiting** " +
+ "while it should be **active**, the primitive has glitched.",
+ },
+ },
+ },
+ render: () => {
+ const [active, setActive] = createSignal(0);
+ const [stressRunning, setStressRunning] = createSignal(false);
+
+ // Refs live in parent scope so createPresenceB can capture them while also
+ // allowing isExiting/gate to be read at the parent level for the diagnostic row.
+ let alphaEl: HTMLDivElement | undefined;
+ let betaEl: HTMLDivElement | undefined;
+
+ const DIAG_OPTS = {
+ enter: [
+ { opacity: 0, transform: "translateX(18px)" },
+ { opacity: 1, transform: "none" },
+ ],
+ exit: [
+ { opacity: 1, transform: "none" },
+ { opacity: 0, transform: "translateX(-18px)" },
+ ],
+ enterOptions: { duration: 350, easing: "ease-out", fill: "both" as FillMode },
+ // Long exit so rapid clicks reliably interrupt the animation mid-flight.
+ exitOptions: { duration: 1200, easing: "ease-in", fill: "forwards" as FillMode },
+ initialEnter: true,
+ };
+
+ const { gate: alphaGate, isExiting: alphaExiting } = createPresenceB(
+ () => alphaEl,
+ () => active() === 0,
+ DIAG_OPTS,
+ );
+
+ const { gate: betaGate, isExiting: betaExiting } = createPresenceB(
+ () => betaEl,
+ () => active() === 1,
+ DIAG_OPTS,
+ );
+
+ let stressTimer: ReturnType | undefined;
+
+ const runStress = () => {
+ clearInterval(stressTimer);
+ setStressRunning(true);
+ let ticks = 0;
+ stressTimer = setInterval(() => {
+ setActive(v => 1 - v);
+ ticks++;
+ if (ticks >= 20) {
+ clearInterval(stressTimer);
+ stressTimer = undefined;
+ setStressRunning(false);
+ }
+ }, 80);
+ };
+
+ onCleanup(() => clearInterval(stressTimer));
+
+ const PAIR = [
+ { label: "Alpha", color: "#6366f1" },
+ { label: "Beta", color: "#ec4899" },
+ ] as const;
+
+ const slideState = (i: number, isExiting: () => boolean): string => {
+ if (isExiting()) return "exiting";
+ if (active() === i) return "active";
+ return "–";
+ };
+
+ const stateColor = (s: string): string =>
+ s === "active" ? "#16a34a" : s === "exiting" ? "#d97706" : "#94a3b8";
+
+ return (
+
+
createPresenceB — stress test
+
+
+
+ {(p, i) => (
+
+ )}
+
+
+
+ {/* Stacked panels — entering and exiting slides overlap during crossfade */}
+
+ Expected: at any moment exactly one slide shows{" "}
+ active or{" "}
+ exiting. If both become{" "}
+ – simultaneously, or a slide stays{" "}
+ exiting after it should be active again,
+ the animation has glitched.
+
+
+ );
+ },
+});
+
+// ─── createPresenceC diagnostic ──────────────────────────────────────────────
+
+export const PresenceCDiagnostic = meta.story({
+ name: "createPresenceC — async-memo diagnostic",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Diagnostic harness for `createPresenceC`. Shows all four returned values " +
+ "(`isMounted`, `isEntered`, `isEntering`, `isExiting`) live as you toggle or stress-test. " +
+ "**Note**: `target()` is called unconditionally in the effect so `show` must start `true` " +
+ "or the initial effect apply throws on a null element — this story starts visible. " +
+ "The key question: does `isMounted` stay `true` for the full 1 s exit animation, " +
+ "and do `isExiting`/`isEntering` correctly reflect animation state?",
+ },
+ },
+ },
+ render: () => {
+ const [show, setShow] = createSignal(true);
+ const [stressRunning, setStressRunning] = createSignal(false);
+
+ let el: HTMLDivElement | undefined;
+
+ const C_OPTS = {
+ enter: [
+ { opacity: 0, transform: "translateX(20px)" },
+ { opacity: 1, transform: "none" },
+ ],
+ exit: [
+ { opacity: 1, transform: "none" },
+ { opacity: 0, transform: "translateX(-20px)" },
+ ],
+ enterOptions: { duration: 400, easing: "ease-out", fill: "both" as FillMode },
+ // Long exit so timing issues are clearly visible.
+ exitOptions: { duration: 1000, easing: "ease-in", fill: "forwards" as FillMode },
+ };
+
+ // createPresenceC: show must start true so target() is non-null when the
+ // non-deferred effect apply fires on initial mount.
+ const { isMounted, isEntered, isEntering, isExiting } = createPresenceC(
+ () => el!,
+ show,
+ C_OPTS,
+ );
+
+ let stressTimer: ReturnType | undefined;
+
+ const runStress = () => {
+ clearInterval(stressTimer);
+ setStressRunning(true);
+ let ticks = 0;
+ stressTimer = setInterval(() => {
+ setShow(v => !v);
+ ticks++;
+ if (ticks >= 20) {
+ clearInterval(stressTimer);
+ stressTimer = undefined;
+ setShow(true);
+ setStressRunning(false);
+ }
+ }, 80);
+ };
+
+ onCleanup(() => clearInterval(stressTimer));
+
+ const Flag = (props: { label: string; value: () => boolean }) => (
+
+ {props.label}
+
+ {String(props.value())}
+
+
+
+ );
+
+ return (
+
+
createPresenceC — diagnostic
+
+
+
+ {/* Panel — gated on isMounted to stay alive during exit animation */}
+
+ Expected: isMounted stays true{" "}
+ for the full 1 s exit. isExiting ={" "}
+ true during exit,{" "}
+ isEntering = true{" "}
+ during enter. If isMounted flips to{" "}
+ false instantly on hide, the async-memo
+ timing is off — animationP isn't reactive so the memo reads it before the
+ effect sets it.
+
+
+ );
+ },
+});
+
+// ─── createPresenceC gate test ────────────────────────────────────────────────
+
+export const PresenceCGateTest = meta.story({
+ name: "createPresenceC — deferred disposal gate test",
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Tests whether `isMounted` acts as a true deferred-disposal gate. " +
+ "The inner component holds a counter (reactive signal) and a live tick timer. " +
+ "Gate `` on `isMounted()` — if deferred disposal works, both the counter " +
+ "and the tick keep running during the 1.2 s exit animation. " +
+ "If the component is disposed immediately when `show` flips to `false`, " +
+ "the counter and tick disappear instantly — the gate mechanism is broken.",
+ },
+ },
+ },
+ render: () => {
+ const [show, setShow] = createSignal(true);
+
+ let el: HTMLDivElement | undefined;
+
+ const GATE_OPTS = {
+ enter: [{ opacity: 0, transform: "scale(0.92)" }, { opacity: 1, transform: "none" }],
+ exit: [{ opacity: 1, transform: "none" }, { opacity: 0, transform: "scale(0.92)" }],
+ enterOptions: { duration: 350, easing: "ease-out", fill: "both" as FillMode },
+ exitOptions: { duration: 1200, easing: "ease-in", fill: "forwards" as FillMode },
+ };
+
+ const { isMounted, isEntering, isExiting } = createPresenceC(
+ () => el!,
+ show,
+ GATE_OPTS,
+ );
+
+ // Inner component: has its own reactive state to prove it survives during exit.
+ const Inner = () => {
+ const [count, setCount] = createSignal(0);
+ const [ticks, setTicks] = createSignal(0);
+
+ // Tick every 200 ms — proves the reactive scope is still alive.
+ const timer = setInterval(() => setTicks(t => t + 1), 200);
+ onCleanup(() => clearInterval(timer));
+
+ const c = "#6366f1";
+ return (
+
+ Click Hide then immediately click the counter buttons. If the counter
+ responds for ~1.2 s before disappearing, deferred disposal works. If it vanishes
+ instantly, isMounted returned false synchronously instead of
+ staying pending.
+