SolidJS primitives for the Web Animations API (WAAPI) — reactive wrappers for element.animate, scroll timelines, view timelines, FLIP, stagger, and animation groups.
| Stage | Category | Version | Last Updated | Demo |
|---|---|---|---|---|
| 0 | Animation | 1.0.0-next.1 (next) | Aug 18, 2026 | Demo → |
npm i @solid-primitives/animation@nextSolid primitives for the Web Animations API (WAAPI).
Primitives
| Primitive | Description |
|---|---|
makeAnimate | Imperative element.animate() wrapper |
createAnimate | Reactive makeAnimate |
makeScrollAnimation | Scroll-driven animation via ScrollTimeline |
createScrollAnimation | Reactive makeScrollAnimation |
makeViewAnimation | Viewport-driven animation via ViewTimeline |
createViewAnimation | Reactive makeViewAnimation |
makeFlip | FLIP layout animation |
makeStagger | Staggered animations across a list of elements |
createStagger | Reactive makeStagger |
makeAnimationGroup | Coordinate multiple animations as a unit |
createAnimationGroup | Reactive makeAnimationGroup |
makeMotionPath | Animate an element along a CSS Motion Path |
createMotionPath | Reactive makeMotionPath |
makeSequence | Chain animation factories into a sequential playlist |
createPresenceAnimation | Mount/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.
// Imperativeconst anim = makeAnimate(el, [{ opacity: 0 }, { opacity: 1 }], { duration: 300 });anim.pause();
// Reactiveconst anim = createAnimate( () => ref, [{ opacity: 0 }, { opacity: 1 }], { duration: 300, fill: "forwards" },);// anim() is the current Animation instance, or undefined while ref is unsetanim()?.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 pageconst anim = createScrollAnimation( () => ref, [{ opacity: 0, transform: "translateY(20px)" }, { opacity: 1, transform: "none" }], { fill: "both" },);
// Tie progress to a specific scroll containerconst 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 viewportconst anim = createViewAnimation( () => ref, [{ opacity: 0, transform: "translateY(16px)" }, { opacity: 1, transform: "none" }], { fill: "both" },);
// Observe a different element than the one being animatedconst 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 insideposition: fixedorposition: absoluteancestors 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 elementsmakeStagger(listItems, [{ opacity: 0 }, { opacity: 1 }], { duration: 400, stagger: 60,});
// Reactive — re-runs (cancelling previous animations) when the target list changesconst 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 listconst 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() changesconst 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 renderconst 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)[]>,): AnimationGroupControlsmakeMotionPath / 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 functionmakeMotionPath(dotEl, "circle(50%)", { duration: 1500, iterations: Infinity });
// Reactive — re-runs whenever target, path, or options changeconst 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 finishesseq.cancel(); // stops immediatelyseq.play(); // restart from the beginningtype 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[]): SequenceControlscreatePresenceAnimation
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 + optionsconst { 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
Related
@solid-primitives/presence— mount/unmount lifecycle coordination for CSS transitions@solid-primitives/transition-group—<TransitionGroup>for lists@solid-primitives/spring— spring-physics value interpolation@solid-primitives/tween— tween value interpolation