Skip to content

Repository files navigation

@ixirjs/pulse

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-motion by default.

Installation

npm install @ixirjs/pulse

Svelte 5 is a required peer dependency:

npm install svelte@^5

animate(element, props, defaults?)

Animate 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 });

animate() defaults

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

AnimationController

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 completion

Transform shorthands

The 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)

flip(options?) — Svelte 5 attachment

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's class option or a dynamic class={…} on the same element, not both: Svelte assigns className wholesale and would drop flip's tokens. A static class="…" is safe, and style never conflicts (it merges per declaration). Neither is applied during SSR — put server-rendered state in markup as well.

Flip options

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

Shared-element transitions (createFlipScope)

<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>

Imperative flip

import { snapshotRect, flipFrom } from '@ixirjs/pulse';

const snapshot = snapshotRect(el);
// ... DOM changes ...
flipFrom(el, snapshot, { duration: 300 });

timeline(defaults?)

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();

Position grammar

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

stagger(interval, options?)

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 });

spring(options?)

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

springEasing(options?)

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 });

Easings

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.

Subpath imports

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';

Public API and controller lifecycle

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);

Contributing

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

License

MIT

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages