diff --git a/README.md b/README.md index ab2a3be..7d8b76e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ npm install oscillation Oscillation is a physics-based animation library for creating smooth, natural-looking animations. -```javascript +```js import { motion, spring } from "oscillation"; // Animate a single value @@ -34,7 +34,7 @@ is overloaded to support different input types: 1. Single motion object: -```javascript +```js motion(spring(0, 100), (value) => { // value gets animated from 0 to 100 }); @@ -42,7 +42,7 @@ motion(spring(0, 100), (value) => { 2. Object with motion properties: -```javascript +```js motion({ x: spring(0, 100), y: spring(-50, 50) }, ({ x, y }) => { // x and y animated simultaneously }); @@ -50,7 +50,7 @@ motion({ x: spring(0, 100), y: spring(-50, 50) }, ({ x, y }) => { 3. Array of motion objects: -```javascript +```js motion([spring([0, 0, 0], [255, 128, 0]), spring(0, 360)], ([color, rotation]) => { // color and rotation animated simultaneously }); @@ -88,30 +88,47 @@ Extra types provided for TypeScript: You can cancel ongoing animations using an `AbortSignal`: -```javascript -const controller = new AbortController(); -const { signal } = controller; +```js +let ctl = new AbortController(); motion( spring(0, 100), (value) => { console.log(value); }, - { signal }, + { signal: ctl.signal }, ); // To cancel the animation: -controller.abort(); +ctl.abort(); +``` + +## Support `prefers-reduced-motion` + +If the user's system has +[reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) +setting enabled, the animation path will be skipped and destination state going to be the only state +rendered. To opt-out of this behavior and always animate the whole path, use `ignoreReducedMotion` +flag: + +```js +motion( + spring(0, 100), + (value) => { + console.log(value); + }, + { ignoreReducedMotion: true }, +); ``` ## Examples ### Animating UI Elements -```javascript +```js import { motion, spring } from "oscillation"; -const button = document.querySelector("#myButton"); +let button = document.querySelector("#myButton"); motion({ x: spring(0, 100), scale: spring(1, 1.2) }, (state) => { button.style.transform = `translateX(${state.x}px) scale(${state.scale})`; @@ -120,13 +137,13 @@ motion({ x: spring(0, 100), scale: spring(1, 1.2) }, (state) => { ### Complex Color and Rotation Animation -```javascript +```js import { motion, spring } from "oscillation"; -const element = document.querySelector("#animatedElement"); +let element = document.querySelector("#animatedElement"); motion([spring([0, 0, 0], [255, 128, 0]), spring(0, 360)], ([color, rotation]) => { - const [r, g, b] = color; + let [r, g, b] = color; element.style.backgroundColor = `rgb(${r}, ${g}, ${b})`; element.style.transform = `rotate(${rotation}deg)`; }); diff --git a/modules/motion.js b/modules/motion.js index 53adaf0..a667acf 100644 --- a/modules/motion.js +++ b/modules/motion.js @@ -7,12 +7,21 @@ export function motion(defs, callback, options) { let cplt = completeOf(defs); let signal = options != null ? options.signal : null; let complete = signal != null ? () => signal.aborted || cplt() : cplt; + let irm = options != null ? options.ignoreReducedMotion : false; + let prm = irm ? { matches: false } : matchMedia("(prefers-reduced-motion: reduce)"); let state; let accumulatedMs = 0; /** @param {number} delta */ function update(delta) { + if (prm.matches) { + updt(-1); + accumulatedMs = 0; + state = intp(0); + return true; + } + // check for accumulated time since we don't fully own the render cycle accumulatedMs += delta; @@ -23,11 +32,8 @@ export function motion(defs, callback, options) { return false; } - // rendering cycle is not consistent and we need to take this into account - for (; accumulatedMs >= MS_PER_FRAME; accumulatedMs -= MS_PER_FRAME) { - updt(); - } - + updt((accumulatedMs / MS_PER_FRAME) | 0); + accumulatedMs %= MS_PER_FRAME; state = intp(accumulatedMs / MS_PER_FRAME); return true; } @@ -45,19 +51,19 @@ export function motion(defs, callback, options) { function updateOf(defs) { if (typeof defs.update === "function") { // is single motion object - return () => defs.update(); + return (n) => defs.update(n); } if (Array.isArray(defs)) { // is array of motion objects - return () => { - for (let index = 0; index < defs.length; index++) defs[index].update(); + return (n) => { + for (let index = 0; index < defs.length; index++) defs[index].update(n); }; } // otherwise, assume dict of motion objects - return () => { - for (let key in defs) defs[key].update(); + return (n) => { + for (let key in defs) defs[key].update(n); }; } diff --git a/modules/spring.js b/modules/spring.js index da067b7..2945e73 100644 --- a/modules/spring.js +++ b/modules/spring.js @@ -26,10 +26,16 @@ export function spring(source, destination, config = springs.noWobble) { function singlespring(current, destination, damping, stiffness, precision) { let velocity = 0; return { - update() { - let tuple = step(current, velocity, destination, damping, stiffness, precision); - current = tuple[0]; - velocity = tuple[1]; + update(n) { + if (n === -1) { + current = destination; + velocity = 0; + } else + while (n-- > 0) { + let tuple = step(current, velocity, destination, damping, stiffness, precision); + current = tuple[0]; + velocity = tuple[1]; + } }, complete() { if (velocity !== 0 || current !== destination) return false; @@ -46,13 +52,21 @@ function multispring(current, destination, damping, stiffness, precision) { let velocity = new Float64Array(current.length); let interpolated = current.slice(); return { - update() { - let tuple; - for (let i = 0; i < current.length; i++) { - tuple = step(current[i], velocity[i], destination[i], damping, stiffness, precision); - current[i] = tuple[0]; - velocity[i] = tuple[1]; - } + update(n) { + if (n === -1) { + for (let i = 0; i < current.length; i++) { + current[i] = destination[i]; + velocity[i] = 0; + } + } else + while (n-- > 0) { + let tuple; + for (let i = 0; i < current.length; i++) { + tuple = step(current[i], velocity[i], destination[i], damping, stiffness, precision); + current[i] = tuple[0]; + velocity[i] = tuple[1]; + } + } }, complete() { for (let i = 0; i < current.length; i++) { diff --git a/oscillation.d.ts b/oscillation.d.ts index de17b17..f39046a 100644 --- a/oscillation.d.ts +++ b/oscillation.d.ts @@ -11,7 +11,7 @@ export function motion>( defs: Value, callback: (state: Value extends Motion ? V : never) => void, - options?: { signal: AbortSignal }, + options?: { signal?: AbortSignal; ignoreReducedMotion?: boolean }, ): void; /** * Starts an animation and calls the callback with updated values on each frame. @@ -27,7 +27,7 @@ export function motion>( export function motion }>( defs: Dict, callback: (state: { [k in keyof Dict]: Dict[k] extends Motion ? V : never }) => void, - options?: { signal: AbortSignal }, + options?: { signal?: AbortSignal; ignoreReducedMotion?: boolean }, ): void; /** * Starts an animation and calls the callback with updated values on each frame. @@ -45,11 +45,11 @@ export function motion }>( export function motion, ...Motion[]]>( defs: List, callback: (state: { [k in keyof List]: List[k] extends Motion ? V : never }) => void, - options?: { signal: AbortSignal }, + options?: { signal?: AbortSignal; ignoreReducedMotion?: boolean }, ): void; export type Motion = { - update(): void; + update(n: number): void; complete(): boolean; interpolate(t: number): Value; }; diff --git a/oscillation.spec.js b/oscillation.spec.js index 1cb8e0a..a4faf8d 100644 --- a/oscillation.spec.js +++ b/oscillation.spec.js @@ -115,3 +115,20 @@ test("explicit motion cancellation", async ({ execute, advance }) => { -10, -14.251734665738812, -20.711652077546674, -28.117553923398887, ]); }); + +test("prefers reduced motion", async ({ page, execute, advance }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + + let snapshots = await execute(async () => { + let { motion, spring } = await import("./oscillation.js"); + let snapshots = []; + let ctl = new AbortController(); + motion(spring(0, 10), (x) => snapshots.push(x), { signal: ctl.signal }); + return snapshots; + }); + + await advance(1, 0); + await advance(5, 1000 / 60); + + expect(await snapshots.jsonValue()).toEqual([10]); +}); diff --git a/package.json b/package.json index 18c6fa6..6d6edd0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "oscillation", "description": "An animation library", - "version": "0.3.0", + "version": "0.4.0", "license": "ISC", "author": "Oleksii Raspopov", "repository": "UnknownPrinciple/oscillation",