From bc61b9764f6eababf4a668c284b76dac4e3b3296 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Thu, 25 Jun 2026 11:52:06 -0400
Subject: [PATCH 01/16] New animation package
---
README.md | 3 +-
packages/animation/CHANGELOG.md | 9 +
packages/animation/LICENSE | 21 +
packages/animation/README.md | 299 ++++++++
packages/animation/dev/index.tsx | 102 +++
packages/animation/package.json | 77 ++
packages/animation/src/animate.ts | 41 +
packages/animation/src/animation-group.ts | 49 ++
packages/animation/src/flip.ts | 40 +
packages/animation/src/index.ts | 8 +
packages/animation/src/motion-path.ts | 61 ++
packages/animation/src/scroll-animation.ts | 56 ++
packages/animation/src/sequence.ts | 62 ++
packages/animation/src/stagger.ts | 43 ++
packages/animation/src/view-animation.ts | 79 ++
.../animation/stories/animation.stories.tsx | 709 ++++++++++++++++++
packages/animation/test/index.test.ts | 515 +++++++++++++
packages/animation/tsconfig.json | 16 +
pnpm-lock.yaml | 10 +
19 files changed, 2199 insertions(+), 1 deletion(-)
create mode 100644 packages/animation/CHANGELOG.md
create mode 100644 packages/animation/LICENSE
create mode 100644 packages/animation/README.md
create mode 100644 packages/animation/dev/index.tsx
create mode 100644 packages/animation/package.json
create mode 100644 packages/animation/src/animate.ts
create mode 100644 packages/animation/src/animation-group.ts
create mode 100644 packages/animation/src/flip.ts
create mode 100644 packages/animation/src/index.ts
create mode 100644 packages/animation/src/motion-path.ts
create mode 100644 packages/animation/src/scroll-animation.ts
create mode 100644 packages/animation/src/sequence.ts
create mode 100644 packages/animation/src/stagger.ts
create mode 100644 packages/animation/src/view-animation.ts
create mode 100644 packages/animation/stories/animation.stories.tsx
create mode 100644 packages/animation/test/index.test.ts
create mode 100644 packages/animation/tsconfig.json
diff --git a/README.md b/README.md
index bf1aad04a..b873c5015 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) [createFlip](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createflip) [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..efd2133c0
--- /dev/null
+++ b/packages/animation/CHANGELOG.md
@@ -0,0 +1,9 @@
+# @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`.
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..f868f1e35
--- /dev/null
+++ b/packages/animation/README.md
@@ -0,0 +1,299 @@
+
+
+
+
+# @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` |
+
+---
+
+## `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 (
+
+
+
+ );
+};
+
+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.
+
+
+ {/* Stacked so entering and exiting slides overlap during crossfade */}
+
+ {slide => }
+
+
+
+ active: {active().label}
+
+
+ );
+ },
+});
From c28e5cdc2c5fab809cfa314bd10db768c748ce3b Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:18:27 -0400
Subject: [PATCH 10/16] Added a stress test and adjusted exitComplete logic
---
packages/animation/src/presence-animation.ts | 33 ++-
.../animation/stories/animation.stories.tsx | 271 +++++++++++++++++-
2 files changed, 302 insertions(+), 2 deletions(-)
diff --git a/packages/animation/src/presence-animation.ts b/packages/animation/src/presence-animation.ts
index db39d2556..39ebe1265 100644
--- a/packages/animation/src/presence-animation.ts
+++ b/packages/animation/src/presence-animation.ts
@@ -283,6 +283,11 @@ export function createPresenceB(
let exitAnim: 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
@@ -300,11 +305,21 @@ export function createPresenceB(
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;
- animPromise = 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;
}
@@ -312,14 +327,29 @@ export function createPresenceB(
// 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 so enter and exit don't race.
+ enterGen++;
+ 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;
setIsExiting(false);
resolve();
@@ -333,6 +363,7 @@ export function createPresenceB(
() => {
exitAnim = undefined;
animPromise = undefined;
+ exitCompleted = true;
setIsExiting(false);
resolve(); // → asyncWrite → gate._value = undefined → Show removes component
},
diff --git a/packages/animation/stories/animation.stories.tsx b/packages/animation/stories/animation.stories.tsx
index aec6c2949..074d5ab57 100644
--- a/packages/animation/stories/animation.stories.tsx
+++ b/packages/animation/stories/animation.stories.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createSignal, For, onSettled, Show } from "solid-js";
+import { createEffect, createSignal, For, onCleanup, onSettled, Show } from "solid-js";
import preview from "../../../.storybook/preview.js";
import {
createAnimate,
@@ -1547,3 +1547,272 @@ export const PresenceBStory = meta.story({
);
},
});
+
+// ─── 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 (
+
+ 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.
+
+
+ );
+ },
+});
From 97f1e4c9fac1a5e23490f2576b79bf24739556ff Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Fri, 26 Jun 2026 12:12:55 -0400
Subject: [PATCH 11/16] Slight adjustments to entering logic
---
packages/animation/src/presence-animation.ts | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/packages/animation/src/presence-animation.ts b/packages/animation/src/presence-animation.ts
index 39ebe1265..b8b842693 100644
--- a/packages/animation/src/presence-animation.ts
+++ b/packages/animation/src/presence-animation.ts
@@ -281,6 +281,7 @@ export function createPresenceB(
const [isExiting, setIsExiting] = createSignal(false, INTERNAL_OPTIONS);
let exitAnim: Animation | undefined;
+ let enterAnim: Animation | undefined;
let animPromise: Promise | undefined;
let enterGen = 0;
let exitGen = 0;
@@ -336,8 +337,14 @@ export function createPresenceB(
// subsequent recomputes is safe: handleAsync guards stale asyncWrite
// callbacks via `el._inFlight !== result`.
if (!animPromise) {
- // Cancel any pending enter microtask so enter and exit don't race.
+ // 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(() => {
@@ -392,7 +399,8 @@ export function createPresenceB(
if (gen !== enterGen) return;
const el = untrack(target);
if (!el) return;
- el.animate(options.enter, options.enterOptions);
+ enterAnim = el.animate(options.enter, options.enterOptions);
+ enterAnim.addEventListener("finish", () => { enterAnim = undefined; }, { once: true });
});
},
);
@@ -403,7 +411,8 @@ export function createPresenceB(
if (gen !== enterGen) return;
const el = untrack(target);
if (!el) return;
- el.animate(options.enter, options.enterOptions);
+ enterAnim = el.animate(options.enter, options.enterOptions);
+ enterAnim.addEventListener("finish", () => { enterAnim = undefined; }, { once: true });
});
}
From a2dd29539114e38d03c2069af9d1e4b1c93efc6a Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Fri, 26 Jun 2026 15:06:05 -0400
Subject: [PATCH 12/16] Stablizies it a bit better
---
packages/animation/src/presence-animation.ts | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/packages/animation/src/presence-animation.ts b/packages/animation/src/presence-animation.ts
index b8b842693..03d325c51 100644
--- a/packages/animation/src/presence-animation.ts
+++ b/packages/animation/src/presence-animation.ts
@@ -383,16 +383,15 @@ export function createPresenceB(
return animPromise;
}) as unknown as Accessor;
- // Read gate() — the async signal — inside a createRenderEffect.
- // When gate is STATUS_PENDING (exit animation running), this render effect
- // is also placed into _pendingNodes via queuePendingNode (apply does not
- // run while pending). When gate resolves to true (show becomes true),
- // apply fires and starts the enter animation.
- createRenderEffect(
- () => gate(),
- (gateValue: unknown, prev: unknown) => {
- if (prev === undefined) return; // skip initial mount
- if (gateValue !== true) return; // pending or false — exit handled by gate memo
+ // Track getShow() directly (a plain boolean, never async) rather than gate()
+ // (the async memo). Tracking gate() puts this effect into _pendingNodes while
+ // the exit Promise is live; if show flips back to true before the Promise
+ // resolves, Solid may not re-queue the stuck effect, so setIsExiting(false)
+ // and the enter animation would never fire. getShow() always triggers reliably.
+ createEffect(
+ () => getShow(),
+ (shouldShow) => {
+ if (!shouldShow) return;
setIsExiting(false);
const gen = ++enterGen;
queueMicrotask(() => {
@@ -403,6 +402,7 @@ export function createPresenceB(
enterAnim.addEventListener("finish", () => { enterAnim = undefined; }, { once: true });
});
},
+ { defer: true },
);
if (options.initialEnter && untrack(getShow)) {
From ccbbc7e591c48fec510d1c92a9bcaad09dda9b30 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Fri, 26 Jun 2026 16:23:29 -0400
Subject: [PATCH 13/16] Additional tests
---
packages/animation/src/presence-animation.ts | 121 +++++-
.../animation/stories/animation.stories.tsx | 353 ++++++++++++++++++
2 files changed, 456 insertions(+), 18 deletions(-)
diff --git a/packages/animation/src/presence-animation.ts b/packages/animation/src/presence-animation.ts
index 03d325c51..c7a8fb614 100644
--- a/packages/animation/src/presence-animation.ts
+++ b/packages/animation/src/presence-animation.ts
@@ -4,6 +4,7 @@ import {
createRenderEffect,
createMemo,
untrack,
+ isPending,
type Accessor,
} from "solid-js";
import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils";
@@ -103,7 +104,7 @@ export function createPresenceAnimation(
createEffect(
() => getShow(),
- (shouldShow) => {
+ shouldShow => {
if (shouldShow) {
setIsMounted(true);
scheduleEnter();
@@ -117,7 +118,14 @@ export function createPresenceAnimation(
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 });
+ anim.addEventListener(
+ "finish",
+ () => {
+ done = true;
+ setIsMounted(false);
+ },
+ { once: true },
+ );
return () => {
if (!done) anim.cancel();
};
@@ -278,7 +286,12 @@ export function createPresenceB(
options: PresenceAnimationOptions,
): { gate: Accessor; isExiting: Accessor } {
const getShow = asAccessor(show);
- const [isExiting, setIsExiting] = createSignal(false, INTERNAL_OPTIONS);
+ // 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;
@@ -358,24 +371,37 @@ export function createPresenceB(
if (!el) {
exitCompleted = true;
animPromise = undefined;
- setIsExiting(false);
+ setExitAnimRunning(false);
resolve();
return;
}
- setIsExiting(true);
+ setExitAnimRunning(true);
const exitKf = options.exit ?? reverseKeyframes(options.enter);
- exitAnim = el.animate(exitKf, options.exitOptions ?? options.enterOptions);
- exitAnim.addEventListener(
+ const anim = el.animate(exitKf, options.exitOptions ?? options.enterOptions);
+ exitAnim = anim;
+ anim.addEventListener(
"finish",
() => {
- exitAnim = undefined;
+ if (exitAnim === anim) exitAnim = undefined;
animPromise = undefined;
exitCompleted = true;
- setIsExiting(false);
+ 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 },
+ );
});
});
}
@@ -383,23 +409,25 @@ export function createPresenceB(
return animPromise;
}) as unknown as Accessor;
- // Track getShow() directly (a plain boolean, never async) rather than gate()
- // (the async memo). Tracking gate() puts this effect into _pendingNodes while
- // the exit Promise is live; if show flips back to true before the Promise
- // resolves, Solid may not re-queue the stuck effect, so setIsExiting(false)
- // and the enter animation would never fire. getShow() always triggers reliably.
+ // 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) => {
+ shouldShow => {
if (!shouldShow) return;
- setIsExiting(false);
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 });
+ enterAnim.addEventListener(
+ "finish",
+ () => {
+ enterAnim = undefined;
+ },
+ { once: true },
+ );
});
},
{ defer: true },
@@ -412,9 +440,66 @@ export function createPresenceB(
const el = untrack(target);
if (!el) return;
enterAnim = el.animate(options.enter, options.enterOptions);
- enterAnim.addEventListener("finish", () => { enterAnim = undefined; }, { once: true });
+ 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/stories/animation.stories.tsx b/packages/animation/stories/animation.stories.tsx
index 074d5ab57..71e6faafa 100644
--- a/packages/animation/stories/animation.stories.tsx
+++ b/packages/animation/stories/animation.stories.tsx
@@ -13,6 +13,7 @@ import {
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";
@@ -1816,3 +1817,355 @@ export const PresenceBDiagnostic = meta.story({
);
},
});
+
+// ─── 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 }) => (
+
+ 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.
+
+
+ );
+ },
+});
From 0266aebc320ed9c709cffaaddea0cc0855380979 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:21:34 -0400
Subject: [PATCH 14/16] animation: remove experimental createPresenceA/B/C
These were exploratory alternatives to createPresenceAnimation (deferred
isMounted, async-gate deferred disposal, async-memo variant) that aren't
usable. Removes the implementations, their dedicated storybook stories
and diagnostic harnesses, unused imports left behind, and the two
already-stale package.json primitive list entries (createPresenceC was
never added to that list to begin with).
createPresenceAnimation is untouched.
---
packages/animation/package.json | 4 +-
packages/animation/src/presence-animation.ts | 377 +-----
.../animation/stories/animation.stories.tsx | 1017 +----------------
3 files changed, 3 insertions(+), 1395 deletions(-)
diff --git a/packages/animation/package.json b/packages/animation/package.json
index 042f3038d..74e12fb53 100644
--- a/packages/animation/package.json
+++ b/packages/animation/package.json
@@ -26,9 +26,7 @@
"makeMotionPath",
"createMotionPath",
"makeSequence",
- "createPresenceAnimation",
- "createPresenceA",
- "createPresenceB"
+ "createPresenceAnimation"
],
"category": "Animation"
},
diff --git a/packages/animation/src/presence-animation.ts b/packages/animation/src/presence-animation.ts
index c7a8fb614..c227bfaf1 100644
--- a/packages/animation/src/presence-animation.ts
+++ b/packages/animation/src/presence-animation.ts
@@ -1,12 +1,4 @@
-import {
- createSignal,
- createEffect,
- createRenderEffect,
- createMemo,
- untrack,
- isPending,
- type Accessor,
-} from "solid-js";
+import { createSignal, createEffect, untrack, type Accessor } from "solid-js";
import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils";
export type PresenceAnimationOptions = {
@@ -136,370 +128,3 @@ export function createPresenceAnimation(
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/stories/animation.stories.tsx b/packages/animation/stories/animation.stories.tsx
index 71e6faafa..510aa8c94 100644
--- a/packages/animation/stories/animation.stories.tsx
+++ b/packages/animation/stories/animation.stories.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createSignal, For, onCleanup, onSettled, Show } from "solid-js";
+import { createEffect, createSignal, For, onSettled, Show } from "solid-js";
import preview from "../../../.storybook/preview.js";
import {
createAnimate,
@@ -11,9 +11,6 @@ import {
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";
@@ -1157,1015 +1154,3 @@ export const PresenceTabs = meta.story({
},
});
-// ─── createPresenceA ──────────────────────────────────────────────────────────
-
-// A self-contained interactive widget defined outside any reactive scope so
-// its identity is stable across renders.
-const InteractiveCard = (props: {
- ref: (el: HTMLDivElement) => void;
- isExiting: () => boolean;
-}) => {
- const [count, setCount] = createSignal(0);
- const [note, setNote] = createSignal("");
-
- 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.
-
- 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 }) => (
-
- 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.
-
-
- );
- },
-});
From 38b405dafc5cee169c7ad835b036a63bd0059a2f Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:28:44 -0400
Subject: [PATCH 15/16] Removed experiments and added JSR
---
deno.lock | 17 +++++++++--------
packages/animation/README.md | 3 ++-
packages/animation/deno.jsonc | 24 ++++++++++++++++++++++++
packages/animation/package.json | 6 +++---
packages/animation/src/index.ts | 18 +++++++++---------
pnpm-lock.yaml | 16 ++++++++--------
6 files changed, 55 insertions(+), 29 deletions(-)
create mode 100644 packages/animation/deno.jsonc
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/README.md b/packages/animation/README.md
index 742cc2784..dfc4b23ee 100644
--- a/packages/animation/README.md
+++ b/packages/animation/README.md
@@ -8,7 +8,8 @@
[](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`.
+Solid primitives for the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) (WAAPI).
+
## Installation
```bash
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
index 74e12fb53..db61e43b0 100644
--- a/packages/animation/package.json
+++ b/packages/animation/package.json
@@ -1,6 +1,6 @@
{
"name": "@solid-primitives/animation",
- "version": "0.0.1",
+ "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",
@@ -69,10 +69,10 @@
"@solid-primitives/utils": "workspace:^"
},
"peerDependencies": {
- "solid-js": "^2.0.0-beta.15"
+ "solid-js": "catalog:peer"
},
"typesVersions": {},
"devDependencies": {
- "solid-js": "2.0.0-beta.15"
+ "solid-js": "catalog:"
}
}
diff --git a/packages/animation/src/index.ts b/packages/animation/src/index.ts
index c283b01b0..0b4736e44 100644
--- a/packages/animation/src/index.ts
+++ b/packages/animation/src/index.ts
@@ -1,9 +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";
+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/pnpm-lock.yaml b/pnpm-lock.yaml
index baf5c530b..4e975d8ec 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -202,12 +202,6 @@ importers:
specifier: 'catalog:'
version: 2.0.0-rc.0
- packages/async:
- devDependencies:
- solid-js:
- specifier: 'catalog:'
- version: 2.0.0-rc.0
-
packages/animation:
dependencies:
'@solid-primitives/utils':
@@ -215,8 +209,14 @@ importers:
version: link:../utils
devDependencies:
solid-js:
- specifier: 2.0.0-beta.15
- version: 2.0.0-beta.15
+ specifier: 'catalog:'
+ version: 2.0.0-rc.0
+
+ packages/async:
+ devDependencies:
+ solid-js:
+ specifier: 'catalog:'
+ version: 2.0.0-rc.0
packages/audio:
dependencies:
From 2b41e2123cdfc3e0b8548a4085194530163e2108 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:31:01 -0400
Subject: [PATCH 16/16] Adjusted README missing details
---
packages/animation/README.md | 79 ++++++++++++++++++++++++++++++++++++
1 file changed, 79 insertions(+)
diff --git a/packages/animation/README.md b/packages/animation/README.md
index dfc4b23ee..0870338d5 100644
--- a/packages/animation/README.md
+++ b/packages/animation/README.md
@@ -33,6 +33,9 @@ pnpm add @solid-primitives/animation
| [`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 |
---
@@ -138,6 +141,8 @@ 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(
@@ -289,6 +294,80 @@ function createAnimationGroup(
---
+## `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.