Skip to main content
Solid Primitives 2

SolidJS primitives for the Web Animations API (WAAPI) — reactive wrappers for element.animate, scroll timelines, view timelines, FLIP, stagger, and animation groups.

StageCategoryVersionLast UpdatedDemo
0Animation1.0.0-next.1 (next)Aug 18, 2026Demo →
Terminal window
npm i @solid-primitives/animation@next

Solid primitives for the Web Animations API (WAAPI).

Primitives

PrimitiveDescription
makeAnimateImperative element.animate() wrapper
createAnimateReactive makeAnimate
makeScrollAnimationScroll-driven animation via ScrollTimeline
createScrollAnimationReactive makeScrollAnimation
makeViewAnimationViewport-driven animation via ViewTimeline
createViewAnimationReactive makeViewAnimation
makeFlipFLIP layout animation
makeStaggerStaggered animations across a list of elements
createStaggerReactive makeStagger
makeAnimationGroupCoordinate multiple animations as a unit
createAnimationGroupReactive makeAnimationGroup
makeMotionPathAnimate an element along a CSS Motion Path
createMotionPathReactive makeMotionPath
makeSequenceChain animation factories into a sequential playlist
createPresenceAnimationMount/unmount lifecycle with WAAPI enter/exit animations

makeAnimate / createAnimate

makeAnimate is a thin wrapper around element.animate() with TypeScript types. createAnimate replays the animation whenever target, keyframes, or options change reactively, and cancels it when the owner disposes.

// 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();
function makeAnimate(
el: Element,
keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
options?: KeyframeAnimationOptions,
): Animation
function createAnimate(
target: Accessor<Element | null | undefined>,
keyframes: MaybeAccessor<Keyframe[] | PropertyIndexedKeyframes | null>,
options?: MaybeAccessor<KeyframeAnimationOptions>,
): Accessor<Animation | undefined>

makeScrollAnimation / createScrollAnimation

Plays a WAAPI animation whose progress is driven by scroll position via ScrollTimeline. No scroll listeners or RAF loops needed.

// 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",
});
type ScrollAnimationOptions = Omit<KeyframeAnimationOptions, "timeline"> & {
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<Element | null | undefined>,
keyframes: MaybeAccessor<Keyframe[] | PropertyIndexedKeyframes | null>,
options?: MaybeAccessor<ScrollAnimationOptions>,
): Accessor<Animation | undefined>

makeViewAnimation / createViewAnimation

Plays a WAAPI animation whose progress is driven by an element's intersection with the scroll port via ViewTimeline. Replaces the IntersectionObserver + class-toggle pattern.

// 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",
});
type ViewAnimationOptions = Omit<KeyframeAnimationOptions, "timeline"> & {
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(
el: Element,
keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
options?: ViewAnimationOptions,
): Animation
function createViewAnimation(
target: Accessor<Element | null | undefined>,
keyframes: MaybeAccessor<Keyframe[] | PropertyIndexedKeyframes | null>,
options?: MaybeAccessor<ViewAnimationOptions>,
): Accessor<Animation | undefined>

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.

let el!: HTMLUListElement;
const { snapshot, flip } = makeFlip(el, { duration: 300, easing: "ease" });
const handleReorder = () => {
snapshot();
setItems(prev => [...prev].reverse()); // DOM updates synchronously
flip();
};
return <ul ref={el}>...</ul>;

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.

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.

// 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" },
);
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<Keyframe[] | PropertyIndexedKeyframes | null>,
options?: MaybeAccessor<StaggerOptions>,
): Accessor<Animation[]>

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.

// 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();
// 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 (
<button onClick={() => group.play()}>Play all</button>
<ul>
<For each={items()}>
{(item, i) => <li ref={itemRefs[i()]}>{item.name}</li>}
</For>
</ul>
);
type AnimationGroupControls = {
play: () => void;
pause: () => void;
cancel: () => void;
reverse: () => void;
finish: () => void;
};
function makeAnimationGroup(
animations: (Animation | null | undefined)[],
): AnimationGroupControls
function createAnimationGroup(
animations: Accessor<(Animation | null | undefined)[]>,
): AnimationGroupControls

makeMotionPath / createMotionPath

Animates an element along a 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.

// 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();
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<HTMLElement | null | undefined>,
path: MaybeAccessor<string>,
options?: MaybeAccessor<MotionPathOptions>,
): Accessor<Animation | undefined>

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.

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

Exit keyframes default to the enter keyframes reversed. If show toggles back to true while an exit is in progress, the exit is cancelled and the enter restarts.

const [show, setShow] = createSignal(false);
let el!: HTMLDivElement;
const { isMounted } = createPresenceAnimation(() => el, show, {
enter: [
{ opacity: 0, transform: "translateY(8px)" },
{ opacity: 1, transform: "none" },
],
enterOptions: { duration: 250, easing: "ease-out" },
// exit defaults to reversed enter — fade out and slide down
});
return (
<>
<button onClick={() => setShow(v => !v)}>Toggle</button>
<Show when={isMounted()}>
<div ref={el}>Hello</div>
</Show>
</>
);
// Separate enter and exit keyframes + options
const { isMounted } = createPresenceAnimation(() => el, show, {
enter: [{ opacity: 0, transform: "scale(0.95)" }, { opacity: 1, transform: "none" }],
exit: [{ opacity: 1, transform: "none" }, { opacity: 0, transform: "scale(0.95)" }],
enterOptions: { duration: 200, easing: "ease-out" },
exitOptions: { duration: 150, easing: "ease-in" },
});
type PresenceAnimationOptions = {
enter: Keyframe[] | PropertyIndexedKeyframes | null;
exit?: Keyframe[] | PropertyIndexedKeyframes | null; // defaults to reversed enter
enterOptions?: KeyframeAnimationOptions;
exitOptions?: KeyframeAnimationOptions; // defaults to enterOptions
initialEnter?: boolean; // animate on first mount (default: false)
};
function createPresenceAnimation(
target: Accessor<HTMLElement | null | undefined>,
show: MaybeAccessor<boolean>,
options: PresenceAnimationOptions,
): { isMounted: Accessor<boolean> }

Changelog

See CHANGELOG.md

Solid Primitives 2High-quality reactive primitives for building applications in Solid2
Community
githubdiscord