Spring-powered WAAPI animation utilities and FLIP layout transitions for Svelte 5.
animate()— Animate any CSS property with spring physics, per-prop overrides, and a full playback API.flip()— Zero-config FLIP layout transitions as a Svelte 5 attachment.timeline()— Sequence and parallelize animations along a shared clock.stagger()— Generate staggered delays for list animations.spring()/springEasing()— Spring physics primitives you can use anywhere.- 30+ built-in easings — CSS equivalents plus cubic-bezier factory.
- Honors
prefers-reduced-motionby default.
npm install @ixirjs/pulseSvelte 5 is a required peer dependency:
npm install svelte@^5Animate one or more CSS properties on an element using the Web Animations API.
import { animate } from '@ixirjs/pulse';
// Bare value: current → target
animate(el, { opacity: 1 });
// [from, to] shorthand
animate(el, { opacity: [0, 1], y: [20, 0] });
// Full PropConfig
animate(el, {
x: { from: -100, to: 0, duration: 400, easing: easeOut },
scale: { to: 1.05, spring: { stiffness: 300, damping: 20 } }
});
// Shared defaults
animate(el, { x: 100, opacity: 1 }, { duration: 300, easing: cubicOut });| Option | Type | Default |
|---|---|---|
duration |
number |
300 |
easing |
EasingFn |
cubic ease-out |
spring |
SpringInput |
— |
delay |
number |
0 |
fill |
FillMode |
'both' |
iterations |
number |
1 |
direction |
PlaybackDirection |
'normal' |
respectReducedMotion |
boolean |
true |
onStart |
(el) => void |
— |
onEnd |
(el, { finished }) => void |
— |
const ctrl = animate(el, { x: 100 });
ctrl.play();
ctrl.pause();
ctrl.reverse();
ctrl.cancel(); // cancel + snap back
ctrl.stop(); // commit current position + cancel
ctrl.seek(150); // seek to 150ms
ctrl.playbackRate = 2; // 2× speed
await ctrl.finished; // resolves on normal completionThe following shorthands animate independent transform components without clobbering each other:
| Key | CSS custom property |
|---|---|
x |
--motion-x (px) |
y |
--motion-y (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) |
Zero-config FLIP layout animation. Attach to any element that may shift position or size.
Layout tracking is built in — resizes and {#each} reorders animate with no options at all.
<script>
import { flip } from '@ixirjs/pulse';
let open = $state(false);
</script>
<!-- Auto-animates whenever the element moves or resizes -->
<div {@attach flip()}>...</div>
<!-- Custom duration + easing -->
<div {@attach flip({ duration: 320, easing: cubicOut })}>...</div>
<!-- Let flip own the attribute: it applies the class, then measures and
animates the resulting layout change in the same tick -->
<div {@attach flip({ class: () => ({ 'is-open': open }) })}>...</div>
<!-- Same, with inline style -->
<div {@attach flip({ style: () => (open ? 'height: 320px' : 'height: 64px') })}>...</div>
<!-- Skip the first render (mount) -->
<div {@attach flip({ skip: (n) => n === 0 })}>...</div>class and style are thunks so their rune dependencies are tracked. Because flip writes the
attribute itself, it measures the element before and after the write and animates the difference
in the same tick — no extra frame, and nothing to keep in sync by hand.
Use
flip'sclassoption or a dynamicclass={…}on the same element, not both: Svelte assignsclassNamewholesale and would drop flip's tokens. A staticclass="…"is safe, andstylenever conflicts (it merges per declaration). Neither is applied during SSR — put server-rendered state in markup as well.
| Option | Type | Default |
|---|---|---|
duration |
number | (distance, rects) => number |
280 |
easing |
EasingFn | string |
cubicOut |
delay |
number |
0 |
translate |
boolean |
true |
scale |
boolean |
true |
opacity |
boolean | { from?, to? } |
— |
class |
() => ClassValue |
— |
style |
() => string |
— |
skip |
boolean | (render, rects) => boolean |
— |
disablePointerEvents |
boolean |
— |
respectReducedMotion |
boolean |
true |
layoutId |
string |
— |
onStart |
(el, rects) => void |
— |
onEnd |
(el, { finished, rects }) => void |
— |
<script>
import { createFlipScope } from '@ixirjs/pulse';
const scope = createFlipScope();
</script>
<!-- Component A (unmounting) -->
<div {@attach scope.flip({ layoutId: 'hero' })}>...</div>
<!-- Component B (mounting) — animates FROM A's last position -->
<div {@attach scope.flip({ layoutId: 'hero' })}>...</div>import { snapshotRect, flipFrom } from '@ixirjs/pulse';
const snapshot = snapshotRect(el);
// ... DOM changes ...
flipFrom(el, snapshot, { duration: 300 });Sequence and parallelize animate() calls on a shared clock.
import { timeline } from '@ixirjs/pulse';
timeline({ duration: 400 })
.add(card, { y: [20, 0], opacity: [0, 1] })
.add(title, { y: [10, 0], opacity: [0, 1] }, undefined, '<+50')
.label('reveal')
.add(actions, { opacity: [0, 1] }, undefined, 'reveal+=100')
.call(() => console.log('done'), '>+50')
.play();| Syntax | Meaning |
|---|---|
undefined |
Append at current end |
123 |
Absolute time in ms |
"+=200", "-=100" |
Offset from current end |
">", ">+200" |
End of last child ± offset |
"<", "<+200" |
Start of last child ± offset |
"label", "label+=200" |
Named label ± offset |
import { animate, stagger } from '@ixirjs/pulse';
const delay = stagger(50);
items.forEach((el, i) => {
animate(el, { opacity: [0, 1], y: [20, 0] }, { delay: delay(i, items.length) });
});
// Center-out wave
const wave = stagger(40, { from: 'center' });
// With easing
const eased = stagger(60, { from: 'start', easing: easeOut });Simulate spring physics from 0 → 1 and return per-frame samples + duration:
import { spring } from '@ixirjs/pulse';
const { samples, duration } = spring({ stiffness: 200, damping: 20 });| Option | Default |
|---|---|
stiffness |
170 |
damping |
26 |
mass |
1 |
velocity |
0 |
restDelta |
0.001 |
restSpeed |
0.001 |
Returns an EasingFn usable anywhere easing is accepted:
import { animate, springEasing } from '@ixirjs/pulse';
const bouncy = springEasing({ stiffness: 300, damping: 18 });
// Duration auto-sized from the spring simulation:
animate(el, { scale: 1.2 }, { easing: bouncy });Import individually or via the easings namespace:
import { cubicOut, backOut, elasticOut, cubicBezier } from '@ixirjs/pulse';
import * as easings from '@ixirjs/pulse';
const snappy = cubicBezier(0.2, 0.9, 0.2, 1);Available: linear, ease, easeIn, easeOut, easeInOut, quadIn/Out/InOut, cubicIn/Out/InOut, quartIn/Out/InOut, quintIn/Out/InOut, expoIn/Out/InOut, sineIn/Out/InOut, circIn/Out/InOut, backIn/Out/InOut, elasticIn/Out/InOut, bounceIn/Out/InOut.
For better tree-shaking you can import directly from subpaths:
import { animate, timeline, spring } from '@ixirjs/pulse/animate';
import { flip, createFlipScope, flipFrom } from '@ixirjs/pulse/flip';The root import and every documented subpath in package.json are supported public API. High-level helpers (animate, flip, and gesture attachments) are the preferred entry points; the low-level exports remain supported for advanced integrations and are not removed without a documented migration.
AnimationController.finished preserves the terminal semantics of its underlying platform. Controllers returned by animate() and APIs built on WAAPI reject when cancel() aborts them; native view-transition and no-animation fallback controllers resolve once their update settles. Handle cancellation when terminal notification is all you need:
await controller.finished.catch(() => undefined);git clone https://github.com/ixirjs/pulse
cd pulse
npm install
npm run dev # start demo app
npm test # run tests
npm run check # type-check
npm run lint # lint + format check