Ani
Guides

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 than maxDuration seconds plays faster so it takes maxDuration.
    • 'ignore': animates normally.
  • waapiTimeline(node, { reducedMotion? }):
    • 'jump' (default): finishes immediately.
    • { maxDuration }: raises playbackRate to duration / 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 most maxDuration seconds.
    • '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').

Policies

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