Split panel: the two duration properties a reduced-motion reset declares, beside the per-frame inline styles a tween writes.

How to use anime.js in v4, and guard the motion you add

The v4 API change costs you an afternoon. The reduced-motion guard is the part that decides whether your motion is usable.

Why does an anime.js example fail at the import line?#

Because v4 has no default export and no callable global anime({...}) form. Code written against the v3 call signature fails at the import line rather than in the animation. Consequently the error rarely looks like an animation problem at all. You get an undefined value, or a "not a function" thrown before a single frame runs.

The entry point in v4 is a named import, and the anime.js documentation covers the install and import shape:

import-esm.js · js
import { animate } from 'animejs';

CommonJS takes the same shape:

import-cjs.js · js
const { animate } = require('animejs');

Also worth knowing if you load from a CDN as an ES module:

import-cdn.js · js
import { animate } from 'https://esm.sh/animejs';

The UMD build still exposes a global called anime, and that is where the confusion completes itself. In v4 that global is a namespace object you destructure, not a function you call:

import-umd.js · js
const { animate } = anime;

Therefore a paste of the older form fails twice over. There is nothing to default-import, and the global is not callable. Once you know that, the fix is mechanical rather than diagnostic.

Where a pasted v3 snippet stops on v4, on both the ESM path and the UMD pathBoth failure points sit upstream of the first frame. The ESM path stops at the import, because there is no default export to bind. The UMD path gets one node further and stops at the call, because the global is a namespace object rather than a function. The animation nodes downstream are never reached.

Which anime.js version should you write against?#

Write against v4. On the npm registry, the latest dist-tag resolves to 4.5.0, published 2026-06-22. Every code sample in this post runs on that release. Version 4.0.0 opened the v4 line on 2025-04-03. Meanwhile the last v3 release is 3.2.2, published 2023-11-28, and the licence is MIT.

There is also a v5 build in circulation, and it is a beta. 5.0.0-beta.2 was published 2026-08-17. Moreover it is available under the beta dist-tag only, never under latest. Therefore it is not what npm install animejs gives you, and none of the code here targets it. There is no v3 dist-tag on the registry at all.

  1. 2023-11-28

    3.2.2, the last v3 release

    The end of the v3 line. There is no v3 dist-tag on the registry at all, so nothing resolves to it by name. Licence: MIT.

  2. 2025-04-03

    4.0.0 opens the v4 line

    The release that removed the default export and the callable anime({...}) form, and introduced the named-import entry points this post teaches.

  3. 2026-06-22

    4.5.0, the latest dist-tag

    What npm install animejs gives you today, and the release every code sample in this post runs on.

  4. 2026-08-17

    5.0.0-beta.2, beta dist-tag only

    Available under the beta dist-tag and never under latest, so it is not what a plain install returns. None of the code here targets it.

Those release dates are the whole of what the registry tells you. Read them and draw your own line. In short, the v4 line has moved and the v3 line has a last release date. Anything beyond that, including any claim about the project's plans, is not something the registry supports. Therefore this post will not invent it.

In practice the decision is simple. New work starts on 4.5.0. Existing v3 code has one conversion ahead of it, and the next section is that conversion. Since every answer to how to use anime.js depends on which major you are holding, settle this first. Then the rest of the API stops surprising you.

How to use anime.js from zero, and convert v3 code in the same pass#

Start with one import and one call. Here is a complete first animation on 4.5.0, straight from the documentation:

first-animation.js · js
import { animate } from 'animejs';

animate('.square', {
  rotate: 90,
  loop: true,
  ease: 'inOutExpo',
});

Notice the argument shape. Targets are the positional first argument, not a targets: key inside the options object. Everything else is the second argument: properties to animate, plus timing and easing.

That call is also the right-hand side of the conversion map. Each v3 shape below has exactly one v4 equivalent. Below are the forms this post uses, plus the ones a converted v3 timeline hits first:

The v3 to v4 conversion map, shape by shape
v3v4
anime({ targets: '.el', ... })animate('.el', { ... })
anime.timeline({...})createTimeline({...})
.add(params, offset).add(targets, params, position)
easing:ease:
easeInOutExpoinOutExpo

Two of those deserve a sentence each. First, the property is ease in v4, not easing. Second, the curve names lost their ease prefix, so easeInOutExpo becomes inOutExpo. Parameterised power easing is written as a call inside the string, 'inOut(3)'. Moreover v4 adds an outIn direction alongside in, out and inOut. The bundle carries inQuad, outQuad, inOutQuad and outInQuad. Also present are the same four directions through Cubic, Quart, Quint, Sine, Circ, Expo, Bounce, Back and Elastic. Beyond those there are easing factories: linear, irregular, steps, cubicBezier, and spring through createSpring. For the full list of curve names, the anime.js documentation carries the ease property and its easing reference.

How do you sequence animations with createTimeline()?#

A timeline in v4 is built with createTimeline(). Its control lives in the position argument you pass alongside each added animation. The anime.js documentation covers createTimeline, .add(), .label() and the position argument in one place. You are not chaining callbacks, and you are not stacking delays by hand.

timeline.js · js
import { createTimeline } from 'animejs';

const tl = createTimeline({ defaults: { duration: 750 } });

tl.label('start')
  .add('.square', { x: '15rem' }, 500)
  .add('.circle', { x: '15rem' }, 'start')
  .add('.triangle', { x: '15rem', rotate: '1turn' }, '<-=500');

Three position forms appear there, and they are the three you need. An absolute millisecond value, 500, places the animation at that point on the timeline. A label name, 'start', places it at a marker you created with .label(). A relative offset, '<-=500', anchors to the previous animation, in this case starting 500ms before it would otherwise have.

Three animations placed on one timeline track by absolute value, by label and by relative offsetThe same three .add() calls as the code above, drawn on one track at the timeline's 750ms default duration. The absolute 500 lands at a fixed point, the label marker anchors the second animation to a name rather than a number, and the relative offset pulls the third back from the previous animation's start.

The timeline also carries .add(), .sync(), .call() and .label() as its methods. .label() is the one worth reaching for early. A named marker survives you inserting an animation above it, whereas a hand-counted millisecond offset does not. The argument order is the silent failure a converted v3 timeline hits first. In v4 it is .add(targets, params, position), where v3 was .add(params, offset). Nothing throws when you get that wrong. The targets argument simply receives an options object. Then the sequence plays in an order you did not ask for.

What does stagger() actually return?#

It returns a function, not a number. stagger() produces a StaggerFunction. Then the library calls that function once per target with that target's index. That single fact is the whole mechanism. Consequently a stagger value can drive any animatable property rather than only delay.

You can check it in the published artifact. The type declaration lives at dist/modules/utils/stagger.d.ts inside the 4.5.0 package. There the export is overloaded four ways, and every overload returns StaggerFunction<number> or StaggerFunction<string>. The simplest of them reads:

stagger.d.ts · ts
export function stagger(val: number, params?: StaggerParams): StaggerFunction<number>;

The familiar shape puts it in a delay slot, which is how the anime.js documentation introduces stagger usage:

stagger-delay.js · js
import { animate, stagger } from 'animejs';

animate('.card', { opacity: [0, 1], delay: stagger(40) });
Show data table
stagger(40) across twelve targets: the delay each target receives, by its index
Item Delay before this target starts
Card 1 (index 0) 0 ms
Card 2 (index 1) 40 ms
Card 3 (index 2) 80 ms
Card 4 (index 3) 120 ms
Card 5 (index 4) 160 ms
Card 6 (index 5) 200 ms
Card 7 (index 6) 240 ms
Card 8 (index 7) 280 ms
Card 9 (index 8) 320 ms
Card 10 (index 9) 360 ms
Card 11 (index 10) 400 ms
Card 12 (index 11) 440 ms

The returned function is called once per target with that target's index, so the delay is index times 40 and nothing else. It runs 0ms on the first card to 440ms on the twelfth, in an exact straight line. The delay slot is incidental to the mechanism: the same per-target value can drive scale, opacity, rotation or duration, and the range form stagger([1.1, 0.75]) produces this same linear ramp running the other way.

Figure stagger(40) across twelve targets: the delay each target receives, by its index Computed from the documented stagger contract in anime.js 4.5.0, not measured

However the slot is not part of the mechanism. Because stagger returns a per-target value generator, it can sit anywhere a value can:

stagger-range.js · js
animate('.card', { scale: stagger([1.1, 0.75]) });

That range form hands the first target 1.1 and the last target 0.75. Every target in between gets a value along that range. In short, read stagger() as "a value that depends on which target this is" rather than as "a delay helper". Then translateY, opacity, rotation and duration are all open to it. For instance a list that fades in can also fan out slightly, with one call and no per-item bookkeeping. That works because the index the library passes is the only thing either property needed.

What moves on first paint, and what waits for the reader?#

First paint gets the one thing a new arrival needs to orient on, and everything further down waits for the scroll. Decide it per element, because motion means different things at different points in a page. onScroll() ties an animation's start to the reader's scroll position. Therefore a reveal can happen where it belongs in the reading order, rather than all at once at the top.

on-scroll.js · js
import { animate, onScroll } from 'animejs';

animate('.panel', {
  opacity: [0, 1],
  y: [24, 0],
  duration: 600,
  autoplay: onScroll({ enter: 'bottom-=100 top' }),
});

The question that decides it is what the reader is looking at. Motion at first paint is aimed at someone who has just arrived and is orienting. Motion further down is aimed at someone already reading. In practice it works when it marks a transition they are moving through, rather than announcing itself. Therefore a hero that introduces a page and a statistics band halfway down are two different jobs. Attaching both to page load collapses them into one.

This is a structural decision before it is a code decision. Therefore it usually belongs with whoever owns the page layout. Sometimes that conversation happens on a design surface rather than in a component file. In that case our UI and UX design practice is where we do that work. Still, the mechanism is the same wherever it is decided: one animation, one trigger, chosen deliberately.

What happens when an animation outlives its component?#

It keeps running against nodes that are no longer in the document. An animation created on mount holds references to its targets, and unmounting the component does not stop it. Consequently you get a timer still ticking, still writing values, against detached elements nobody can see.

createScope() is the documented disposal boundary. You create your animations inside a scope, and you keep the scope. Then you revert it when the owning component goes away. Here that is written as a React effect with its cleanup return:

scoped-panel.jsx · jsx
import { useEffect, useRef } from 'react';
import { createScope, animate } from 'animejs';

function Panel() {
  const root = useRef(null);

  useEffect(() => {
    const scope = createScope({ root }).add(() => {
      animate('.card', { opacity: [0, 1], duration: 600 });
    });

    return () => scope.revert();
  }, []);

  return <div ref={root}><div className="card" /></div>;
}
An animation's lifecycle with and without a scope, from mount through unmountThe unscoped path has no terminal state: unmount removes the nodes and the animation keeps writing values to targets nobody can see, with no error and nothing visible. The scoped path closes the loop, because revert ends the animation at the moment its owner is torn down.

The framework is incidental. Whatever you are using, the rule is that whatever creates the animation owns it. Then, the moment that owner is torn down, the scope is reverted. A Vue watcher, a Svelte lifecycle hook and a plain component setup function transpose the same two lines. The scope is worth creating even for a single animation. It costs one line at setup. Besides, it removes the question of whether this particular tween needed teardown.

createScope() also takes a mediaQueries option, typed as Record<string, string>, and it calls window.matchMedia for each entry. That option is the hook the next act turns on, so note it now. Our introduction to building accessible websites covers the practice this guard sits inside.

Why does your reduced-motion reset not touch a JavaScript tween?#

Because the reset declares two properties that a JavaScript tween never uses. Here is the reset:

reduced-motion-reset.css · css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

Read what it actually declares. animation-duration takes effect where a CSS animation exists. transition-duration takes effect where a CSS transition exists. A JavaScript tween is neither of those things. Instead it writes a discrete value to transform or opacity on every frame. Therefore those two declarations have nothing to act on. Nothing is overridden, because nothing is competing.

The reset's two declarations and a JavaScript tween's per-frame write, drawn as two sets with no edge between themThe teaching here is the absence of an edge. The reset's animation-duration and transition-duration reach into the CSS animation and CSS transition surface and terminate there. The tween's per-frame write to inline transform and opacity sits in a second set that nothing in the reset points at. This is a category difference, not a precedence contest.

That distinction is category, not precedence, and the difference matters. An !important author declaration genuinely does beat a normal inline declaration of the same property. Therefore any story about the inline style winning the cascade would be wrong. The true version is also the one you can check. Open devtools on a page with a running tween and look at the element. Then you will see an inline transform on one side and two duration declarations on the other. They are about different things. Consequently a stylesheet that has covered your CSS motion correctly for years tells you nothing about the JavaScript motion you added last week.

The library leaves this decision to you. createScope()'s mediaQueries option is the documented place to bind the query, and the next section is the guard itself.

How do you write a reduced-motion guard that holds?#

Read the MediaQueryList, listen for its change event, and resolve to the animation's end state rather than removing anything. window.matchMedia('(prefers-reduced-motion: reduce)') returns an object carrying both a boolean .matches and that event. MDN documents MediaQueryList, .matches and the change event together. Using the event is what makes the guard hold when someone changes the setting while your page is open.

reduced-motion-guard.js · js
import { animate, stagger, utils } from 'animejs';

const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');

if (reduced.matches) {
  utils.set('.card', { opacity: 1, y: 0 });
} else {
  animate('.card', {
    opacity: [0, 1],
    y: [24, 0],
    duration: 600,
    delay: stagger(50),
    ease: 'out(3)',
  });
}
  1. Read the MediaQueryList once, at setup.

    window.matchMedia('(prefers-reduced-motion: reduce)') returns an object, not a boolean. Keep the object, because the fourth step needs it.

  2. Branch on .matches before you create the animation.

    The preference comes from the operating system rather than from your page, so it is available before your first frame runs.

  3. Set the finished values rather than removing anything.

    utils.set writes the end state directly. Someone who asked for less motion gets the same layout everyone else ends up with.

  4. Bind the change event so a mid-session toggle is respected.

    This is the step authors skip. A guard that reads the preference once at load only ever handles half of what the preference can do.

Look at the if branch. It does not delete the cards and it does not skip their content. Instead it sets the finished values directly. Then a reader who asked for less motion gets the same layout everyone else ends up with. That is the correct reading of the setting.

Wire the same query through the change event so a mid-session toggle is respected:

reduced-motion-change.js · js
reduced.addEventListener('change', (event) => {
  if (event.matches) utils.set('.card', { opacity: 1, y: 0 });
});

prefers-reduced-motion is documented by MDN Web Docs. Its reference page for the media feature describes it as detecting whether a user has requested the system minimise non-essential motion (MDN Web Docs, accessed 13 September 2026). Because that request comes from the operating system rather than from your page, it is available before your first frame runs. Moreover it can change afterwards. A guard that reads it once at load only ever handles half of that.

What does a stagger step cost you in seconds?#

More than you would guess, and the arithmetic is worth doing before you ship. First, the criterion this feeds into. SC 2.2.2 Pause, Stop, Hide, in the WCAG 2.2 Recommendation, applies where all three of its conditions hold together: the moving content starts automatically, it lasts more than five seconds, and it is presented in parallel with other content. The arithmetic below settles exactly one of those, the duration. Therefore it is an input to the question, not a conformance verdict.

totalcount1stepduration
Stagger budget: set the target count and the stagger step, and read the computed wall-clock against the five-second mark
Total wall-clock of the last target: 1,150 msThe five-second mark named in SC 2.2.2 Pause, Stop, Hide: 5,000 ms

Where the last target lands#

[+] Under
Delay on the last target11 x 50 ms = 550 ms
Total wall-clock of the last target550 ms + 600 ms = 1,150 ms
Threshold[+] The sequence finishes inside the five-second mark.

This reports a duration against a threshold, not a conformance verdict. SC 2.2.2 has three conjunctive conditions, and duration is only one of them: the content must also start automatically and be presented in parallel with other content.

Threshold from SC 2.2.2 Pause, Stop, Hide, WCAG 2.2 Recommendation, W3C. The criterion reads more than five seconds, so a duration equal to the mark is not past it.

Each worked case with its computed total and where it sits against the five-second mark.
CaseTargetsStepTotalAgainst the markThe arithmetic
Twelve cards at a 50ms step1250 ms1,150 ms[+] Under11 x 50 = 550ms of delay, plus its own 600ms duration, finishing at 1150ms.
Twelve cards at a 400ms step12400 ms5,000 ms[=] At the boundary11 x 400 = 4400ms, plus 600ms, landing on 5000ms exactly. At the boundary and still outside it.
Thirteen cards at a 400ms step13400 ms5,400 ms[!] Over12 x 400 + 600 = 5400ms. One more card at the same step takes it past the mark.
Twelve cards at a 401ms step12401 ms5,011 ms[!] Over11 x 401 + 600 = 5011ms. One millisecond on the step does the same thing.
Move either control and the last target's wall-clock is recomputed and drawn against the threshold line. The four worked cases from this section are always present as a table, so the figure teaches the same facts with JavaScript disabled. The bar reports a duration against a threshold; it is not a conformance verdict.

Twelve cards at a 50ms step#

Take the guard's own example, on a grid of twelve cards. stagger(50) multiplies the step by the target index. Therefore card 12 is index 11 and carries 11 x 50 = 550ms of delay. Adding its own 600ms duration, the sequence finishes at 1150ms, or 1.15 seconds. That is not a duration worth worrying about.

Where the same grid reaches the boundary#

Now change one number. At a 400ms step the same twelve cards give 11 x 400 = 4400ms of delay on the last card. Then its own 600ms duration takes the total to 5000ms. 5000ms is exactly five seconds, and the criterion's threshold is more than five seconds. Therefore at that value the criterion is not engaged. The sequence sits precisely at the boundary and still outside it.

What puts it over is one more card at that step, since 12 x 400 + 600 = 5400ms. Alternatively a step of 401ms does it with the same twelve cards, because 11 x 401 + 600 = 5011ms. In other words a single design change you would make without thinking moves the page across a line. Still, that line is one you were not measuring.

One more distinction. Where the reveal is something the reader started by scrolling, its automatic-start condition is genuinely arguable. Instead SC 2.3.3 Animation from Interactions, in the WCAG 2.2 Recommendation, is the criterion whose subject is motion triggered by interaction. Reserve SC 2.2.2's full three-condition reading for motion that runs at first paint without the reader doing anything. Either way, the number is one the author needs.

Which WCAG criteria cover motion, and at what levels?#

Three of them, and their levels are worth getting right because a misquoted level weakens the argument you are making. All three are stated in the W3C's Web Content Accessibility Guidelines 2.2 Recommendation, published by the W3C.

The three WCAG 2.2 success criteria that cover motion, and which motion in this post each one covers
Success criterionLevelWhat engages itWhich motion here
SC 2.2.2 Pause, Stop, HideAAll three together: the content starts automatically, lasts more than five seconds, and is presented in parallel with other content. Where all three hold, it needs a mechanism to pause, stop or hide it.Motion that runs at first paint without you doing anything.
SC 2.3.1 Three Flashes or Below ThresholdANothing flashes more than three times in any one second period.Any flashing content, whatever triggered it.
SC 2.3.3 Animation from InteractionsAAAHonouring a motion preference for animation you triggered.Most of the scroll-driven work in this post.

SC 2.2.2 Pause, Stop, Hide is a Level A criterion. It covers moving content that starts automatically, lasts more than five seconds and is presented in parallel with other content. Where all three hold, that content needs a mechanism to pause, stop or hide it.

Also at Level A sits SC 2.3.1 Three Flashes or Below Threshold. Its threshold is that nothing flashes more than three times in any one second period.

Meanwhile SC 2.3.3 Animation from Interactions sits at Level AAA, not AA. This is the criterion about honouring a motion preference for animation the reader triggered. Also, it is the one that covers most of the scroll-driven work in this post. In contrast, SC 2.2.2 is the one that covers motion starting automatically, which is the first-paint case.

Pairing those two the other way round is the same error wearing different clothes, so match each criterion to the kind of motion it actually covers. A WCAG level is a conformance target rather than a duty owed by every site. Whether any of it binds a particular project depends on a jurisdiction, an instrument and a commitment this post does not name. Besides, the guard stands on its own merits. Someone who has asked for less motion and got a page that ignores the request does not need a level number to tell you it went wrong.

Perhaps you want the levels laid out beside each other rather than three at a time. In that case our comparison of the WCAG guidelines does exactly that.

When should you not use anime.js at all?#

When the job is a hover, a fade, or a single declared state change with no sequencing and no cleanup. That belongs in CSS. The reason is the mechanism from two sections ago, read in the other direction. A CSS transition is exactly what the reduced-motion reset acts on. Declare the motion in CSS and the guard you would otherwise write by hand comes free.

On size, here is exactly what exists. The anime.js documentation on when to use the Web Animation API, accessed 13 September 2026, states that "Initial page load time is critical and every KB counts (3KB gzip vs 10KB for the JavaScript version)", comparing its waapi.animate() entry point against animate().

The minified single-file UMD build of each major, gzipped at level 940,592 bytes against 7,086 bytes

7,086 bytes

3.2.2, lib/anime.min.js

current major

40,592 bytes

4.5.0, dist/bundles/anime.umd.min.js

Both are the whole library in one file rather than a tree-shaken application build, and v4 carries features v3 did not have. Read those two numbers as what they are and not as an argument for an older major. The documentation's 3KB against 10KB figure quoted above is a different comparison from a different source, between two entry points inside v4, and does not share this scale.

The minified single-file UMD build of each major, gzipped at level 9 (bytes, gzip level 9, measured by Atyantik on the published packages)
Optionbytes, gzip level 9, measured by Atyantik on the published packages
3.2.2, lib/anime.min.js7,086 bytes
4.5.0, dist/bundles/anime.umd.min.js40,592 bytes

Separately, we measured each major's minified single-file UMD build ourselves at gzip level 9: lib/anime.min.js in 3.2.2 is 7,086 bytes, and dist/bundles/anime.umd.min.js in 4.5.0 is 40,592 bytes. Both are the whole library in one file rather than a tree-shaken application build, and v4 carries features v3 did not have. Read those two numbers as what they are and not as an argument for an older major.

For CSS state work done carefully, our post on focus and focus-visible without the headache is the same discipline applied to a different problem.

What on your page must never move?#

Answer that before you write the animation, not after. By this point the post has opened three structural questions, and they close together. Those are what animates on first paint, what waits for a scroll, and what must never move at all. The third one is the easiest to leave implicit. Yet it is the one that decides whether a page feels considered.

A page partitioned into three motion zones: first paint, the scroll band, and the regions that never moveThe post's three structural questions, resolved together. First paint holds exactly one thing. The scroll band holds every other reveal, placed where it belongs in the reading order. The never-moves zone holds anything carrying an error, anything you are typing into, and anything they came to read. The guard is the one obligation all three zones share.

So work through them in order on your own page. First, name the one thing that should move when someone arrives, and accept that it is one thing. Second, place every other reveal behind the scroll position where it belongs in the reading order. Third, list the regions where motion is never appropriate. They are anything holding an error, anything the reader is typing into, anything they came to read.

Then write the guard on the same day as the animation. That is the single commitment this post asks for. A reduced-motion branch added later is a separate task, and a separate task can slip. Written beside the tween it costs four lines, and you already have them above. In the end that is most of what knowing how to use anime.js well amounts to. The calls are small, the sequencing is explicit, and the guard is yours to write.

Finally, perhaps you would rather have an existing site checked than work through this yourself. In that case our accessibility testing service tests a site against WCAG 2.2. Either way the decision is the same one, and it belongs to whoever ships the page.

Talk to us about motion that holds up

Keep reading