` element that handles sizing of the canvas based on the parent that wraps the component.
**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,12 +57,13 @@ In addition to the props laid out below, the component accepts other props that
### useRive Hook
```tsx
-import {useRive} from '@rive-app/react-canvas';
+import { useRive } from '@rive-app/react-canvas';
```
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.
**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.
+
{() => {
@@ -69,7 +71,7 @@ The runtime also exports a named `useRive` hook that allows for more control at
const [animationText, setAnimationText] = useState('');
const { rive, RiveComponent: RiveComponentPlayback } = useRive({
src: 'truck.riv',
- stateMachines: "drive",
+ stateMachines: 'drive',
artboard: 'Truck',
autoplay: true,
onPause: () => {
@@ -88,15 +90,17 @@ The runtime also exports a named `useRive` hook that allows for more control at
setIsPlaying(true);
}
};
- return ((
+ return (
<>
{animationText}
-
{isPlaying ? 'Pause' : 'Play'}
+
+ {isPlaying ? 'Pause' : 'Play'}
+
>
- ));
+ );
}}
diff --git a/package.json b/package.json
index 867024ed..afc66fcb 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"format": "prettier --write src",
"types:check": "tsc --noEmit",
"release": "release-it",
- "storybook": "start-storybook -p 6006",
+ "storybook": "NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6006",
"build-storybook": "build-storybook -o docs-build"
},
"repository": {
diff --git a/setupTests.ts b/setupTests.ts
index 638a0d4b..f94de98d 100644
--- a/setupTests.ts
+++ b/setupTests.ts
@@ -24,6 +24,15 @@ window.IntersectionObserver = class IntersectionObserver {
unobserve() {}
};
+window.matchMedia = jest.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+}));
+
jest.mock('@rive-app/canvas', () => ({
Rive: jest.fn().mockImplementation(() => ({
on: jest.fn(),
diff --git a/src/components/Rive.tsx b/src/components/Rive.tsx
index a7742276..d338b92b 100644
--- a/src/components/Rive.tsx
+++ b/src/components/Rive.tsx
@@ -28,7 +28,11 @@ export interface RiveProps {
* 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.
*/
useOffscreenRenderer?: boolean;
-};
+ /**
+ * If true, the runtime will respect the users "prefers-reduced-motion" accessibilty option and start the animation paused. Defaults to false.
+ */
+ usePrefersReducedMotion?: boolean;
+}
const Rive = ({
src,
@@ -37,6 +41,7 @@ const Rive = ({
stateMachines,
layout,
useOffscreenRenderer = true,
+ usePrefersReducedMotion = false,
...rest
}: RiveProps & ComponentProps<'canvas'>) => {
const params = {
@@ -50,6 +55,7 @@ const Rive = ({
const options = {
useOffscreenRenderer,
+ usePrefersReducedMotion,
};
const { RiveComponent } = useRive(params, options);
diff --git a/src/hooks/useRive.tsx b/src/hooks/useRive.tsx
index cdca3e01..8caed913 100644
--- a/src/hooks/useRive.tsx
+++ b/src/hooks/useRive.tsx
@@ -13,7 +13,11 @@ import {
RiveState,
Dimensions,
} from '../types';
-import { useSize, useDevicePixelRatio } from '../utils';
+import {
+ useSize,
+ useDevicePixelRatio,
+ usePrefersReducedMotion,
+} from '../utils';
type RiveComponentProps = {
setContainerRef: RefCallback
;
@@ -52,6 +56,7 @@ const defaultOptions = {
useDevicePixelRatio: true,
fitCanvasToArtboardHeight: false,
useOffscreenRenderer: true,
+ usePrefersReducedMotion: false,
};
/**
@@ -100,6 +105,7 @@ export default function useRive(
// occur.
const size = useSize(containerRef);
const currentDevicePixelRatio = useDevicePixelRatio();
+ const prefersReducedMotion = usePrefersReducedMotion();
const isParamsLoaded = Boolean(riveParams);
const options = getOptions(opts);
@@ -198,6 +204,20 @@ export default function useRive(
}
}, [rive, size, currentDevicePixelRatio]);
+ const animations = riveParams?.animations;
+ /**
+ * Listen to changes on the for the prefersReducedMotion accessibilty setting
+ */
+ useEffect(() => {
+ if (rive && options.usePrefersReducedMotion) {
+ if (prefersReducedMotion && rive.isPlaying) {
+ rive.pause();
+ } else if (!prefersReducedMotion && rive.isPaused) {
+ rive.play();
+ }
+ }
+ }, [rive, prefersReducedMotion]);
+
/**
* Ref callback called when the canvas element mounts and unmounts.
*/
@@ -275,7 +295,6 @@ export default function useRive(
/**
* Listen for changes in the animations params
*/
- const animations = riveParams?.animations;
useEffect(() => {
if (rive && animations) {
if (rive.isPlaying) {
diff --git a/src/types.ts b/src/types.ts
index bec24779..e9623ff3 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -7,6 +7,7 @@ export type UseRiveOptions = {
useDevicePixelRatio: boolean;
fitCanvasToArtboardHeight: boolean;
useOffscreenRenderer: boolean;
+ usePrefersReducedMotion: boolean;
};
export type Dimensions = {
diff --git a/src/utils.ts b/src/utils.ts
index 698845e3..1a1f3273 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -3,9 +3,9 @@ import { Dimensions } from './types';
// There are polyfills for this, but they add hundreds of lines of code
class FakeResizeObserver {
- observe() { }
- unobserve() { }
- disconnect() { }
+ observe() {}
+ unobserve() {}
+ disconnect() {}
}
function throttle(f: Function, delay: number) {
@@ -127,3 +127,26 @@ export function getDevicePixelRatio(): number {
const dpr = hasDprProp ? window.devicePixelRatio : 1;
return Math.min(Math.max(1, dpr), 3);
}
+
+export function usePrefersReducedMotion(): boolean {
+ const [prefersReducedMotion, setPrefersReducedMotion] =
+ useState(false);
+
+ useEffect(() => {
+ const canListen = typeof window !== 'undefined' && 'matchMedia' in window;
+ if (!canListen) {
+ return;
+ }
+
+ const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
+ function updatePrefersReducedMotion() {
+ setPrefersReducedMotion(() => mediaQuery.matches);
+ }
+ mediaQuery.addEventListener('change', updatePrefersReducedMotion);
+ updatePrefersReducedMotion();
+ return () =>
+ mediaQuery.removeEventListener('change', updatePrefersReducedMotion);
+ }, []);
+
+ return prefersReducedMotion;
+}
diff --git a/test/useRive.test.tsx b/test/useRive.test.tsx
index 926f1a31..47730f00 100644
--- a/test/useRive.test.tsx
+++ b/test/useRive.test.tsx
@@ -451,4 +451,92 @@ describe('useRive', () => {
expect(canvasSpy).toHaveAttribute('width', '200');
expect(canvasSpy).toHaveAttribute('height', '200');
});
+
+ it('pauses the animation if usePrefersReducedMotion is passed as true and media query returns true', async () => {
+ const params = {
+ src: 'file-src',
+ };
+
+ window.matchMedia = jest.fn().mockImplementation((query) => ({
+ matches: true,
+ media: query,
+ onchange: null,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ }));
+
+ const playMock = jest.fn();
+ const pauseMock = jest.fn();
+ const stopMock = jest.fn();
+
+ const riveMock = {
+ ...baseRiveMock,
+ stop: stopMock,
+ play: playMock,
+ pause: pauseMock,
+ animationNames: ['light'],
+ isPlaying: true,
+ isPaused: false,
+ };
+
+ // @ts-ignore
+ mocked(rive.Rive).mockImplementation(() => riveMock);
+ const canvasSpy = document.createElement('canvas');
+
+ const { result } = renderHook(() =>
+ useRive(params, { usePrefersReducedMotion: true })
+ );
+
+ await act(async () => {
+ result.current.setCanvasRef(canvasSpy);
+ controlledRiveloadCb();
+ });
+
+ expect(pauseMock).toBeCalled();
+ });
+
+ it('does not pause the animation if usePrefersReducedMotion is passed as false and media query returns true', async () => {
+ const params = {
+ src: 'file-src',
+ };
+
+ window.matchMedia = jest.fn().mockImplementation((query) => ({
+ matches: true,
+ media: query,
+ onchange: null,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ }));
+
+ const playMock = jest.fn();
+ const pauseMock = jest.fn();
+ const stopMock = jest.fn();
+
+ const riveMock = {
+ ...baseRiveMock,
+ stop: stopMock,
+ play: playMock,
+ pause: pauseMock,
+ animationNames: ['light'],
+ isPlaying: true,
+ isPaused: false,
+ };
+
+ // @ts-ignore
+ mocked(rive.Rive).mockImplementation(() => riveMock);
+ const canvasSpy = document.createElement('canvas');
+
+ const { result } = renderHook(() =>
+ useRive(params, { usePrefersReducedMotion: false })
+ );
+
+ await act(async () => {
+ result.current.setCanvasRef(canvasSpy);
+ controlledRiveloadCb();
+ });
+
+ expect(pauseMock).not.toBeCalled();
+ });
});