Skip to content

Commit

Permalink
support prefers-reduced-motion media by default
Browse files Browse the repository at this point in the history
  • Loading branch information
alexeyraspopov committed Oct 18, 2024
1 parent e389bc5 commit d3b48db
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 40 deletions.
45 changes: 31 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -34,23 +34,23 @@ 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
});
```

2. Object with motion properties:

```javascript
```js
motion({ x: spring(0, 100), y: spring(-50, 50) }, ({ x, y }) => {
// x and y animated simultaneously
});
```

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
});
Expand Down Expand Up @@ -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})`;
Expand All @@ -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)`;
});
Expand Down
26 changes: 16 additions & 10 deletions modules/motion.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
Expand All @@ -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);
};
}

Expand Down
36 changes: 25 additions & 11 deletions modules/spring.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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++) {
Expand Down
8 changes: 4 additions & 4 deletions oscillation.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
export function motion<Value extends Motion<any>>(
defs: Value,
callback: (state: Value extends Motion<infer V> ? 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.
Expand All @@ -27,7 +27,7 @@ export function motion<Value extends Motion<any>>(
export function motion<Dict extends { [k: string]: Motion<any> }>(
defs: Dict,
callback: (state: { [k in keyof Dict]: Dict[k] extends Motion<infer V> ? 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.
Expand All @@ -45,11 +45,11 @@ export function motion<Dict extends { [k: string]: Motion<any> }>(
export function motion<List extends [Motion<any>, ...Motion<any>[]]>(
defs: List,
callback: (state: { [k in keyof List]: List[k] extends Motion<infer V> ? V : never }) => void,
options?: { signal: AbortSignal },
options?: { signal?: AbortSignal; ignoreReducedMotion?: boolean },
): void;

export type Motion<Value> = {
update(): void;
update(n: number): void;
complete(): boolean;
interpolate(t: number): Value;
};
Expand Down
17 changes: 17 additions & 0 deletions oscillation.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down

0 comments on commit d3b48db

Please sign in to comment.