Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
44e3458
fix(build): fully specified relative imports for native Node ESM
ryu-man Oct 16, 2026
7d4fcd2
fix(morph): stop parsing at malformed path data instead of looping
ryu-man Oct 16, 2026
fc1e713
fix(animate): don't re-arm rAF loops cancelled from inside their call…
ryu-man Oct 16, 2026
e1547c8
fix(animate): make controller teardown idempotent
ryu-man Oct 16, 2026
da892c5
fix(flip): evict expired shared-layout records on write
ryu-man Oct 16, 2026
843f6e5
fix(gestures): release wheelable's layer hint on cleanup
ryu-man Oct 16, 2026
b4d2f06
fix(timeline): size entries by all iterations
ryu-man Oct 16, 2026
4d03968
fix(scroll): gate inView on intersectionRatio, not any overlap
ryu-man Oct 16, 2026
9f91c33
fix: require svelte 5.29 for attachments; correct README shorthands
ryu-man Oct 16, 2026
f72e692
perf(easing): mark preset factories pure so unused easings tree-shake
ryu-man Oct 16, 2026
6fdc36d
fix(animate): measure size keywords in the element's box-sizing
ryu-man Oct 16, 2026
42dbc0f
fix(scroll): measure progress from the resting box, not the driven tr…
ryu-man Oct 16, 2026
bbc29e6
fix(flip): honor cubic-bezier() easing strings
ryu-man Oct 16, 2026
ad4d23a
fix(animate): settle on the actual terminal state, honoring direction…
ryu-man Oct 16, 2026
63e09da
feat(presence,gradient): honor prefers-reduced-motion
ryu-man Oct 16, 2026
b7ac81a
test(animate): type the keyframe read-back as KeyframeEffect
ryu-man Oct 16, 2026
2708c2d
feat(animate): reject prop inputs without a target, naming the prop
ryu-man Oct 16, 2026
7428882
fix(spring): sampled spring velocity is progress/s, like the live spring
ryu-man Oct 16, 2026
569a309
feat(timeline): drive call/set entries from the timeline clock
ryu-man Oct 16, 2026
81ff12e
perf(flip,scroll): read-then-write phases in the shared frame batch
ryu-man Oct 16, 2026
5ead19a
style: prettier
ryu-man Oct 16, 2026
c197300
chore(size): raise the root budget to 20.5 kB for the review fixes
ryu-man Oct 16, 2026
7797d7a
perf(flip,scroll): suppress shared animating ancestors once per frame
ryu-man Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/node-esm-specifiers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ixirjs/pulse': patch
---

The published `dist` now uses fully specified relative imports (`./x.js`, `./dir/index.js`), so the package loads under native Node ESM (server scripts, SSR without a bundler) instead of failing with `ERR_UNSUPPORTED_DIR_IMPORT`. `prepack` now import-checks the build.
10 changes: 10 additions & 0 deletions .changeset/review-behavior.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@ixirjs/pulse': minor
---

Behavior changes from a performance / DX review:

- Presence transitions (`fade`, `fly`, `scale`, `size`) and `animateGradient()` now honor `prefers-reduced-motion` like the rest of the library: they skip the delay and the tween. Each gains a `respectReducedMotion` option (default `true`); pass `false` to opt out.
- `animate()`, `timeline().add()` and keyframe building now throw a `TypeError` naming the property when an input has no target: `[]`, `[100]`, `{ values: [] }`, or a timing-only object such as `{ duration: 200 }`. These inputs used to produce keyframes the browser silently ignored.
- `velocity` in sampled springs (`spring()`, `springEasing()`, `animate(…, { spring })`) is now in progress per second, consistent with the live spring and its own docs. It was divided by 1000 internally, so any value you passed is now 1000× stronger: divide existing values by 1000 to keep the old motion.
- Timeline `.call()` and `.set()` entries now follow the timeline clock instead of `setTimeout`. `pause()`, `seek()`, `setPlaybackRate()` and `cancel()` apply to them, an entry reversed before it is reached is skipped, and `finished` waits for them.
22 changes: 22 additions & 0 deletions .changeset/review-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@ixirjs/pulse': patch
---

Bug fixes from a performance / DX review:

- `parsePath` / `morph()` no longer hang on malformed path data (e.g. `M0 0 Z 1`, `M0 0 L?`); parsing stops at the first error, as SVG renderers do.
- `onUpdate` (animate), `frameTween` frames and `createSpringValue` listeners can now cancel/stop their animation from inside the callback; previously the rAF loop re-armed itself and ran forever.
- Cancelling an `animate()` controller more than once, or after it finished, no longer releases a newer animation's transform ownership (which dropped its layer hint and confused FLIP measurement).
- Shared-layout records (`createFlipScope`) for ids that never return are now evicted on the next write, so long-lived scopes over feeds or virtual lists no longer grow without bound.
- `wheelable()` now releases its `will-change` layer hint when the attachment is removed mid-gesture.
- Timeline positioning now counts `iterations`: an entry with `iterations: 3` occupies three cycles before the next appended entry starts. Entries positioned after an `iterations: Infinity` entry are never reached and are skipped.
- `inView({ amount })` now enforces the visibility ratio in every engine. Spec-conformant browsers (Firefox) report `isIntersecting` at any overlap, so `onEnter` used to fire, and `onLeave` was skipped, below the requested amount.
- The `svelte` peer range is now `^5.29.0`, the first release with attachments (`{@attach}`, `svelte/attachments`), which the public API depends on.
- README: removed the `skewX` / `skewY` shorthands, which were never implemented (there is no individual CSS `skew` property to compose through), and listed the missing `z`.
- Easing presets are annotated `/* @__PURE__ */`, so importing a single easing no longer pulls in every preset (`{ easeOut }`: 614 B → 353 B brotli).
- `width` / `height` keyword endpoints (`'auto'`, `'fit-content'`, …) are now measured in the element's own `box-sizing` and ignore transforms. Before, a `content-box` element with padding or a border overshot to its border-box size, then snapped back when the keyword was restored.
- `scroll()` measures progress from the element's resting layout box. Before, a driven transform (for example `y: [400, 0]`) moved the rect it measured, so progress drifted without any scrolling.
- `flip({ easing })` now accepts `cubic-bezier(…)` strings. They used to fall back to `cubicOut` silently.
- `animate()` now settles where the animation actually ended. `direction: 'reverse'`, an even number of `alternate` iterations, reverse playback and fractional iterations no longer jump to the last keyframe on finish, and `fill: 'none'` / `'backwards'` no longer leave the end state written inline.
- FLIP and `scroll()` attachments that share a frame now measure everything first and animate afterwards, so N attachments cost one layout per frame instead of N forced reflows.
- Measurements in a shared frame now suppress an animating ancestor's transform once per frame instead of once per measured child (100 FLIP children under an animating parent: 1 write/restore instead of 100).
8 changes: 7 additions & 1 deletion .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{
"name": "Root public API",
"path": "./dist/index.js",
"limit": "20 kB"
"limit": "20.5 kB"
},
{
"name": "animate only",
Expand All @@ -13,5 +13,11 @@
"name": "flip only",
"path": "./dist/flip/index.js",
"limit": "9 kB"
},
{
"name": "single easing (named import)",
"path": "./dist/index.js",
"import": "{ easeOut }",
"limit": "500 B"
}
]
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ Spring-powered WAAPI animation utilities and FLIP layout transitions for Svelte
npm install @ixirjs/pulse
```

Svelte 5 is a required peer dependency:
Svelte 5.29+ (for `{@attach}`) is a required peer dependency:

```sh
npm install svelte@^5
npm install svelte@^5.29
```

## `animate(element, props, defaults?)`
Expand Down Expand Up @@ -84,12 +84,11 @@ The following shorthands animate independent transform components without clobbe
| -------- | ----------------------- |
| `x` | `--motion-x` (px) |
| `y` | `--motion-y` (px) |
| `z` | `--motion-z` (px) |
| `scale` | `--motion-scale` |
| `scaleX` | `--motion-scale-x` |
| `scaleY` | `--motion-scale-y` |
| `rotate` | `--motion-rotate` (deg) |
| `skewX` | `--motion-skew-x` (deg) |
| `skewY` | `--motion-skew-y` (deg) |

## `flip(options?)` — Svelte 5 attachment

Expand Down Expand Up @@ -235,6 +234,8 @@ const { samples, duration } = spring({ stiffness: 200, damping: 20 });
| `restDelta` | `0.001` |
| `restSpeed` | `0.001` |

`velocity` is in progress per second over the normalized 0 → 1 travel: divide a px/s release velocity by the distance.

## `springEasing(options?)`

Returns an `EasingFn` usable anywhere `easing` is accepted:
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"build": "vite build && npm run prepack",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"prepack": "svelte-kit sync && svelte-package && publint",
"prepack": "svelte-kit sync && svelte-package && publint && node -e \"import('@ixirjs/pulse')\"",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
Expand Down Expand Up @@ -110,7 +110,7 @@
}
},
"peerDependencies": {
"svelte": "^5.0.0"
"svelte": "^5.29.0"
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
Expand Down
8 changes: 4 additions & 4 deletions src/lib/animate/animate-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@
* ```
*/

import { isBrowser, shouldReduceMotion } from '../shared/browser';
import { frameTween } from '../shared/frame-tween';
import { easeOut } from '../easing';
import type { EasingFn } from './types';
import { isBrowser, shouldReduceMotion } from '../shared/browser.js';
import { frameTween } from '../shared/frame-tween.js';
import { easeOut } from '../easing/index.js';
import type { EasingFn } from './types.js';

/** Default tween duration in ms when none is supplied. */
const DEFAULT_DURATION = 300;
Expand Down
61 changes: 61 additions & 0 deletions src/lib/animate/core/animate.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { afterEach, describe, expect, it } from 'vitest';
import { animate } from './animate';
import { hasActiveTransforms } from '../properties/transform-tracker';

let el: HTMLElement | null = null;

Expand Down Expand Up @@ -74,4 +75,64 @@ describe('animate() onUpdate', () => {
await new Promise((r) => setTimeout(r, 50));
expect(calls).toBe(after);
});

it('does not re-arm when onUpdate cancels the controller', async () => {
const node = mount();
let calls = 0;
const ctrl = animate(
node,
{ x: [0, 200] },
{ duration: 300, onUpdate: () => (calls++, ctrl.cancel()) }
);
await new Promise((r) => setTimeout(r, 80));
expect(calls).toBe(1);
});
});

describe('animate() teardown', () => {
it('releases transform ownership once, however often it is cancelled', () => {
const node = mount();
const first = animate(node, { x: [0, 100] }, { duration: 1000 });
const second = animate(node, { x: [0, 200] }, { duration: 1000 });
first.cancel();
first.cancel();
expect(hasActiveTransforms(node)).toBe(true);
second.cancel();
expect(hasActiveTransforms(node)).toBe(false);
});
});

describe('animate() size keywords', () => {
it("resolves 'auto' in the element's box-sizing, not its border box", () => {
const parent = mount();
parent.style.width = '200px';
const child = document.createElement('div');
child.style.cssText = 'box-sizing:content-box;padding:10px;border:2px solid;';
parent.appendChild(child);
const ctrl = animate(child, { width: [50, 'auto'] }, { duration: 1000 });
const frames = (ctrl.animations[0]!.effect as KeyframeEffect).getKeyframes();
expect(frames.at(-1)!.width).toBe('176px'); // 200 - 2×10 padding - 2×2 border
ctrl.cancel();
});
});

describe('animate() terminal state', () => {
it('settles where a reversed run ends, not on the last keyframe', async () => {
const node = mount();
await animate(node, { opacity: [0, 1] }, { duration: 60, direction: 'reverse' }).finished;
expect(node.style.opacity).toBe('0');
});

it('settles at the start after an even number of alternating iterations', async () => {
const node = mount();
await animate(node, { x: [0, 100] }, { duration: 40, iterations: 2, direction: 'alternate' })
.finished;
expect(getComputedStyle(node).getPropertyValue('--motion-x').trim()).toBe('0px');
});

it("leaves no end state behind with fill: 'none'", async () => {
const node = mount();
await animate(node, { opacity: [0, 0.5] }, { duration: 60, fill: 'none' }).finished;
expect(node.style.opacity).toBe('');
});
});
25 changes: 15 additions & 10 deletions src/lib/animate/core/animate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,32 @@
* ```
*/

import { createController, noopController } from './controller';
import { buildKeyframes, type KeyframeGroup } from '../keyframes/keyframes';
import { normalizeInput } from '../keyframes/normalize';
import { createController, noopController } from './controller.js';
import { buildKeyframes, type KeyframeGroup } from '../keyframes/keyframes.js';
import { normalizeInput } from '../keyframes/normalize.js';
import {
demoteFoldedTransforms,
deregisterTransformAnimation,
hasActiveTransforms,
registerFoldedTransforms,
registerTransformAnimation,
type FoldedTransform
} from '../properties/properties';
} from '../properties/properties.js';
import {
ensurePropertiesRegistered,
ensureTransformWired,
isTransformOwned,
wireTransform
} from '../properties/transform-setup';
import { applyFolds, planFolds } from '../keyframes/fold';
import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../types';
import { isBrowser, shouldReduceMotion } from '../../shared/browser';
import { formatValue, resolveProp } from '../properties/prop-utils';
} from '../properties/transform-setup.js';
import { applyFolds, planFolds } from '../keyframes/fold.js';
import type {
AnimateDefaults,
AnimateProps,
AnimationController,
MotionElement
} from '../types.js';
import { isBrowser, shouldReduceMotion } from '../../shared/browser.js';
import { formatValue, resolveProp } from '../properties/prop-utils.js';

/**
* Animate one or more properties on an element using the Web Animations API.
Expand Down Expand Up @@ -187,7 +192,7 @@ const applyEndStateImmediately = (
for (const key of Object.keys(props)) {
const def = resolveProp(key);
if (def.transform) needsTransform = true;
const { to } = normalizeInput(props[key]!, defaults);
const { to } = normalizeInput(props[key]!, defaults, key);
// `to` mirrors the last stop of a `values` sequence, so this lands on the
// correct end-state for multi-keyframe props too.
if (to != null) style.setProperty(def.css, formatValue(to, def));
Expand Down
52 changes: 37 additions & 15 deletions src/lib/animate/core/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
* and own the lifecycle hooks (`onStart` / `onEnd`) plus inline-style cleanup.
*/

import { isBrowser } from '../../shared/browser';
import { demoteFoldedTransforms } from '../properties/properties';
import { playbackControls } from '../../shared/playback';
import type { AnimateDefaults, AnimationController, MotionElement } from '../types';
import type { CssWrite } from '../keyframes/keyframes';
import { isBrowser } from '../../shared/browser.js';
import { demoteFoldedTransforms } from '../properties/properties.js';
import { playbackControls } from '../../shared/playback.js';
import type { AnimateDefaults, AnimationController, MotionElement } from '../types.js';
import type { CssWrite } from '../keyframes/keyframes.js';

const NOOP = (): void => {};
const EMPTY_ANIMATIONS: readonly Animation[] = Object.freeze([]);
Expand Down Expand Up @@ -113,21 +113,38 @@ export const createController = ({
const tick = (): void => {
const progress = primaryAnimation.effect?.getComputedTiming().progress ?? 1;
onUpdate(progress, element);
rafId = requestAnimationFrame(tick);
// onUpdate may have cancelled/stopped the controller — don't re-arm.
if (rafId) rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
};

let finalized = false;
const finalize = (): void => {
/**
* Settle on where the animation actually ended. `terminalProgress` is the
* primary effect's progress at its end: `1` for a normal run, `0` (or a
* fraction) after `reverse`, an even `alternate` count, a negative rate or
* fractional iterations, and `null` when the fill doesn't hold the end state.
*/
const finalize = (terminalProgress: number | null): void => {
if (finalized) return;
finalized = true;
stopRaf();
// We don't call commitStyles() because the applyFinalStyles() call below
// writes the same end-state we'd commit (and avoids an extra forced style
// resolution per animation).
cancelAnimations();
applyFinalStyles(element, finalStyles, restorations);
if (terminalProgress === 1) {
// The common case: the end state is the last keyframe we already
// computed, so write it directly instead of resolving styles per
// animation (which commitStyles() would do).
cancelAnimations();
applyFinalStyles(element, finalStyles, restorations);
} else if (terminalProgress !== null && isBrowser()) {
// Ended somewhere else: freeze what is on screen, like stop().
demoteFoldedTransforms(element);
commitComputedStyles(element, finalStyles);
cancelAnimations();
} else {
// fill 'none' / 'backwards': back to the element's own styles.
cancelAnimations();
}
onTeardown?.();
};

Expand All @@ -143,12 +160,12 @@ export const createController = ({
// Capture the terminal timing before finalize() cancels the effects.
// This is 1 for a normal one-shot animation, but correctly preserves
// alternate/reverse iteration direction.
const terminalProgress = primaryAnimation?.effect?.getComputedTiming().progress ?? 1;
finalize();
const terminalProgress = primaryAnimation?.effect?.getComputedTiming().progress ?? null;
finalize(terminalProgress);
// The last rAF can run just before WAAPI reaches its terminal time.
// Publish the terminal sample explicitly so onUpdate has the same
// end-state guarantee as the animation controller itself.
defaults.onUpdate?.(terminalProgress, element);
defaults.onUpdate?.(terminalProgress ?? 1, element);
defaults.onEnd?.(element, { finished: true });
},
(err) => {
Expand All @@ -164,6 +181,11 @@ export const createController = ({
const teardown = (): void => {
stopRaf();
cancelAnimations();
// Shares finalize()'s guard: onTeardown releases per-element bookkeeping
// (transform ownership counts), so running it twice — cancel after finish,
// or cancel twice — would release a newer animation's share.
if (finalized) return;
finalized = true;
onTeardown?.();
};

Expand Down
8 changes: 4 additions & 4 deletions src/lib/animate/draw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
* ```
*/

import { animate } from './core/animate';
import { isBrowser } from '../shared/browser';
import { noopController } from './core/controller';
import type { AnimateDefaults, AnimationController } from './types';
import { animate } from './core/animate.js';
import { isBrowser } from '../shared/browser.js';
import { noopController } from './core/controller.js';
import type { AnimateDefaults, AnimationController } from './types.js';

export interface DrawOptions extends AnimateDefaults {
/** Fraction of the path drawn at the start (`0` = hidden, `1` = full). Default 0. */
Expand Down
Loading
Loading