diff --git a/examples/stories/RiveOverview.stories.mdx b/examples/stories/RiveOverview.stories.mdx index 25dfa29..8e03a13 100644 --- a/examples/stories/RiveOverview.stories.mdx +++ b/examples/stories/RiveOverview.stories.mdx @@ -4,8 +4,8 @@ import { useState } from 'react'; import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs'; -import RiveComponent, {useRive, useStateMachineInput} from '../../src'; -import {Button} from './components/Button'; +import RiveComponent, { useRive, useStateMachineInput } from '../../src'; +import { Button } from './components/Button'; import './rive-overview.css'; @@ -31,6 +31,7 @@ There's multiple ways to render Rive using the React runtime. See the associated ```tsx import RiveComponent from '@rive-app/react-canvas'; ``` + The React runtime exports a default React component you can insert as JSX. Under the hood, it renders a `` element that runs the animation, and a wrapping `
` 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}

- +
- )); + ); }}
diff --git a/package.json b/package.json index 867024e..afc66fc 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 638a0d4..f94de98 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 a774227..d338b92 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 cdca3e0..8caed91 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 bec2477..e9623ff 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 698845e..1a1f327 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 926f1a3..47730f0 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(); + }); });