├── .gitignore ├── babel.config.js ├── .npmignore ├── native └── package.json ├── jest.config.js ├── .eslintrc.js ├── src └── targets │ ├── native │ ├── index.js │ └── index.test.js │ └── web │ ├── index.js │ └── index.test.js ├── package.json ├── rollup.config.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:metro-react-native-babel-preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .eslintrc.js 3 | .gitignore 4 | babel.config.js 5 | jest.config.js 6 | rollup.config.js 7 | -------------------------------------------------------------------------------- /native/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-reduce-motion/native", 3 | "private": true, 4 | "main": "./dist/react-reduce-motion.native.cjs.js", 5 | "jsnext:main": "./dist/react-reduce-motion.native.esm.js", 6 | "module": "./dist/react-reduce-motion.native.esm.js" 7 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: [ 3 | { 4 | displayName: 'native', 5 | preset: 'react-native', 6 | testMatch: ['/src/targets/native/**/*.test.js'], 7 | }, 8 | { 9 | displayName: 'web', 10 | testMatch: ['/src/targets/web/**/*.test.js'], 11 | }, 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | browser: true, 6 | jest: true, 7 | }, 8 | extends: ['problems'], 9 | parserOptions: { 10 | ecmaVersion: 2019, 11 | sourceType: 'module', 12 | }, 13 | plugins: ['prettier'], 14 | rules: { 15 | 'prettier/prettier': 2, 16 | strict: 0, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/targets/native/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactNative from 'react-native'; 3 | 4 | export function useReduceMotion() { 5 | const [matches, setMatch] = React.useState(false); 6 | React.useEffect(() => { 7 | const handleChange = isReduceMotionEnabled => { 8 | setMatch(isReduceMotionEnabled); 9 | }; 10 | ReactNative.AccessibilityInfo.isReduceMotionEnabled().then(handleChange); 11 | ReactNative.AccessibilityInfo.addEventListener( 12 | 'reduceMotionChanged', 13 | handleChange 14 | ); 15 | return () => { 16 | ReactNative.AccessibilityInfo.removeEventListener( 17 | 'reduceMotionChanged', 18 | handleChange 19 | ); 20 | }; 21 | }, []); 22 | return matches; 23 | } 24 | -------------------------------------------------------------------------------- /src/targets/native/index.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('react-native', () => ({ 2 | AccessibilityInfo: { 3 | isReduceMotionEnabled: jest.fn(), 4 | addEventListener: jest.fn(), 5 | removeEventListener: jest.fn(), 6 | }, 7 | })); 8 | import { renderHook } from '@testing-library/react-hooks'; 9 | import { AccessibilityInfo } from 'react-native'; 10 | 11 | import { useReduceMotion } from './'; 12 | 13 | test('returns true if "Reduce motion" is enabled', async () => { 14 | AccessibilityInfo.isReduceMotionEnabled.mockResolvedValue(true); 15 | const { result, waitForNextUpdate } = renderHook(useReduceMotion); 16 | expect(result.current).toBe(false); 17 | await waitForNextUpdate(); 18 | expect(result.current).toBe(true); 19 | }); 20 | 21 | test('returns false if "Reduce motion" is disabled', async () => { 22 | AccessibilityInfo.isReduceMotionEnabled.mockResolvedValue(false); 23 | const { result } = renderHook(useReduceMotion); 24 | expect(result.current).toBe(false); 25 | }); 26 | -------------------------------------------------------------------------------- /src/targets/web/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /* Warning if you've imported this file on React Native */ 4 | if ( 5 | process.env.NODE_ENV !== 'production' && 6 | typeof navigator !== 'undefined' && 7 | navigator.product === 'ReactNative' 8 | ) { 9 | // eslint-disable-next-line no-console 10 | console.warn( 11 | "It looks like you've imported 'react-reduce-motion' on React Native.\n" + 12 | "Perhaps you're looking to import 'react-reduce-motion/native'?" 13 | ); 14 | } 15 | 16 | export function useReduceMotion() { 17 | const [matches, setMatch] = React.useState( 18 | window.matchMedia('(prefers-reduced-motion: reduce)').matches 19 | ); 20 | React.useEffect(() => { 21 | const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); 22 | const handleChange = () => { 23 | setMatch(mq.matches); 24 | }; 25 | handleChange(); 26 | mq.addEventListener('change', handleChange); 27 | return () => { 28 | mq.removeEventListener('change', handleChange); 29 | }; 30 | }, []); 31 | return matches; 32 | } 33 | -------------------------------------------------------------------------------- /src/targets/web/index.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react-hooks'; 2 | 3 | import { useReduceMotion } from './'; 4 | 5 | const mqDefaults = { 6 | matches: false, 7 | onchange: null, 8 | addEventListener: jest.fn(), 9 | removeEventListener: jest.fn(), 10 | }; 11 | 12 | test('returns true if "Reduce motion" is enabled', async () => { 13 | window.matchMedia = jest.fn().mockImplementation(query => { 14 | return { 15 | ...mqDefaults, 16 | matches: true, 17 | media: query, 18 | }; 19 | }); 20 | 21 | const { result } = renderHook(useReduceMotion); 22 | 23 | expect(result.current).toBe(true); 24 | }); 25 | 26 | test('returns false if "Reduce motion" is disabled', async () => { 27 | window.matchMedia = jest.fn().mockImplementation(query => { 28 | return { 29 | ...mqDefaults, 30 | media: query, 31 | }; 32 | }); 33 | 34 | const { result } = renderHook(useReduceMotion); 35 | 36 | expect(result.current).toBe(false); 37 | }); 38 | 39 | test('handles change of "prefers-reduce-motion" media query value', async () => { 40 | let change; 41 | window.matchMedia = jest.fn().mockImplementation(query => { 42 | return { 43 | ...mqDefaults, 44 | matches: false, 45 | addEventListener(event, listener) { 46 | this.matches = true; 47 | change = listener; 48 | }, 49 | media: query, 50 | }; 51 | }); 52 | 53 | const { result } = renderHook(useReduceMotion); 54 | 55 | expect(result.current).toBe(false); 56 | 57 | act(() => { 58 | change(); 59 | }); 60 | 61 | expect(result.current).toBe(true); 62 | }); 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-reduce-motion", 3 | "version": "2.0.2", 4 | "description": "A cross platform react hook that indicates whether a user's OS is configured to \"Reduce motion\" for accessibility purposes", 5 | "main": "dist/react-reduce-motion.browser.cjs.js", 6 | "jsnext:main": "dist/react-reduce-motion.esm.js", 7 | "module": "dist/react-reduce-motion.esm.js", 8 | "react-native": "native/dist/react-reduce-motion.native.cjs.js", 9 | "sideEffects": false, 10 | "browser": { 11 | "./dist/react-reduce-motion.esm.js": "./dist/react-reduce-motion.browser.esm.js", 12 | "./dist/react-reduce-motion.cjs.js": "./dist/react-reduce-motion.browser.cjs.js" 13 | }, 14 | "scripts": { 15 | "build": "rollup -c", 16 | "test": "jest", 17 | "size": "bundlesize", 18 | "prepublishOnly": "yarn build", 19 | "release": "np" 20 | }, 21 | "keywords": [ 22 | "react", 23 | "hooks", 24 | "react-hooks", 25 | "utils", 26 | "a11y", 27 | "accessibility", 28 | "reduce motion", 29 | "vestibular dysfunction" 30 | ], 31 | "author": "Luke Herrington ", 32 | "license": "MIT", 33 | "peerDependencies": { 34 | "react": "^16.8.0" 35 | }, 36 | "devDependencies": { 37 | "@testing-library/react-hooks": "^2.0.3", 38 | "babel-jest": "^24.9.0", 39 | "bundlesize": "^0.18.0", 40 | "eslint": "^6.5.1", 41 | "eslint-config-problems": "^3.0.1", 42 | "eslint-plugin-prettier": "^3.1.1", 43 | "husky": "^3.0.8", 44 | "jest": "^24.9.0", 45 | "lint-staged": "^9.4.1", 46 | "metro-react-native-babel-preset": "^0.56.0", 47 | "np": "^5.1.0", 48 | "prettier": "1.18.2", 49 | "react": "^16.8.0", 50 | "react-dom": "^16.8.0", 51 | "react-native": "^0.60", 52 | "react-test-renderer": "^16.10.2", 53 | "rollup": "^1.23.0", 54 | "rollup-plugin-commonjs": "^10.1.0", 55 | "rollup-plugin-json": "^4.0.0", 56 | "rollup-plugin-node-resolve": "^5.2.0", 57 | "rollup-plugin-replace": "^2.2.0", 58 | "rollup-plugin-sourcemaps": "^0.4.2", 59 | "rollup-plugin-terser": "^5.1.2" 60 | }, 61 | "bundlesize": [ 62 | { 63 | "path": "./dist/react-reduce-motion.min.js", 64 | "maxSize": "500b" 65 | } 66 | ], 67 | "husky": { 68 | "hooks": { 69 | "pre-commit": "lint-staged", 70 | "pre-push": "yarn build && yarn bundlesize && yarn test" 71 | } 72 | }, 73 | "lint-staged": { 74 | "*.js": [ 75 | "eslint --fix", 76 | "git add" 77 | ] 78 | }, 79 | "prettier": { 80 | "trailingComma": "es5", 81 | "singleQuote": true 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const nodeResolve = require('rollup-plugin-node-resolve'); 2 | const replace = require('rollup-plugin-replace'); 3 | const commonjs = require('rollup-plugin-commonjs'); 4 | const json = require('rollup-plugin-json'); 5 | const { terser } = require('rollup-plugin-terser'); 6 | const sourceMaps = require('rollup-plugin-sourcemaps'); 7 | 8 | const globals = { react: 'React', 'react-native': 'ReactNative' }; 9 | 10 | const cjs = { 11 | format: 'cjs', 12 | sourcemap: true, 13 | }; 14 | 15 | const esm = { 16 | format: 'esm', 17 | sourcemap: true, 18 | }; 19 | 20 | const getCJS = override => ({ ...cjs, ...override }); 21 | const getESM = override => ({ ...esm, ...override }); 22 | 23 | const commonPlugins = [ 24 | sourceMaps(), 25 | json(), 26 | nodeResolve(), 27 | commonjs({ 28 | namedExports: { 29 | 'react-is': ['isElement', 'isValidElementType', 'ForwardRef'], 30 | }, 31 | }), 32 | ]; 33 | 34 | const standaloneBaseConfig = { 35 | input: './src/targets/web/index.js', 36 | output: { 37 | file: 'dist/react-reduce-motion.js', 38 | format: 'umd', 39 | globals, 40 | name: 'react-reduce-motion', 41 | sourcemap: true, 42 | }, 43 | external: Object.keys(globals), 44 | plugins: commonPlugins, 45 | }; 46 | 47 | const standaloneConfig = { 48 | ...standaloneBaseConfig, 49 | plugins: standaloneBaseConfig.plugins.concat( 50 | replace({ 51 | 'process.env.NODE_ENV': JSON.stringify('development'), 52 | }) 53 | ), 54 | }; 55 | 56 | const prodPlugins = [ 57 | replace({ 58 | 'process.env.NODE_ENV': JSON.stringify('production'), 59 | }), 60 | terser({ 61 | sourcemap: true, 62 | }), 63 | ]; 64 | 65 | const standaloneProdConfig = { 66 | ...standaloneBaseConfig, 67 | output: { 68 | ...standaloneBaseConfig.output, 69 | file: 'dist/react-reduce-motion.min.js', 70 | }, 71 | plugins: standaloneBaseConfig.plugins.concat(prodPlugins), 72 | }; 73 | 74 | const browserConfig = { 75 | input: './src/targets/web/index.js', 76 | output: [ 77 | getESM({ file: 'dist/react-reduce-motion.browser.esm.js' }), 78 | getCJS({ file: 'dist/react-reduce-motion.browser.cjs.js' }), 79 | ], 80 | external: Object.keys(globals), 81 | plugins: commonPlugins, 82 | }; 83 | 84 | const nativeConfig = { 85 | input: './src/targets/native/index.js', 86 | output: [ 87 | getCJS({ 88 | file: 'native/dist/react-reduce-motion.native.cjs.js', 89 | }), 90 | getESM({ 91 | file: 'native/dist/react-reduce-motion.native.esm.js', 92 | }), 93 | ], 94 | external: Object.keys(globals), 95 | plugins: commonPlugins, 96 | }; 97 | 98 | export default [ 99 | standaloneConfig, 100 | standaloneProdConfig, 101 | browserConfig, 102 | nativeConfig, 103 | ]; 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Reduce Motion ➰ 2 | 3 | ## Installation and Usage 4 | 5 | > ❗ React Reduce Motion requires react ^16.8.0 or for native, requires react-native ^0.60. 6 | 7 | ```sh 8 | yarn add react-reduce-motion 9 | ``` 10 | 11 | React Reduce Motion provides a [react hook](https://reactjs.org/docs/hooks-intro.html) that exposes the "Reduce Motion" preference of a user's operating system to your componets. 12 | 13 | ```js 14 | import { useReduceMotion } from 'react-reduce-motion'; 15 | ``` 16 | 17 | ## Why? 18 | 19 | Building animations in React is fun! Especially if you're using a library like [react-spring](https://react-spring.io). I recently had some fun [messing around with react-spring](https://lukeherrington.com/posts/springtime-in-react-town/) and learned that animations are not fun for everyone. 20 | 21 | Vestibular dysfunction, a balance disorder of the inner ear, is surprisingly common among US adults. [A study](https://www.ncbi.nlm.nih.gov/pubmed/19468085) from the early 2000's found that approximately 69 million Americans had vestibular dysfunction which results in vertigo, nausea, migraines and hearing loss. Many people affected by vestibular dysfunction will choose to set the "Reduce motion" setting in their OS. In macOS it's found in the accessibility settings. 22 | 23 | ![A macOS system preferences screen with the "Reduce motion" checkbox checked](https://lukeherrington.com/static/56a2a145993311eb80344c1b9845f23f/884f2/reduce-motion-macos.png) 24 | 25 | If you're including animations in your app, consider optionally reducing their intensity so that _everyone_ can enjoy your app. There are a couple ways you can do this with React Reduce Motion. 26 | 27 | 1. If you're using `react-spring`, disable animations entirely using a global: 28 | 29 | ```js 30 | import { Globals } from 'react-spring' 31 | import { useReduceMotion } from 'react-reduce-motion'; 32 | 33 | const MyApp = () => { 34 | const prefersReducedMotion = useReduceMotion() 35 | React.useEffect(() => { 36 | Globals.assign({ 37 | skipAnimation: prefersReducedMotion, 38 | }) 39 | }, [prefersReducedMotion]) 40 | return ... 41 | } 42 | ``` 43 | 44 | 2. Reduce the animation intensity using a heuristic of your choosing: 45 | 46 | ```js 47 | import { useReduceMotion } from 'react-reduce-motion'; 48 | 49 | function ParallaxAnimatedButton({ rotation = 10, scale = 1.2 }) { 50 | const buttonRef = React.useRef(); 51 | const reduceMotion = useReduceMotion(); 52 | const defaultTransform = [0, 0, 1] 53 | // This is where we choose the animation intensity depending on user preference. 54 | const actualRotation = reduceMotion ? rotation / 3 : rotation; 55 | const actualScale = reduceMotion ? 1.01 : scale; 56 | const [props, set] = useSpring(() => ({ 57 | xys: defaultTransform, 58 | config: { mass: 7, tension: 500, friction: 40 } 59 | })); 60 | return ( 61 | 65 | set({ xys: calc(actualRotation, actualScale, clientX, clientY, buttonRef.current) }) 66 | } 67 | onMouseLeave={() => set({ xys: defaultTransform })} 68 | style={{ 69 | transform: props.xys.to(transform), 70 | }} 71 | > 72 | Hover over me! 73 | 74 | ); 75 | } 76 | ``` 77 | 78 | Before: 79 | 80 | ![A very intensely animated button](https://user-images.githubusercontent.com/1127238/66233346-fa988980-e69f-11e9-89af-e7db47549293.gif) 81 | 82 | After: 83 | 84 | ![A subtly animated button](https://user-images.githubusercontent.com/1127238/66233366-071ce200-e6a0-11e9-87f6-42b850e18a6e.gif) 85 | 86 | ```js 87 | const actualRotation = reduceMotion ? rotation / 3 : rotation; 88 | ``` 89 | 90 | The above snippet is where the heuristic is applied. Depending on what you're animating, you need to make your own decision. See the [Resources](#Resources) section below as your guide. 91 | 92 | ✨ [Interactive example](https://react-reduce-motion.netlify.com) ✨ 93 | 94 | ## Native 95 | 96 | To use React Reduce Motion with React Native, import the `native` build use the hook as demonstrated above. 97 | 98 | > ❗ The native react hook provided by React Reduce Motion requires react-native ^0.60. 99 | 100 | ```js 101 | import { useReduceMotion } from 'react-reduce-motion/native'; 102 | ``` 103 | 104 | ## Implementation 105 | 106 | The web version of this package is based on `prefers-reduced-motion` from Media Queries Level 5. See browser support [here](https://caniuse.com/#feat=prefers-reduced-motion). 107 | 108 | The native version depends on React Native's [AccessibilityInfo API](https://facebook.github.io/react-native/docs/accessibilityinfo) which provides a cross platform `isReduceMotionEnabled` function. This was introduced in React Native `v0.60`. 109 | 110 | ## Inspiration 111 | 112 | Writing a blog post about my experience learning `react-spring` helped me realize the need for a package that promotes building accessible animations. [Read it here](https://lukeherrington.com/posts/springtime-in-react-town/) and you'll learn how I implemented it. [A conversation with Paul, the creator of react-spring](https://github.com/react-spring/react-spring/issues/811), spurred me to contribute this work. 113 | 114 | ## Resources 115 | 116 | ### [WCAG 2.1 - Guideline 2.3 Seizures and Physical Reactions](https://www.w3.org/TR/WCAG21/#seizures-and-physical-reactions) 117 | > Do not design content in a way that is known to cause seizures or physical reactions. [reference](https://www.w3.org/TR/WCAG21/#seizures-and-physical-reactions) 118 | 119 | > Motion animation triggered by interaction can be disabled, unless the animation is _[essential](https://www.w3.org/TR/WCAG21/#dfn-essential)_ [emphasis added] to the functionality or the information being conveyed. [reference](https://www.w3.org/TR/WCAG21/#animation-from-interactions) 120 | 121 | --------------------------------------------------------------------------------