Reduced motion
Ani honors prefers-reduced-motion by default. Pick a policy per timeline and test it deterministically.
Some people get sick from large movement. They turn on reduce motion in their operating system, and browsers report it as prefers-reduced-motion: reduce. Ani honors it by default for every timeline type: rafTimeline, waapiTimeline, rafGroup, follow and the timelines that createStates creates. Only drive() does not read the preference: the user controls a driven run directly.
The default
When the preference is on, animations jump by default: play() or set() validates as usual, then the run completes at its final values.
import * as a from "@freestylejs/ani-core";
const timeline = a.rafTimeline(
a.ani({ to: { opacity: 1 }, duration: 1 }),
);
const result = await timeline.play({ from: { opacity: 0 } });
// result === 'ended', timeline.status === 'ENDED', value is { opacity: 1 }Bound targets are written, onUpdate listeners are notified once, and the clock is never subscribed (or unsubscribed immediately). With repeat, the value is the end of the last run; with repeat: Infinity, the end of one run.
The preference comes from the clock: by default it follows matchMedia('(prefers-reduced-motion: reduce)') live (and is false where matchMedia does not exist, such as on the server). It is read once when the run starts (play(), set(), transitionTo()), so a change affects the next run and never one that is already running.
Timeline types and options
Set a policy with the reducedMotion option on each timeline type:
rafTimeline(node, { reducedMotion? }):'jump'(default): completes at once in its end state and resolves'ended'. Endless runs pause at the end of their first iteration.{ maxDuration }: a run longer thanmaxDurationseconds plays faster so it takesmaxDuration.'ignore': animates normally.
waapiTimeline(node, { reducedMotion? }):'jump'(default): finishes immediately.{ maxDuration }: raisesplaybackRatetoduration / maxDuration.'ignore': animates normally.
rafGroup(node, { reducedMotion? }):'jump'(default): finishes every target (stagger included) in one frame at its final values and resolves'ended'. Endless runs pause at the end of their first iteration.{ maxDuration }: compresses the whole group run, stagger included, to at mostmaxDurationseconds.'ignore': animates normally.
follow(initial, { reducedMotion? }):'jump'(default): while reduced motion is on,set()reaches the target on the next frame with zero velocity, notifies once, and resolves'ended'.'ignore': springs toward the target normally.{ maxDuration }is rejected (TypeError, a spring has no duration).
createStates({ states, ..., reducedMotion? }):- Forwarded to every state's timeline (
'jump'default,{ maxDuration },'ignore').
- Forwarded to every state's timeline (
Policies
| Policy | Behavior |
|---|---|
'jump' (default) | Every run completes at once in its end state (on the next frame for follow). |
{ maxDuration } | A run longer than maxDuration seconds plays faster so it takes maxDuration. Shorter runs are untouched. |
'ignore' | Unchanged behavior. |
import * as a from "@freestylejs/ani-core";
const node = a.ani({ to: { opacity: 1 }, duration: 1 });
// Keep a short, gentle transition instead of an instant change.
const panel = a.rafTimeline(node, { reducedMotion: { maxDuration: 0.2 } });
// A 1 s run plays 5x faster. `panel.duration` still reports 1.maxDuration must be finite and greater than 0, otherwise the constructor throws a RangeError.
On waapiTimeline the same options apply, and clock only provides the preference (it defaults to the shared clock):
import * as a from "@freestylejs/ani-core";
const node = a.ani({ to: { opacity: 1 }, duration: 1 });
const line = a.waapiTimeline(node, { reducedMotion: "jump" });'jump' creates the animations and finishes them immediately. { maxDuration } raises each animation's playbackRate to duration / maxDuration.
When to use 'ignore'
Reduced motion is about decorative movement. Keep 'ignore' for motion that carries information: a progress indicator, a loading spinner that proves the app is alive, a timer, a drag that follows the pointer.
import * as a from "@freestylejs/ani-core";
const spinner = a.rafTimeline(
a.ani({ to: { rotate: 360 }, duration: 1 }),
{ reducedMotion: "ignore" }
);Choosing the preference source
Pass reducedMotion to createClock to override the system value. Timelines and machines that use that clock ({ clock }) follow it:
import { createClock } from "@freestylejs/ani-core";
createClock({ reducedMotion: "system" }); // default
createClock({ reducedMotion: true }); // force on, e.g. an in-app setting
createClock({ reducedMotion: false }); // never reduce, whatever the system says
createClock({ reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)") });An object with a boolean matches is read live. clock.reducedMotion returns the current effective value. A custom clock that has no reducedMotion member reports false.
Testing
createManualClock takes reducedMotion (default false) and exposes setReducedMotion:
import * as a from "@freestylejs/ani-core";
import { createManualClock } from "@freestylejs/ani-core/testing";
import { expect, it } from "vitest";
it("jumps when reduced motion is on", async () => {
const node = a.ani({ to: { opacity: 1 }, duration: 1 });
const manual = createManualClock();
const timeline = a.rafTimeline(node, { clock: manual.clock });
manual.setReducedMotion(true);
await expect(timeline.play({ from: { opacity: 0 } })).resolves.toBe("ended");
expect(timeline.value).toEqual({ opacity: 1 });
expect(manual.clock.size).toBe(0);
});Flip the preference between plays to test both paths with the same timeline. See Testing for the rest of the manual clock.