├── .github ├── FUNDING.yml └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── index.min.js ├── package.json ├── readme.md ├── rollup.config.js └── src ├── index.d.ts ├── index.js ├── index.test.js └── index.types.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://www.paypal.me/franciscopresencia/19 2 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [16.x, 18.x, 20.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: install dependencies 20 | run: npm install 21 | - name: npm test 22 | run: npm test 23 | env: 24 | CI: true 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Not for libraries 2 | package-lock.json 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | .DS_Store 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # Optional npm cache directory 48 | .npm 49 | 50 | # Optional eslint cache 51 | .eslintcache 52 | 53 | # Optional REPL history 54 | .node_repl_history 55 | 56 | # Output of 'npm pack' 57 | *.tgz 58 | 59 | # Yarn Integrity file 60 | .yarn-integrity 61 | 62 | # dotenv environment variables file 63 | .env 64 | 65 | # next.js build output 66 | .next 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Francisco Presencia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.min.js: -------------------------------------------------------------------------------- 1 | import{useRef as e,useLayoutEffect as r}from"react";export default n=>{if("undefined"==typeof performance||"undefined"==typeof window)return;const t=e(),c=e(),o=e(performance.now()),u=e(performance.now());t.current=n;const a=e=>{t.current({time:(e-o.current)/1e3,delta:(e-u.current)/1e3}),u.current=e,c.current=requestAnimationFrame(a)};r(()=>(c.current=requestAnimationFrame(a),()=>c.current&&cancelAnimationFrame(c.current)),[])}; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-animation-frame", 3 | "version": "0.2.1", 4 | "description": "A React hook to run requestAnimationFrame seamlessly", 5 | "homepage": "https://github.com/franciscop/use-animation-frame#readme", 6 | "repository": "https://github.com/franciscop/use-animation-frame.git", 7 | "bugs": "https://github.com/franciscop/use-animation-frame/issues", 8 | "funding": "https://www.paypal.me/franciscopresencia/19", 9 | "author": "Francisco Presencia (https://francisco.io/)", 10 | "license": "MIT", 11 | "scripts": { 12 | "build": "rollup -c", 13 | "size": "echo $(gzip -c index.min.js | wc -c) bytes", 14 | "start": "jest --watch", 15 | "test": "jest --coverage && npx check-dts" 16 | }, 17 | "keywords": [ 18 | "react", 19 | "use-animation-frame", 20 | "hooks", 21 | "animation", 22 | "interval" 23 | ], 24 | "main": "index.min.js", 25 | "type": "module", 26 | "types": "src/index.d.ts", 27 | "files": [ 28 | "src/index.d.ts" 29 | ], 30 | "devDependencies": { 31 | "@babel/core": "^7.15.0", 32 | "@babel/preset-env": "^7.15.0", 33 | "@babel/preset-react": "^7.14.5", 34 | "babel-loader": "^8.2.2", 35 | "babel-polyfill": "^6.26.0", 36 | "check-dts": "^0.8.2", 37 | "jest": "^28.1.0", 38 | "jest-environment-jsdom": "^28.1.0", 39 | "react": "^18.3.0", 40 | "react-test": "^0.21.2", 41 | "rollup": "^1.32.1", 42 | "rollup-plugin-babel": "^4.4.0", 43 | "rollup-plugin-terser": "^5.2.0" 44 | }, 45 | "peerDependencies": { 46 | "react": ">=16.8.0" 47 | }, 48 | "babel": { 49 | "presets": [ 50 | "@babel/preset-env", 51 | "@babel/preset-react" 52 | ] 53 | }, 54 | "jest": { 55 | "testEnvironment": "jsdom" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # use-animation-frame 2 | 3 | A hook to effortlessly run [`requestAnimationFrame()`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) in React ([**demo**](https://codesandbox.io/s/fps-counter-8jfdg)): 4 | 5 | ```js 6 | import useAnimationFrame from 'use-animation-frame'; 7 | 8 | const Counter = () => { 9 | const [time, setTime] = useState(0); 10 | useAnimationFrame(e => setTime(e.time)); 11 | return
Running for:
{time.toFixed(1)}s
; 12 | }; 13 | ``` 14 | 15 | Inspired by [CSS-Tricks' Using requestAnimationFrame with React Hooks](https://css-tricks.com/using-requestanimationframe-with-react-hooks/) and my [twitter reply](https://mobile.twitter.com/FPresencia/status/1164193851931631616). 16 | 17 | ## API 18 | 19 | Accepts a function that will be called on each requestAnimationFrame step. If there's a re-render and a new function is created, it'll use that instead of the previous one: 20 | 21 | ```js 22 | useAnimationFrame(callback); 23 | ``` 24 | 25 | The callback receives a single parameter, which is an object with two properties (based on [the `performance.now()` API](https://developer.mozilla.org/en-US/docs/Web/API/Performance/now)): 26 | 27 | - `time`: the absolute time _since the hook was first mounted_. This is useful for wall clock, general time, etc. 28 | - `delta`: the time _since the hook was run last_. This is useful to measure e.g. FPS. 29 | 30 | All times are in the International System of Units **seconds**, including decimals. 31 | 32 | 33 | ## Example: FPS counter 34 | 35 | With my other library [use-interpolation](https://www.npmjs.com/package/use-interpolation) it's easy to calculate the FPS ([see in CodeSandbox](https://codesandbox.io/s/angry-voice-8jfdg)): 36 | 37 | ```js 38 | import React, { useState } from "react"; 39 | import useInterpolation from 'use-interpolation'; 40 | import useAnimationFrame from 'use-animation-frame'; 41 | 42 | const Counter = () => { 43 | const [time, setTime] = useState(0); 44 | // 1s of interpolation time 45 | const [fps, setFps] = useInterpolation(1000); 46 | useAnimationFrame(e => { 47 | setFps(1 / e.delta); 48 | setTime(e.time); 49 | }); 50 | return ( 51 |
52 | {time.toFixed(1)}s 53 |
54 | {fps && Math.floor(fps.value)} FPS 55 |
56 | ); 57 | }; 58 | ``` 59 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel"; 2 | import { terser } from "rollup-plugin-terser"; 3 | 4 | export default { 5 | input: "src/index.js", 6 | output: { file: "index.min.js", format: "esm" }, 7 | external: ["react"], 8 | plugins: [ 9 | babel({ 10 | exclude: "node_modules/**", 11 | presets: [ 12 | ["@babel/env", { targets: { node: 12 } }], 13 | "@babel/preset-react", 14 | ], 15 | }), 16 | terser(), 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | type Callback = (data: { time: number; delta: number }) => void; 2 | 3 | export default function useAnimationFrame(cb: Callback): void; 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Based off a tweet and codesandbox: 2 | // https://mobile.twitter.com/hieuhlc/status/1164369876825169920 3 | import { useLayoutEffect, useRef } from "react"; 4 | 5 | // Reusable component that also takes dependencies 6 | export default (cb) => { 7 | if (typeof performance === "undefined" || typeof window === "undefined") { 8 | return; 9 | } 10 | 11 | const cbRef = useRef(); 12 | const frame = useRef(); 13 | const init = useRef(performance.now()); 14 | const last = useRef(performance.now()); 15 | 16 | cbRef.current = cb; 17 | 18 | const animate = (now) => { 19 | // In seconds ~> you can do ms or anything in userland 20 | cbRef.current({ 21 | time: (now - init.current) / 1000, 22 | delta: (now - last.current) / 1000, 23 | }); 24 | last.current = now; 25 | frame.current = requestAnimationFrame(animate); 26 | }; 27 | 28 | useLayoutEffect(() => { 29 | frame.current = requestAnimationFrame(animate); 30 | return () => frame.current && cancelAnimationFrame(frame.current); 31 | }, []); 32 | }; 33 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import "babel-polyfill"; 2 | import React, { useState } from "react"; 3 | import $ from "react-test"; 4 | import useAnimationFrame from "./index.js"; 5 | 6 | describe("use-animation-frame", () => { 7 | it("renders properly for 1 second", async () => { 8 | const Counter = () => { 9 | const [time, setTime] = useState(0); 10 | useAnimationFrame((e) => setTime(e.time), []); 11 | return
{time.toFixed(1)}s
; 12 | }; 13 | const $counter = $(); 14 | expect($counter.text()).toBe("0.0s"); 15 | await $counter.delay(1000); 16 | expect($counter.text()).toBe("1.0s"); 17 | }); 18 | 19 | it("changes the value on dep change. Test for #4", async () => { 20 | let c = false; 21 | const Changer = () => { 22 | const [clicked, setClicked] = useState(false); 23 | c = clicked; 24 | const [out, setOut] = useState("hello"); 25 | useAnimationFrame(() => { 26 | setOut(clicked ? "bye" : "hello"); 27 | }, [clicked]); 28 | return ; 29 | }; 30 | const $counter = $(); 31 | expect($counter.text()).toBe("hello"); 32 | await $counter.delay(1000); 33 | expect($counter.text()).toBe("hello"); 34 | await $counter.click(); 35 | expect(c).toBe(true); 36 | await $counter.delay(100); // React act waits for the render of setClicked(), but not for the re-render within useAnimationFrame 37 | expect($counter.text()).toBe("bye"); 38 | await $counter.delay(1000); 39 | expect($counter.text()).toBe("bye"); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/index.types.ts: -------------------------------------------------------------------------------- 1 | import useAnimationFrame from "../"; 2 | 3 | (() => { 4 | useAnimationFrame(() => { 5 | console.log("Tick"); 6 | }); 7 | useAnimationFrame( 8 | ({ time: _time, delta: _delta }: { time: number; delta: number }) => { 9 | console.log("Tick"); 10 | }, 11 | ); 12 | })(); 13 | --------------------------------------------------------------------------------