Skip to content

Commit 3e9b8af

Browse files
committed
Support listening to prefers-reduced-motion setting
1 parent 04910f7 commit 3e9b8af

File tree

8 files changed

+164
-14
lines changed

8 files changed

+164
-14
lines changed

examples/stories/RiveOverview.stories.mdx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { useState } from 'react';
44

55
import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs';
66

7-
import RiveComponent, {useRive, useStateMachineInput} from '../../src';
8-
import {Button} from './components/Button';
7+
import RiveComponent, { useRive, useStateMachineInput } from '../../src';
8+
import { Button } from './components/Button';
99
import './rive-overview.css';
1010

1111
<Meta title="React Runtime/Overview" />
@@ -31,6 +31,7 @@ There's multiple ways to render Rive using the React runtime. See the associated
3131
```tsx
3232
import RiveComponent from '@rive-app/react-canvas';
3333
```
34+
3435
The React runtime exports a default React component you can insert as JSX. Under the hood, it renders a `<canvas>` element that runs the animation, and a wrapping `<div>` element that handles sizing of the canvas based on the parent that wraps the component.
3536

3637
**When to use this**: Use this for simple rendering cases where you don't need to control playback or setup state machine inputs to advance state machines. It will simply autoplay the first animation it finds in the `.riv`, the animation name you provide it, or the state machine name if you provide one.
@@ -56,20 +57,21 @@ In addition to the props laid out below, the component accepts other props that
5657
### useRive Hook
5758

5859
```tsx
59-
import {useRive} from '@rive-app/react-canvas';
60+
import { useRive } from '@rive-app/react-canvas';
6061
```
6162

6263
The runtime also exports a named `useRive` hook that allows for more control at Rive instantiation, since it passes back a `rive` object you can use to manipulate state machines, control playback, and more.
6364

6465
**When to use this:** When you need to control your Rive animation in any aspect, such as controlling playback, using state machine inputs to advance state machines, add adding callbacks on certain Rive-specific events such as `onStateChange`, `onPause`, etc.
66+
6567
<Canvas withSource="open">
6668
<Story name="useRive Hook">
6769
{() => {
6870
const [isPlaying, setIsPlaying] = useState(true);
6971
const [animationText, setAnimationText] = useState('');
7072
const { rive, RiveComponent: RiveComponentPlayback } = useRive({
7173
src: 'truck.riv',
72-
stateMachines: "drive",
74+
stateMachines: 'drive',
7375
artboard: 'Truck',
7476
autoplay: true,
7577
onPause: () => {
@@ -88,15 +90,17 @@ The runtime also exports a named `useRive` hook that allows for more control at
8890
setIsPlaying(true);
8991
}
9092
};
91-
return ((
93+
return (
9294
<>
9395
<div className="center">
9496
<RiveComponentPlayback className="base-canvas-size" />
9597
<p>{animationText}</p>
96-
<Button onClick={togglePlaying}>{isPlaying ? 'Pause' : 'Play'}</Button>
98+
<Button onClick={togglePlaying}>
99+
{isPlaying ? 'Pause' : 'Play'}
100+
</Button>
97101
</div>
98102
</>
99-
));
103+
);
100104
}}
101105
</Story>
102106
</Canvas>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"format": "prettier --write src",
1616
"types:check": "tsc --noEmit",
1717
"release": "release-it",
18-
"storybook": "start-storybook -p 6006",
18+
"storybook": "NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6006",
1919
"build-storybook": "build-storybook -o docs-build"
2020
},
2121
"repository": {

setupTests.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ window.IntersectionObserver = class IntersectionObserver {
2424
unobserve() {}
2525
};
2626

27+
window.matchMedia = jest.fn().mockImplementation((query) => ({
28+
matches: false,
29+
media: query,
30+
onchange: null,
31+
addEventListener: jest.fn(),
32+
removeEventListener: jest.fn(),
33+
dispatchEvent: jest.fn(),
34+
}));
35+
2736
jest.mock('@rive-app/canvas', () => ({
2837
Rive: jest.fn().mockImplementation(() => ({
2938
on: jest.fn(),

src/components/Rive.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ export interface RiveProps {
2828
* For `@rive-app/react-webgl`, sets this property to maintain a single WebGL context for multiple canvases. **We recommend to keep the default value** when rendering multiple Rive instances on a page.
2929
*/
3030
useOffscreenRenderer?: boolean;
31-
};
31+
/**
32+
* If true, the runtime will respect the users "prefers-reduced-motion" accessibilty option and start the animation paused. Defaults to false.
33+
*/
34+
usePrefersReducedMotion?: boolean;
35+
}
3236

3337
const Rive = ({
3438
src,
@@ -37,6 +41,7 @@ const Rive = ({
3741
stateMachines,
3842
layout,
3943
useOffscreenRenderer = true,
44+
usePrefersReducedMotion = false,
4045
...rest
4146
}: RiveProps & ComponentProps<'canvas'>) => {
4247
const params = {
@@ -50,6 +55,7 @@ const Rive = ({
5055

5156
const options = {
5257
useOffscreenRenderer,
58+
usePrefersReducedMotion,
5359
};
5460

5561
const { RiveComponent } = useRive(params, options);

src/hooks/useRive.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import {
1313
RiveState,
1414
Dimensions,
1515
} from '../types';
16-
import { useSize, useDevicePixelRatio } from '../utils';
16+
import {
17+
useSize,
18+
useDevicePixelRatio,
19+
usePrefersReducedMotion,
20+
} from '../utils';
1721

1822
type RiveComponentProps = {
1923
setContainerRef: RefCallback<HTMLElement>;
@@ -52,6 +56,7 @@ const defaultOptions = {
5256
useDevicePixelRatio: true,
5357
fitCanvasToArtboardHeight: false,
5458
useOffscreenRenderer: true,
59+
usePrefersReducedMotion: false,
5560
};
5661

5762
/**
@@ -100,6 +105,7 @@ export default function useRive(
100105
// occur.
101106
const size = useSize(containerRef);
102107
const currentDevicePixelRatio = useDevicePixelRatio();
108+
const prefersReducedMotion = usePrefersReducedMotion();
103109

104110
const isParamsLoaded = Boolean(riveParams);
105111
const options = getOptions(opts);
@@ -198,6 +204,20 @@ export default function useRive(
198204
}
199205
}, [rive, size, currentDevicePixelRatio]);
200206

207+
const animations = riveParams?.animations;
208+
/**
209+
* Listen to changes on the for the prefersReducedMotion accessibilty setting
210+
*/
211+
useEffect(() => {
212+
if (rive && options.usePrefersReducedMotion) {
213+
if (prefersReducedMotion && rive.isPlaying) {
214+
rive.pause();
215+
} else if (!prefersReducedMotion && rive.isPaused) {
216+
rive.play();
217+
}
218+
}
219+
}, [rive, prefersReducedMotion]);
220+
201221
/**
202222
* Ref callback called when the canvas element mounts and unmounts.
203223
*/
@@ -275,7 +295,6 @@ export default function useRive(
275295
/**
276296
* Listen for changes in the animations params
277297
*/
278-
const animations = riveParams?.animations;
279298
useEffect(() => {
280299
if (rive && animations) {
281300
if (rive.isPlaying) {

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type UseRiveOptions = {
77
useDevicePixelRatio: boolean;
88
fitCanvasToArtboardHeight: boolean;
99
useOffscreenRenderer: boolean;
10+
usePrefersReducedMotion: boolean;
1011
};
1112

1213
export type Dimensions = {

src/utils.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { Dimensions } from './types';
33

44
// There are polyfills for this, but they add hundreds of lines of code
55
class FakeResizeObserver {
6-
observe() { }
7-
unobserve() { }
8-
disconnect() { }
6+
observe() {}
7+
unobserve() {}
8+
disconnect() {}
99
}
1010

1111
function throttle(f: Function, delay: number) {
@@ -127,3 +127,26 @@ export function getDevicePixelRatio(): number {
127127
const dpr = hasDprProp ? window.devicePixelRatio : 1;
128128
return Math.min(Math.max(1, dpr), 3);
129129
}
130+
131+
export function usePrefersReducedMotion(): boolean {
132+
const [prefersReducedMotion, setPrefersReducedMotion] =
133+
useState<boolean>(false);
134+
135+
useEffect(() => {
136+
const canListen = typeof window !== 'undefined' && 'matchMedia' in window;
137+
if (!canListen) {
138+
return;
139+
}
140+
141+
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
142+
function updatePrefersReducedMotion() {
143+
setPrefersReducedMotion(() => mediaQuery.matches);
144+
}
145+
mediaQuery.addEventListener('change', updatePrefersReducedMotion);
146+
updatePrefersReducedMotion();
147+
return () =>
148+
mediaQuery.removeEventListener('change', updatePrefersReducedMotion);
149+
}, []);
150+
151+
return prefersReducedMotion;
152+
}

test/useRive.test.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,4 +451,92 @@ describe('useRive', () => {
451451
expect(canvasSpy).toHaveAttribute('width', '200');
452452
expect(canvasSpy).toHaveAttribute('height', '200');
453453
});
454+
455+
it('pauses the animation if usePrefersReducedMotion is passed as true and media query returns true', async () => {
456+
const params = {
457+
src: 'file-src',
458+
};
459+
460+
window.matchMedia = jest.fn().mockImplementation((query) => ({
461+
matches: true,
462+
media: query,
463+
onchange: null,
464+
addEventListener: jest.fn(),
465+
removeEventListener: jest.fn(),
466+
dispatchEvent: jest.fn(),
467+
}));
468+
469+
const playMock = jest.fn();
470+
const pauseMock = jest.fn();
471+
const stopMock = jest.fn();
472+
473+
const riveMock = {
474+
...baseRiveMock,
475+
stop: stopMock,
476+
play: playMock,
477+
pause: pauseMock,
478+
animationNames: ['light'],
479+
isPlaying: true,
480+
isPaused: false,
481+
};
482+
483+
// @ts-ignore
484+
mocked(rive.Rive).mockImplementation(() => riveMock);
485+
const canvasSpy = document.createElement('canvas');
486+
487+
const { result } = renderHook(() =>
488+
useRive(params, { usePrefersReducedMotion: true })
489+
);
490+
491+
await act(async () => {
492+
result.current.setCanvasRef(canvasSpy);
493+
controlledRiveloadCb();
494+
});
495+
496+
expect(pauseMock).toBeCalled();
497+
});
498+
499+
it('does not pause the animation if usePrefersReducedMotion is passed as false and media query returns true', async () => {
500+
const params = {
501+
src: 'file-src',
502+
};
503+
504+
window.matchMedia = jest.fn().mockImplementation((query) => ({
505+
matches: true,
506+
media: query,
507+
onchange: null,
508+
addEventListener: jest.fn(),
509+
removeEventListener: jest.fn(),
510+
dispatchEvent: jest.fn(),
511+
}));
512+
513+
const playMock = jest.fn();
514+
const pauseMock = jest.fn();
515+
const stopMock = jest.fn();
516+
517+
const riveMock = {
518+
...baseRiveMock,
519+
stop: stopMock,
520+
play: playMock,
521+
pause: pauseMock,
522+
animationNames: ['light'],
523+
isPlaying: true,
524+
isPaused: false,
525+
};
526+
527+
// @ts-ignore
528+
mocked(rive.Rive).mockImplementation(() => riveMock);
529+
const canvasSpy = document.createElement('canvas');
530+
531+
const { result } = renderHook(() =>
532+
useRive(params, { usePrefersReducedMotion: false })
533+
);
534+
535+
await act(async () => {
536+
result.current.setCanvasRef(canvasSpy);
537+
controlledRiveloadCb();
538+
});
539+
540+
expect(pauseMock).not.toBeCalled();
541+
});
454542
});

0 commit comments

Comments
 (0)