Large-scale usage
Run thousands of animated elements, many mounts and unmounts, and long-lived apps without leaks or per-frame overhead.
Ani is built to drive many animations at once. This guide lists the choices that keep cost flat and memory bounded.
Write styles, do not re-render
Every frame, a playing timeline computes new values. What happens next decides the cost.
| Approach | Per frame work | Use for |
|---|---|---|
useAniRef / createAniRef / Vue and Svelte useAniRef | Computes values and writes element.style directly in the commit phase. No framework render. | Styles of one element |
useAniGroup / createAniGroup / Vue and Svelte useAniGroup | One plan and one clock subscription for the whole list, written per element. No framework render. | Styles of many elements |
useAni / createAni | Updates framework state, which re-renders what reads it. | Text, attributes or props driven by an animation |
waapiTimeline | None in JavaScript. The browser animates natively. | Simple transitions and endless loops |
- Do use the
*Refand*Groupbindings for style animation, even for thousands of elements. - Don't use
useAnifor transforms: one re-render per element per frame does not scale.
Share nodes, not timelines
An animation tree is immutable and holds no values. Build it once and use it for every element.
import * as a from "@freestylejs/ani-core";
import { useAniRef, useTimeline } from "@freestylejs/ani-react";
// module scope: created once
const fadeIn = a.sequence([
a.ani({ to: { opacity: 1 }, duration: 0.3 }),
a.ani({ to: { translateY: 0 }, duration: 0.4 }),
]);
// one timeline per element, all sharing the same tree
function Item() {
const timeline = useTimeline(() => a.rafTimeline(fadeIn));
const { ref, style } = useAniRef(timeline, {
initialValue: { opacity: 0, translateY: 20 },
});
return <li ref={ref} style={style} />;
}- One timeline per element when animations are independent. A timeline has one set of values, so elements bound to the same timeline always look identical.
- For a list that plays one tree on every item, use one
rafGroup: a single clock subscription and a single plan compilation, with a per-item offset. - The WAAPI compilation cache is keyed by the shared root node, so many elements with the same
fromcompile once. - Reuse the same
resolverobject to keep cache hits.
One clock
All timelines use the shared defaultClock by default: one requestAnimationFrame loop for any number of timelines, stopped automatically when the last timeline leaves. Do not create a clock per component. Create your own clock only for isolation (tests, a separate engine).
Cost model
- Per frame, a timeline costs time proportional to the number of animated properties. It does not grow with the number of segments in the tree or with how many already finished.
- Subscribers of one timeline share one state object per frame (read-only), so extra listeners do not allocate extra state.
loop(child, n)is represented once in the plan, so its cost does not depend onn. Useloop(child, Infinity)orrepeat: Infinityfor endless animation.play()validates and compiles the tree (once per call). Avoid calling it every frame.- A
rafGroupevaluates targets with the same offset andfromvalues once, so N identical targets cost one evaluation plus N writes.
On the project benchmark (Node, no DOM writes): the per-frame cost of a 1000-segment timeline late in the animation is 1.3x the cost early on, and 1000 timelines of 20 segments cost about 0.6 ms per frame. Compiling a 1000-segment tree to WAAPI keyframes takes about 2 ms.
Production and development builds
The package ships two variants of every entry. A bundler that supports the development export condition (Vite, webpack 5, Next.js in dev mode) loads the development variant, which keeps the long error messages and the ANI_DEV_* warnings. The production variant folds the DEV flag to false: DEV ? ... : '' branches are removed, so long message and warning text costs no bundle size, error messages become [CODE] <docs url>, and dev warnings never run. Error classes, code, docs and synchronous throw timing are identical in both. Choose the variant with your bundler's mode (for example Vite's mode / build.minify pipeline) instead of a runtime NODE_ENV check.
Ownership and disposal
Whoever creates a timeline or state machine owns it and must release it.
| Created by | Owner | Released by |
|---|---|---|
useTimeline / createTimeline | The binding | Automatically on unmount / owner cleanup |
useAniStates / createAniStates, useFollow / createFollow | The binding | Automatically on unmount / owner cleanup |
Timeline or group you pass to useAniRef, useAni, useAniGroup, ... | You | You: call dispose() (the binding only detaches), or create it with useTimeline |
createStates(...), follow(...), rafGroup(...) in plain code | You | dispose() |
rafTimeline(...) in plain code | You | timeline.dispose() |
bind(...) | You | The returned unbind function |
onUpdate callback | You | The returned unsubscribe function |
- A playing timeline stays on the clock until it ends, pauses, resets or is disposed. An endless animation (
repeat: Infinity) runs forever if its owner forgets it. - The bindings never dispose a timeline you gave them, because you may still use it elsewhere.
- Svelte:
useTimeline,useAniStatesanduseFollowdispose what they create inonDestroy.
Keep the loop healthy
- An error in one
onUpdatecallback or timeline does not stop the loop. It is reported asynchronously (use the clock'sonErrorto route it to your logger). - Under jank (frames up to 0.5 s) animations stay on the wall clock; after a suspension (hidden tab) they resume instead of jumping. See lag smoothing.
- During development, assert in a test that nothing leaks:
defaultClock.sizemust return to0after your views unmount. See Testing.
Checklist
- Build trees at module scope or memoize them.
- Use
*Refbindings for styles, one timeline per independent element, onerafGroupfor lists, shared trees. - Dispose what you create; let the bindings dispose what they create.
- Prefer
waapiTimelinefor simple transitions that need no per-frame JavaScript. - Test mount/unmount churn and check
clock.size.