├── .eslintrc.js ├── .github └── workflows │ ├── add_to_project.yml │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .prettierrc ├── .release-it.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── examples ├── .gitignore ├── .storybook │ ├── main.ts │ ├── preview.ts │ └── stories.css ├── package.json ├── public │ ├── avatars.riv │ ├── layout_test.riv │ ├── person_databinding_test.riv │ ├── rating.riv │ └── stocks.riv ├── src │ └── components │ │ ├── DataBinding.stories.ts │ │ ├── DataBinding.tsx │ │ ├── DataBindingTests.stories.tsx │ │ ├── DataBindingTests.tsx │ │ ├── Events.stories.ts │ │ ├── Events.tsx │ │ ├── Http.stories.ts │ │ ├── Http.tsx │ │ ├── ResponsiveLayout.stories.ts │ │ ├── ResponsiveLayout.tsx │ │ ├── Simple.stories.ts │ │ └── Simple.tsx ├── tsconfig.json └── yarn.lock ├── jest.config.js ├── npm ├── react-canvas-lite │ ├── README.md │ └── package.json ├── react-canvas │ ├── README.md │ └── package.json ├── react-webgl │ ├── README.md │ └── package.json └── react-webgl2 │ ├── README.md │ └── package.json ├── package.json ├── scripts ├── build.sh ├── publish_all.sh ├── setup_all_packages.sh ├── setup_package.sh └── trimPackageJson.js ├── setupTests.ts ├── src ├── components │ └── Rive.tsx ├── hooks │ ├── elementObserver.ts │ ├── useContainerSize.ts │ ├── useDevicePixelRatio.ts │ ├── useIntersectionObserver.ts │ ├── useResizeCanvas.ts │ ├── useRive.tsx │ ├── useRiveFile.ts │ ├── useStateMachineInput.ts │ ├── useViewModel.ts │ ├── useViewModelInstance.ts │ ├── useViewModelInstanceBoolean.ts │ ├── useViewModelInstanceColor.ts │ ├── useViewModelInstanceEnum.ts │ ├── useViewModelInstanceNumber.ts │ ├── useViewModelInstanceProperty.ts │ ├── useViewModelInstanceString.ts │ └── useViewModelInstanceTrigger.ts ├── index.ts ├── types.ts └── utils.ts ├── test ├── Rive.test.tsx ├── elementObserver.test.tsx ├── useIntersectionObserver.test.tsx ├── useRive.test.tsx ├── useRiveFile.test.tsx └── useStateMachine.test.tsx ├── tsconfig.json └── tsconfig.test.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | 'plugin:react/recommended', 8 | 'prettier', 9 | 'plugin:storybook/recommended', 10 | ], 11 | parser: '@typescript-eslint/parser', 12 | parserOptions: { 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | ecmaVersion: 12, 17 | sourceType: 'module', 18 | }, 19 | plugins: ['@typescript-eslint', 'prettier', 'react-hooks'], 20 | rules: { 21 | '@typescript-eslint/no-unused-vars': 'error', 22 | 'prefer-const': [ 23 | 'warn', 24 | { 25 | destructuring: 'all', 26 | }, 27 | ], 28 | 'no-var': 'error', 29 | eqeqeq: ['error', 'smart'], 30 | 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks 31 | 'react-hooks/exhaustive-deps': 'off', // Checks effect dependencies 32 | }, 33 | settings: { 34 | react: { 35 | version: 'detect', 36 | }, 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /.github/workflows/add_to_project.yml: -------------------------------------------------------------------------------- 1 | name: Adds all new issues to project board 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | add-to-project: 10 | name: Add issue to project 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/add-to-project@v0.5.0 14 | with: 15 | project-url: https://github.com/orgs/rive-app/projects/12/views/1 16 | github-token: ${{ secrets.ADD_TO_PROJECT_ACTION }} 17 | 18 | - uses: actions/github-script@v6 19 | with: 20 | script: | 21 | github.rest.issues.addLabels({ 22 | issue_number: context.issue.number, 23 | owner: context.repo.owner, 24 | repo: context.repo.repo, 25 | labels: ["triage"] 26 | }) 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | major: 6 | description: 'Major' 7 | type: boolean 8 | default: false 9 | minor: 10 | description: 'Minor' 11 | type: boolean 12 | default: false 13 | jobs: 14 | publish_job: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | with: 19 | fetch-depth: 0 20 | - name: Setup Git config 21 | run: | 22 | git config --local user.email 'hello@rive.app' 23 | git config --local user.name ${{ github.actor }} 24 | - name: Authenticate with registry 25 | run: npm config set //registry.npmjs.org/:_authToken ${{ secrets.NPM_TOKEN }} 26 | - uses: actions/setup-node@v2 27 | with: 28 | node-version: '16.x' 29 | registry-url: 'https://registry.npmjs.org' 30 | - name: Install Modules 31 | run: npm install 32 | - name: Run type check 33 | run: npm run types:check 34 | - name: Run Linter 35 | run: npm run lint 36 | - name: Run Tests 37 | run: npm test 38 | - if: ${{ inputs.major == true }} 39 | name: Major Release - Bump version number, update changelog, push and tag 40 | run: npm run release:major 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} 43 | PAT_GITHUB: ${{ secrets.PAT_GITHUB }} 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | - if: ${{inputs.major == false && inputs.minor == true}} 46 | name: Minor release - Bump version number, update changelog, push and tag 47 | run: npm run release:minor 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} 50 | PAT_GITHUB: ${{ secrets.PAT_GITHUB }} 51 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 52 | - if: ${{inputs.major == false && inputs.minor == false}} 53 | name: Patch release - Bump version number, update changelog, push and tag 54 | run: npm run release:patch 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} 57 | PAT_GITHUB: ${{ secrets.PAT_GITHUB }} 58 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v2 9 | - name: Install Modules 10 | run: npm install 11 | - name: Run type check 12 | run: npm run types:check 13 | - name: Run Linter 14 | run: npm run lint 15 | - name: Run Tests 16 | run: npm test 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | .env 5 | .idea 6 | .vscode 7 | examples/**/package-lock.json 8 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "bracketSpacing": true, 6 | "printWidth": 80 7 | } 8 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "addUntrackedFiles": true, 4 | "requireCleanWorkingDir": false, 5 | "commitMessage": "chore: release ${version}", 6 | "tagName": "v${version}", 7 | "changelog": "npx auto-changelog --stdout --commit-limit false --unreleased --template https://raw.githubusercontent.com/release-it/release-it/master/templates/changelog-compact.hbs" 8 | }, 9 | "npm": { 10 | "publish": true 11 | }, 12 | "github": { 13 | "release": true, 14 | "releaseName": "${version}" 15 | }, 16 | "hooks": { 17 | "after:version:bump": [ 18 | "npm run build", 19 | "npm run setup-builds", 20 | "npm run setup-packages", 21 | "npx auto-changelog -p", 22 | "npm run publish:all" 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love contributions! If you want to run the project locally to test out changes, run the examples, or just see how things work under the hood, read on below. 4 | 5 | ## Local development 6 | 7 | This runtime consumes specific tied-down versions of the [JS/WASM runtime](https://github.com/rive-app/rive-wasm) to have better control over changes that occur in that downstream runtime. 8 | 9 | ### Installation 10 | 11 | 1. Clone the project down 12 | 2. Run `npm i` in the shell/terminal at the base of the project to install the dependencies needed for the project 13 | 14 | ### Local dev server 15 | 16 | To start the local dev server to reflect any changes made to the core `src/` files, run the following in a terminal tab: 17 | 18 | ``` 19 | npm run dev 20 | ``` 21 | 22 | ### Running the example storybook locally 23 | 24 | We use Storybook to deploy our examples out onto a public-facing page for folks to view and see code examples for. It also serves as the place we'll include any example suites. These story files are stored in `/examples` 25 | 26 | To run Storybook, run the following command in the terminal: 27 | 28 | ``` 29 | npm run storybook 30 | ``` 31 | 32 | To see changes made to the Rive React runtime reflected in your storyook, run the following command in a separate terminal window: 33 | 34 | ``` 35 | npm run dev 36 | ``` 37 | 38 | ### Testing 39 | 40 | We also have a suite of unit tests against the high-level component and various hooks exported in the `test/` folder. When adding new components, changing the API, or underlying functionality, make sure to add a test here! 41 | 42 | To run the test suite: 43 | 44 | ``` 45 | npm test 46 | ``` 47 | 48 | ## Making changes 49 | 50 | When you're ready to make changes, push up to a feature branch off of the `main` branch. Create a pull request to this repository in Github. When creating commit messages, please be as descriptive as possible to the changes being made. 51 | 52 | For example, if the change is simply a bug fix or patch change: 53 | 54 | ``` 55 | git commit -m "Fix: Fixing a return type from useRive" 56 | ``` 57 | 58 | Or if it's simply a docs change: 59 | 60 | ``` 61 | git commit -m "Docs: Adding a new link for another example page" 62 | ``` 63 | 64 | For minor/major version releases, also ensure you preface commit messages with: 65 | 66 | ``` 67 | git commit -m "Major: Restructuring the useRive API with new parameters" 68 | ``` 69 | 70 | These messages help make the changelog clear as to what changes are made for future devs to see. 71 | 72 | When pull requests are merged, the runtime will automatically deploy the next release version. By default, patch versions are published. If you want to set the next version as a minor/major version to be released, you have to manually update the `package.json` file at the root of the project to the verison you want it to. 73 | 74 | You can find the deploy scripts in `.github/` 75 | 76 | ## Bumping the underlying JS/WASM runtime 77 | 78 | Many times, fixes to the runtime and feature adds come from the underlying JS/WASM runtime. In these cases, just bump the `@rive-app/canvas` and `@rive-app/webgl` versions to the verison you need to incorporate the fix/feature. Run `npm i` and test out the change locally against the Storybook examples and run the test suite to make sure nothing breaks, and then submit a PR with just the `package.json` change if that's all that's needed. 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Rive 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://github.com/rive-app/rive-react/actions/workflows/tests.yml/badge.svg) 2 | ![Discord badge](https://img.shields.io/discord/532365473602600965) 3 | ![Twitter handle](https://img.shields.io/twitter/follow/rive_app.svg?style=social&label=Follow) 4 | 5 | # Rive React 6 | 7 | ![Rive hero image](https://cdn.rive.app/rive_logo_dark_bg.png) 8 | 9 | A React runtime library for [Rive](https://rive.app). 10 | 11 | This library is a wrapper around the [JS/Wasm runtime](https://github.com/rive-app/rive-wasm), giving full control over the js runtime while providing components and hooks for React applications. 12 | 13 | ## Table of contents 14 | 15 | - :star: [Rive Overview](#rive-overview) 16 | - 🚀 [Getting Started & API docs](#getting-started) 17 | - :mag: [Supported Versions](#supported-versions) 18 | - :books: [Examples](#examples) 19 | - :runner: [Migration Guides](#migration-guides) 20 | - 👨‍💻 [Contributing](#contributing) 21 | - :question: [Issues](#issues) 22 | 23 | ## Rive overview 24 | 25 | [Rive](https://rive.app) is a real-time interactive design and animation tool that helps teams create and run interactive animations anywhere. Designers and developers use our collaborative editor to create motion graphics that respond to different states and user inputs. Our lightweight open-source runtime libraries allow them to load their animations into apps, games, and websites. 26 | 27 | :house_with_garden: [Homepage](https://rive.app/) 28 | 29 | :blue_book: [General help docs](https://rive.app/community/doc/) 30 | 31 | 🛠 [Rive Forums](https://rive.app/community/forums/home) 32 | 33 | ## Getting started 34 | 35 | Follow along with the link below for a quick start in getting Rive React integrated into your React apps. 36 | 37 | - [Getting Started with Rive in React](https://rive.app/community/doc/react/docRfaSQ0eaE) 38 | - [API documentation](https://rive.app/community/doc/parameters-and-return-values/docJlDMNulDh) 39 | 40 | For more information, see the Runtime sections of the Rive help documentation: 41 | 42 | - [Animation Playback](https://rive.app/community/doc/animation-playback/docDKKxsr7ko) 43 | - [Layout](https://rive.app/community/doc/layout/docBl81zd1GB) 44 | - [State Machines](https://rive.app/community/doc/state-machines/docxeznG7iiK) 45 | - [Rive Text](https://rive.app/community/doc/text/docn2E6y1lXo) 46 | - [Rive Events](https://rive.app/community/doc/rive-events/docbOnaeffgr) 47 | - [Loading Assets](https://rive.app/community/doc/loading-assets/doct4wVHGPgC) 48 | 49 | ## Supported versions 50 | 51 | This library supports React versions `^16.8.0` through `^18.0.0`. 52 | 53 | ## Examples 54 | 55 | Check out our Storybook instance that shows how to use the library in small examples, along with code snippets! This includes examples using the basic component, as well as the convenient hooks exported to take advantage of state machines. 56 | 57 | - [Mouse tracking](https://codesandbox.io/s/rive-mouse-track-test-t0y965?file=/src/App.js) 58 | - [Accessibility concerns](https://rive.app/blog/accesible-web-animations-aria-live-regions) 59 | 60 | ### Awesome Rive 61 | 62 | For even more examples and resources on using Rive at runtime or in other tools, checkout the [awesome-rive](https://github.com/rive-app/awesome-rive) repo. 63 | 64 | ## Migration guides 65 | 66 | Using an older version of the runtime and need to learn how to upgrade to the latest version? Check out the migration guides below in our help center that help guide you through version bumps; breaking changes and all! 67 | 68 | [Migration guides](https://rive.app/community/doc/migrating-from-v3-to-v4/dociIPXVHKFF) 69 | 70 | ## Contributing 71 | 72 | We love contributions! Check out our [contributing docs](./CONTRIBUTING.md) to get more details into how to run this project, the examples, and more all locally. 73 | 74 | ## Issues 75 | 76 | Have an issue with using the runtime, or want to suggest a feature/API to help make your development life better? Log an issue in our [issues](https://github.com/rive-app/rive-react/issues) tab! You can also browse older issues and discussion threads there to see solutions that may have worked for common problems. 77 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | *storybook.log 26 | -------------------------------------------------------------------------------- /examples/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-webpack5'; 2 | import path from 'path'; 3 | 4 | const config: StorybookConfig = { 5 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 6 | addons: [ 7 | '@storybook/addon-essentials', 8 | '@storybook/preset-create-react-app', 9 | '@storybook/addon-interactions', 10 | ], 11 | framework: { 12 | name: '@storybook/react-webpack5', 13 | options: {}, 14 | }, 15 | staticDirs: ['../public'], 16 | webpackFinal: async (config) => { 17 | if (!config.resolve) config.resolve = {}; 18 | if (!config.resolve.alias) config.resolve.alias = {}; 19 | 20 | config.resolve.alias['react'] = path.resolve( 21 | __dirname, 22 | '../../node_modules/react' 23 | ); 24 | config.resolve.alias['react-dom'] = path.resolve( 25 | __dirname, 26 | '../../node_modules/react-dom' 27 | ); 28 | 29 | config.resolve.alias['@rive-app/react-canvas'] = path.resolve( 30 | __dirname, 31 | '../../' 32 | ); 33 | config.resolve.alias['@rive-app/react-canvas-lite'] = path.resolve( 34 | __dirname, 35 | '../../' 36 | ); 37 | config.resolve.alias['@rive-app/react-webgl'] = path.resolve( 38 | __dirname, 39 | '../../' 40 | ); 41 | config.resolve.alias['@rive-app/react-webgl2'] = path.resolve( 42 | __dirname, 43 | '../../' 44 | ); 45 | 46 | config.module?.rules?.push({ 47 | test: /\.(ts|tsx|js|jsx)$/, 48 | include: [ 49 | path.resolve(__dirname, '../src'), 50 | path.resolve(__dirname, '../../'), 51 | ], 52 | use: { 53 | loader: require.resolve('babel-loader'), 54 | options: { 55 | presets: [ 56 | require.resolve('@babel/preset-env'), 57 | require.resolve('@babel/preset-react'), 58 | require.resolve('@babel/preset-typescript'), 59 | ], 60 | }, 61 | }, 62 | }); 63 | 64 | config.watchOptions = { 65 | ignored: /node_modules/, 66 | poll: 1000, 67 | aggregateTimeout: 300, 68 | }; 69 | 70 | return config; 71 | }, 72 | }; 73 | 74 | export default config; 75 | -------------------------------------------------------------------------------- /examples/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react'; 2 | 3 | import './stories.css'; 4 | 5 | const preview: Preview = { 6 | parameters: { 7 | controls: { 8 | matchers: { 9 | color: /(background|color)$/i, 10 | date: /Date$/i, 11 | }, 12 | }, 13 | }, 14 | }; 15 | 16 | export default preview; 17 | -------------------------------------------------------------------------------- /examples/.storybook/stories.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | } 4 | 5 | #storybook-root { 6 | height: 100%; 7 | } -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/dom": "^10.4.0", 7 | "@testing-library/jest-dom": "^6.6.3", 8 | "@testing-library/react": "^16.3.0", 9 | "@testing-library/user-event": "^13.5.0", 10 | "@types/jest": "^27.5.2", 11 | "@types/node": "^16.18.126", 12 | "@types/react": "^19.1.2", 13 | "@types/react-dom": "^19.1.2", 14 | "react-scripts": "5.0.1", 15 | "typescript": "^4.9.5", 16 | "web-vitals": "^2.1.4" 17 | }, 18 | "scripts": { 19 | "storybook": "storybook dev -p 6006", 20 | "build-storybook": "storybook build", 21 | "test-storybook": "test-storybook" 22 | }, 23 | "eslintConfig": { 24 | "extends": [ 25 | "react-app", 26 | "react-app/jest", 27 | "plugin:storybook/recommended" 28 | ] 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | }, 42 | "devDependencies": { 43 | "@storybook/addon-essentials": "^8.6.12", 44 | "@storybook/addon-interactions": "^8.6.12", 45 | "@storybook/addon-onboarding": "^8.6.12", 46 | "@storybook/blocks": "^8.6.12", 47 | "@storybook/preset-create-react-app": "^8.6.12", 48 | "@storybook/react": "^8.6.12", 49 | "@storybook/react-webpack5": "^8.6.12", 50 | "@storybook/test": "^8.6.12", 51 | "@storybook/test-runner": "^0.22.0", 52 | "eslint-plugin-storybook": "^0.12.0", 53 | "storybook": "^8.6.12", 54 | "webpack": "^5.99.6" 55 | } 56 | } -------------------------------------------------------------------------------- /examples/public/avatars.riv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rive-app/rive-react/d310f1c96dbff6cbb7397d4bea2687c8d3f271f4/examples/public/avatars.riv -------------------------------------------------------------------------------- /examples/public/layout_test.riv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rive-app/rive-react/d310f1c96dbff6cbb7397d4bea2687c8d3f271f4/examples/public/layout_test.riv -------------------------------------------------------------------------------- /examples/public/person_databinding_test.riv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rive-app/rive-react/d310f1c96dbff6cbb7397d4bea2687c8d3f271f4/examples/public/person_databinding_test.riv -------------------------------------------------------------------------------- /examples/public/rating.riv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rive-app/rive-react/d310f1c96dbff6cbb7397d4bea2687c8d3f271f4/examples/public/rating.riv -------------------------------------------------------------------------------- /examples/public/stocks.riv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rive-app/rive-react/d310f1c96dbff6cbb7397d4bea2687c8d3f271f4/examples/public/stocks.riv -------------------------------------------------------------------------------- /examples/src/components/DataBinding.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import DataBinding from './DataBinding'; 4 | 5 | const meta = { 6 | title: 'DataBinding', 7 | component: DataBinding, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | args: {}, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | type Story = StoryObj; 16 | 17 | export const Default: Story = {}; 18 | -------------------------------------------------------------------------------- /examples/src/components/DataBinding.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { 3 | useRive, 4 | useViewModel, 5 | useViewModelInstance, 6 | useViewModelInstanceColor, 7 | useViewModelInstanceNumber, 8 | useViewModelInstanceString, 9 | useViewModelInstanceEnum, 10 | useViewModelInstanceTrigger, 11 | } from '@rive-app/react-webgl2'; 12 | 13 | const randomValue = () => Math.random() * 200 - 100; 14 | 15 | const DataBinding = () => { 16 | const { rive, RiveComponent } = useRive({ 17 | src: 'stocks.riv', 18 | artboard: 'Main', 19 | stateMachines: 'State Machine 1', 20 | autoplay: true, 21 | autoBind: false, 22 | }); 23 | 24 | // Get the default instance of the view model 25 | const viewModel = useViewModel(rive, { name: 'Dashboard' }); 26 | const viewModelInstance = useViewModelInstance(viewModel, { rive }); 27 | 28 | // Get the view model instance properties 29 | 30 | const { setValue: setTitle } = useViewModelInstanceString( 31 | 'title', 32 | viewModelInstance 33 | ); 34 | 35 | const { setValue: setLogoShape } = useViewModelInstanceEnum( 36 | 'logoShape', 37 | viewModelInstance 38 | ); 39 | 40 | const { setValue: setRootColor } = useViewModelInstanceColor( 41 | 'rootColor', 42 | viewModelInstance 43 | ); 44 | 45 | const { trigger: triggerSpinLogo } = useViewModelInstanceTrigger( 46 | 'triggerSpinLogo', 47 | viewModelInstance 48 | ); 49 | 50 | useViewModelInstanceTrigger('triggerButton', viewModelInstance, { 51 | onTrigger: () => console.log('Button Triggered!'), 52 | }); 53 | 54 | // Apple Values 55 | const { setValue: setAppleName } = useViewModelInstanceString( 56 | 'apple/name', 57 | viewModelInstance 58 | ); 59 | const { setValue: setAppleStockChange } = useViewModelInstanceNumber( 60 | 'apple/stockChange', 61 | viewModelInstance 62 | ); 63 | const { value: appleColor } = useViewModelInstanceColor( 64 | 'apple/currentColor', 65 | viewModelInstance 66 | ); 67 | // Apple Values 68 | const { setValue: setMicrosoftName } = useViewModelInstanceString( 69 | 'microsoft/name', 70 | viewModelInstance 71 | ); 72 | const { setValue: setMicrosoftStockChange } = useViewModelInstanceNumber( 73 | 'microsoft/stockChange', 74 | viewModelInstance 75 | ); 76 | // Tesla Values 77 | const { setValue: setTeslaName } = useViewModelInstanceString( 78 | 'tesla/name', 79 | viewModelInstance 80 | ); 81 | const { setValue: setTeslaStockChange } = useViewModelInstanceNumber( 82 | 'tesla/stockChange', 83 | viewModelInstance 84 | ); 85 | 86 | useEffect(() => { 87 | // Set initial values for the view model 88 | if ( 89 | setTitle && 90 | setLogoShape && 91 | setRootColor && 92 | setAppleName && 93 | setMicrosoftName && 94 | setTeslaName 95 | ) { 96 | setTitle('Rive Stocks Dashboard'); 97 | setLogoShape('triangle'); 98 | setRootColor(parseInt('ffc0ffee', 16)); 99 | setAppleName('AAPL'); 100 | setMicrosoftName('MSFT'); 101 | setTeslaName('TSLA'); 102 | } 103 | 104 | // randomly generate stock values every 2 seconds 105 | const interval = setInterval(() => { 106 | const appleValue = randomValue(); 107 | const microsoftValue = randomValue(); 108 | const teslaValue = randomValue(); 109 | 110 | setAppleStockChange(appleValue); 111 | setMicrosoftStockChange(microsoftValue); 112 | setTeslaStockChange(teslaValue); 113 | 114 | // If all the stock values are either all positive or all negative, spin the logo 115 | if ( 116 | (appleValue > 0 && microsoftValue > 0 && teslaValue > 0) || 117 | (appleValue < 0 && microsoftValue < 0 && teslaValue < 0) 118 | ) { 119 | triggerSpinLogo(); 120 | } 121 | }, 2000); 122 | 123 | return () => clearInterval(interval); 124 | }, [ 125 | setTitle, 126 | setLogoShape, 127 | setRootColor, 128 | setAppleName, 129 | setMicrosoftName, 130 | setTeslaName, 131 | setAppleStockChange, 132 | setMicrosoftStockChange, 133 | setTeslaStockChange, 134 | triggerSpinLogo, 135 | ]); 136 | 137 | // listen for changes to the AAPL color and log them 138 | useEffect(() => { 139 | if (appleColor) { 140 | console.log('Apple color changed:', appleColor); 141 | } 142 | }, [appleColor]); 143 | 144 | return ; 145 | }; 146 | 147 | export default DataBinding; 148 | -------------------------------------------------------------------------------- /examples/src/components/DataBindingTests.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import { within, expect, waitFor, userEvent } from '@storybook/test'; 4 | 5 | import { StringPropertyTest, NumberPropertyTest, BooleanPropertyTest, ColorPropertyTest, EnumPropertyTest, NestedViewModelTest, TriggerPropertyTest, PersonForm, PersonInstances } from './DataBindingTests'; 6 | 7 | const meta: Meta = { 8 | title: 'Tests/DataBinding', 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | }; 13 | 14 | export default meta; 15 | 16 | 17 | export const StringPropertyStory: StoryObj = { 18 | name: 'String Property', 19 | render: () => , 20 | play: async ({ canvasElement }) => { 21 | const canvas = within(canvasElement); 22 | 23 | // Wait for the Rive file to load 24 | await waitFor(() => { 25 | expect(canvas.getByTestId('name-value')).toBeTruthy(); 26 | }, { timeout: 3000 }); 27 | 28 | const nameInput = canvas.getByTestId('name-input'); 29 | await userEvent.clear(nameInput); 30 | 31 | // Wait for the input to be cleared 32 | await waitFor(() => { 33 | expect(nameInput.value).toBe(''); 34 | }, { timeout: 1000 }); 35 | 36 | await userEvent.click(nameInput); 37 | await userEvent.paste('Test User'); 38 | 39 | await waitFor(() => { 40 | expect(nameInput.value).toBe('Test User'); 41 | }, { timeout: 2000 }); 42 | 43 | await waitFor(() => { 44 | expect(canvas.getByTestId('name-value').textContent).toBe('Test User'); 45 | }, { timeout: 2000 }); 46 | } 47 | }; 48 | 49 | export const NumberPropertyStory: StoryObj = { 50 | name: 'Number Property', 51 | render: () => , 52 | play: async ({ canvasElement }) => { 53 | const canvas = within(canvasElement); 54 | 55 | // Wait for the Rive file to load 56 | await waitFor(() => { 57 | expect(canvas.getByTestId('age-value')).toBeTruthy(); 58 | }, { timeout: 2000 }); 59 | 60 | const ageInput = canvas.getByTestId('age-input'); 61 | 62 | const currentValue = ageInput.value; 63 | expect(currentValue).toBe('23'); 64 | 65 | await userEvent.click(ageInput); 66 | await userEvent.clear(ageInput); 67 | await waitFor(() => { 68 | expect(ageInput.value).toBe('0'); // This is a hack to wait for the input to be cleared 69 | }, { timeout: 1000 }); 70 | 71 | await userEvent.paste('42'); 72 | 73 | 74 | await waitFor(() => { 75 | expect(canvas.getByTestId('age-value').textContent).toBe('42'); 76 | }, { timeout: 2000 }); 77 | } 78 | }; 79 | 80 | export const BooleanPropertyStory: StoryObj = { 81 | name: 'Boolean Property', 82 | render: () => , 83 | play: async ({ canvasElement }) => { 84 | const canvas = within(canvasElement); 85 | 86 | // Wait for the Rive file to load 87 | 88 | await waitFor(() => { 89 | expect(canvas.getByTestId('terms-value')).toBeTruthy(); 90 | }, { timeout: 2000 }); 91 | 92 | const termsCheckbox = canvas.getByTestId('terms-checkbox'); 93 | 94 | expect(termsCheckbox.checked).toBe(false); 95 | 96 | expect(canvas.getByTestId('terms-value').textContent).toBe('false'); 97 | 98 | await userEvent.click(termsCheckbox); 99 | 100 | // Verify terms update 101 | await waitFor(() => { 102 | expect(canvas.getByTestId('terms-value').textContent).toBe('true'); 103 | }); 104 | } 105 | }; 106 | 107 | export const ColorPropertyStory: StoryObj = { 108 | name: 'Color Property', 109 | render: () => , 110 | play: async ({ canvasElement }) => { 111 | const canvas = within(canvasElement); 112 | 113 | // Wait for the Rive file to load and the component to render 114 | await waitFor(() => { 115 | expect(canvas.getByTestId('color-value')).toBeTruthy(); 116 | expect(canvas.getByTestId('set-color-red')).toBeTruthy(); 117 | expect(canvas.getByTestId('set-color-blue')).toBeTruthy(); 118 | }, { timeout: 5000 }); 119 | 120 | const numberValueDiv = canvas.getByTestId('number-value'); 121 | const hexValueDiv = canvas.getByTestId('hex-value'); 122 | 123 | // Verify initial state is red 124 | await waitFor(() => { 125 | expect(hexValueDiv.textContent).toContain('Hex value: #ce2323'); 126 | expect(numberValueDiv.textContent).toContain('Number value: -3267805'); 127 | }); 128 | 129 | 130 | // Change color to Blue --- 131 | const blueButton = canvas.getByTestId('set-color-blue'); 132 | await userEvent.click(blueButton); 133 | 134 | // Verify Blue State 135 | await waitFor(() => { 136 | expect(numberValueDiv.textContent).toContain('Number value: -16776961'); 137 | expect(hexValueDiv.textContent).toContain('Hex value: #0000ff'); 138 | }); 139 | 140 | 141 | // Change color back to Red --- 142 | const redButton = canvas.getByTestId('set-color-red'); 143 | await userEvent.click(redButton); 144 | 145 | // Verify Red State 146 | await waitFor(() => { 147 | expect(numberValueDiv.textContent).toContain('Number value: -65536'); 148 | expect(hexValueDiv.textContent).toContain('Hex value: #ff0000'); 149 | }); 150 | } 151 | }; 152 | 153 | export const EnumPropertyStory: StoryObj = { 154 | name: 'Enum Property', 155 | render: () => , 156 | play: async ({ canvasElement }) => { 157 | const canvas = within(canvasElement); 158 | 159 | // Wait for the Rive file to load 160 | await waitFor(() => { 161 | expect(canvas.getByTestId('country-value')).toBeTruthy(); 162 | }); 163 | 164 | // Wait for options to be loaded 165 | await waitFor(() => { 166 | const countrySelect = canvas.getByTestId('country-select'); 167 | return countrySelect.options.length > 0; 168 | }); 169 | 170 | const countrySelect = canvas.getByTestId('country-select'); 171 | 172 | // Verify that the dropdown contains usa, japan, and canada 173 | const optionValues = Array.from(countrySelect.options).map(option => option.value); 174 | expect(optionValues).toContain('usa'); 175 | expect(optionValues).toContain('japan'); 176 | expect(optionValues).toContain('canada'); 177 | 178 | const currentValue = countrySelect.value; 179 | 180 | expect(currentValue).toBe('usa'); 181 | 182 | let optionToSelect = 'japan'; 183 | 184 | await userEvent.selectOptions(countrySelect, optionToSelect); 185 | 186 | await waitFor(() => { 187 | expect(canvas.getByTestId('country-value').textContent).toBe(optionToSelect); 188 | }); 189 | } 190 | }; 191 | 192 | export const NestedViewModelStory: StoryObj = { 193 | name: 'Nested ViewModel Property', 194 | render: () => , 195 | play: async ({ canvasElement }) => { 196 | const canvas = within(canvasElement); 197 | 198 | 199 | // Wait for the Rive file to load 200 | await waitFor(() => { 201 | expect(canvas.getByTestId('drink-type-value')).toBeTruthy(); 202 | }); 203 | 204 | // Wait for options to be loaded 205 | await waitFor(() => { 206 | const drinkTypeSelect = canvas.getByTestId('drink-type-select'); 207 | return drinkTypeSelect.options.length > 0; 208 | }, { timeout: 2000 }); 209 | 210 | const drinkTypeSelect = canvas.getByTestId('drink-type-select'); 211 | const optionValues = Array.from(drinkTypeSelect.options).map(option => option.value); 212 | expect(optionValues).toContain('Coffee'); 213 | expect(optionValues).toContain('Tea'); 214 | 215 | 216 | expect(drinkTypeSelect.value).toBe('Tea'); 217 | 218 | let optionToSelect = 'Coffee'; 219 | 220 | await userEvent.selectOptions(drinkTypeSelect, optionToSelect); 221 | 222 | await waitFor(() => { 223 | expect(canvas.getByTestId('drink-type-value').textContent).toBe(optionToSelect); 224 | }); 225 | } 226 | }; 227 | 228 | export const TriggerPropertyStory: StoryObj = { 229 | name: 'Trigger Property', 230 | render: () => , 231 | play: async ({ canvasElement }) => { 232 | const canvas = within(canvasElement); 233 | 234 | // Wait for the Rive file to load 235 | await waitFor(() => { 236 | expect(canvas.getByTestId('submit-button')).toBeTruthy(); 237 | }, { timeout: 2000 }); 238 | 239 | expect(canvas.getByTestId('callback-triggered').textContent).toContain('none'); 240 | 241 | // Trigger submit action 242 | await userEvent.click(canvas.getByTestId('submit-button')); 243 | 244 | await waitFor(() => { 245 | expect(canvas.getByTestId('callback-triggered').textContent).toContain('submit-callback'); 246 | }); 247 | 248 | await userEvent.click(canvas.getByTestId('reset-button')); 249 | 250 | // Verify onTrigger callback works for reset 251 | await waitFor(() => { 252 | expect(canvas.getByTestId('callback-triggered').textContent).toContain('reset-callback'); 253 | }); 254 | } 255 | }; 256 | 257 | export const PersonInstancesStory: StoryObj = { 258 | name: 'Person Instances', 259 | render: () => , 260 | play: async ({ canvasElement }) => { 261 | const canvas = within(canvasElement); 262 | 263 | // Wait for the Rive file to load 264 | await waitFor(() => { 265 | expect(canvas.getByTestId('instance-name')).toBeTruthy(); 266 | expect(canvas.getByTestId('select-jane')).toBeTruthy(); 267 | expect(canvas.getByTestId('select-default')).toBeTruthy(); 268 | }, { timeout: 2000 }); 269 | 270 | // Initially should show Steve 271 | expect(canvas.getByTestId('instance-name').textContent).toContain('Steve'); 272 | 273 | // Switch to Jane 274 | const janeButton = canvas.getByTestId('select-jane'); 275 | await userEvent.click(janeButton); 276 | 277 | // Verify instance changed to Jane 278 | await waitFor(() => { 279 | expect(canvas.getByTestId('instance-name').textContent).toContain('Jane'); 280 | }); 281 | 282 | // Switch to Default instance 283 | const defaultButton = canvas.getByTestId('select-default'); 284 | await userEvent.click(defaultButton); 285 | 286 | // Verify instance changed to Default 287 | await waitFor(() => { 288 | expect(canvas.getByTestId('instance-name').textContent).toContain('Default'); 289 | }); 290 | 291 | // Switch back to Steve 292 | const steveButton = canvas.getByTestId('select-steve'); 293 | await userEvent.click(steveButton); 294 | 295 | // Verify instance changed back to Steve 296 | await waitFor(() => { 297 | expect(canvas.getByTestId('instance-name').textContent).toContain('Steve'); 298 | }); 299 | } 300 | }; 301 | 302 | // A configurable form story, so we can test all the properties at once 303 | export const PersonFormStory: StoryObj = { 304 | name: 'Complete Person Form', 305 | render: () => , 306 | play: async ({ canvasElement }) => { 307 | const canvas = within(canvasElement); 308 | 309 | 310 | // Wait for the Rive file to load 311 | await waitFor(() => { 312 | expect(canvas.getByTestId('name-value')).toBeTruthy(); 313 | }, { timeout: 2000 }); 314 | 315 | // Update name 316 | const nameInput = canvas.getByTestId('name-input'); 317 | await userEvent.clear(nameInput); 318 | await userEvent.type(nameInput, 'Test User'); 319 | 320 | // Update age 321 | const ageInput = canvas.getByTestId('age-input'); 322 | await userEvent.clear(ageInput); 323 | await userEvent.type(ageInput, '42'); 324 | 325 | // Toggle terms agreement 326 | const termsCheckbox = canvas.getByTestId('terms-checkbox'); 327 | await userEvent.click(termsCheckbox); 328 | 329 | // Change color 330 | const colorButton = canvas.getByTestId('set-color-red'); 331 | await userEvent.click(colorButton); 332 | 333 | // Change country 334 | const countrySelect = canvas.getByTestId('country-select'); 335 | await userEvent.selectOptions(countrySelect, 'japan'); 336 | 337 | // Change drink type 338 | const drinkTypeSelect = canvas.getByTestId('drink-type-select'); 339 | await userEvent.selectOptions(drinkTypeSelect, 'Coffee'); 340 | 341 | // Submit the form 342 | const submitButton = canvas.getByTestId('submit-button'); 343 | await userEvent.click(submitButton); 344 | } 345 | }; 346 | 347 | 348 | 349 | -------------------------------------------------------------------------------- /examples/src/components/DataBindingTests.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | import Rive, { 4 | useRive, 5 | useViewModel, 6 | useViewModelInstance, 7 | useViewModelInstanceBoolean, 8 | useViewModelInstanceString, 9 | useViewModelInstanceNumber, 10 | useViewModelInstanceEnum, 11 | useViewModelInstanceColor, 12 | useViewModelInstanceTrigger 13 | } from '@rive-app/react-webgl2'; 14 | 15 | 16 | export const StringPropertyTest = ({ src }: { src: string }) => { 17 | const { rive, RiveComponent } = useRive({ 18 | src, 19 | autoplay: true, 20 | artboard: "Artboard", 21 | autoBind: true, 22 | stateMachines: "State Machine 1", 23 | }); 24 | 25 | const { value: name, setValue: setName } = useViewModelInstanceString('name', rive?.viewModelInstance); 26 | 27 | return ( 28 |
29 | 30 | {(rive === null) ?
Loading…
: ( 31 |
32 | 42 |
{name}
43 |
44 | )} 45 |
46 | ); 47 | }; 48 | 49 | export const NumberPropertyTest = ({ src }: { src: string }) => { 50 | const { rive, RiveComponent } = useRive({ 51 | src, 52 | autoplay: true, 53 | artboard: "Artboard", 54 | autoBind: true, 55 | stateMachines: "State Machine 1", 56 | }); 57 | 58 | const { value: age, setValue: setAge } = useViewModelInstanceNumber('age', rive?.viewModelInstance); 59 | 60 | 61 | return ( 62 |
63 | 64 | {(rive === null) ?
Loading…
: ( 65 |
66 | 76 |
{age}
77 |
78 | )} 79 |
80 | ); 81 | }; 82 | 83 | export const BooleanPropertyTest = ({ src }: { src: string }) => { 84 | const { rive, RiveComponent } = useRive({ 85 | src, 86 | autoplay: true, 87 | artboard: "Artboard", 88 | autoBind: true, 89 | stateMachines: "State Machine 1", 90 | }); 91 | 92 | const { value: agreedToTerms, setValue: setAgreedToTerms } = useViewModelInstanceBoolean('agreedToTerms', rive?.viewModelInstance); 93 | 94 | return ( 95 |
96 | 97 | {(rive === null) ?
Loading…
: ( 98 |
99 | 108 |
{agreedToTerms ? 'true' : 'false'}
109 |
110 | )} 111 |
112 | ); 113 | }; 114 | 115 | const colorNumberToHexString = (colorNum: number | null) => { 116 | if (colorNum === null) { 117 | return 'N/A'; 118 | } 119 | const unsignedInt = colorNum >>> 0; 120 | const r = (unsignedInt >> 16) & 0xff; 121 | const g = (unsignedInt >> 8) & 0xff; 122 | const b = unsignedInt & 0xff; 123 | 124 | const toHex = (c: number) => c.toString(16).padStart(2, '0'); 125 | return `#${toHex(r)}${toHex(g)}${toHex(b)}`; 126 | }; 127 | 128 | export const ColorPropertyTest = ({ src }: { src: string }) => { 129 | const { rive, RiveComponent } = useRive({ 130 | src, 131 | autoplay: true, 132 | artboard: "Artboard", 133 | autoBind: true, 134 | stateMachines: "State Machine 1", 135 | }); 136 | 137 | 138 | const { value: colorNum, setValue: setColor, setRgb } = useViewModelInstanceColor('favColor', rive?.viewModelInstance); 139 | 140 | return ( 141 |
142 | 143 | {(rive === null) ?
Loading…
: ( 144 |
145 | 161 | 168 | 175 |
176 | )} 177 |
178 | ); 179 | }; 180 | 181 | export const EnumPropertyTest = ({ src }: { src: string }) => { 182 | const { rive, RiveComponent } = useRive({ 183 | src, 184 | autoplay: true, 185 | artboard: "Artboard", 186 | autoBind: true, 187 | stateMachines: "State Machine 1", 188 | }); 189 | 190 | const { value: country, setValue: setCountry, values: countries } = useViewModelInstanceEnum('country', rive?.viewModelInstance); 191 | 192 | return ( 193 |
194 | 195 | {(rive === null) ?
Loading…
: ( 196 |
197 | 209 |
{country}
210 |
211 | )} 212 |
213 | ); 214 | }; 215 | 216 | export const NestedViewModelTest = ({ src }: { src: string }) => { 217 | const { rive, RiveComponent } = useRive({ 218 | src, 219 | autoplay: true, 220 | artboard: "Artboard", 221 | autoBind: true, 222 | stateMachines: "State Machine 1", 223 | }); 224 | 225 | const { value: drinkType, setValue: setDrinkType, values: drinkTypes } = useViewModelInstanceEnum('favDrink/type', rive?.viewModelInstance); 226 | 227 | return ( 228 |
229 | 230 | {(rive === null) ?
Loading…
: ( 231 |
232 | 244 |
{drinkType}
245 |
246 | )} 247 |
248 | ); 249 | }; 250 | 251 | export const TriggerPropertyTest = ({ src }: { src: string }) => { 252 | const [callbackTriggered, setCallbackTriggered] = useState(''); 253 | 254 | const { rive, RiveComponent } = useRive({ 255 | src, 256 | autoplay: true, 257 | autoBind: true, 258 | artboard: "Artboard", 259 | stateMachines: "State Machine 1", 260 | }); 261 | 262 | 263 | 264 | const { trigger: onFormSubmit } = useViewModelInstanceTrigger('onFormSubmit', rive?.viewModelInstance, 265 | { 266 | onTrigger: () => { 267 | setCallbackTriggered('submit-callback'); 268 | } 269 | } 270 | ); 271 | 272 | const { trigger: onFormReset } = useViewModelInstanceTrigger('onFormReset', rive?.viewModelInstance, 273 | { 274 | onTrigger: () => { 275 | setCallbackTriggered('reset-callback'); 276 | } 277 | } 278 | ); 279 | 280 | const handleSubmit = () => { 281 | onFormSubmit(); 282 | }; 283 | 284 | const handleReset = () => { 285 | onFormReset(); 286 | }; 287 | 288 | return ( 289 |
290 | 291 | {(rive === null) ?
Loading…
: ( 292 |
293 | 294 | 295 | 296 |
297 | Last callback triggered: {callbackTriggered || 'none'} 298 |
299 |
300 | )} 301 |
302 | ); 303 | }; 304 | 305 | export const PersonForm = ({ src }: { src: string }) => { 306 | const { rive, RiveComponent } = useRive({ 307 | src, 308 | autoplay: true, 309 | autoBind: true, 310 | artboard: "Artboard", 311 | stateMachines: "State Machine 1", 312 | }); 313 | 314 | const { value: name, setValue: setName } = useViewModelInstanceString('name', rive?.viewModelInstance); 315 | const { value: age, setValue: setAge } = useViewModelInstanceNumber('age', rive?.viewModelInstance); 316 | const { value: agreedToTerms, setValue: setAgreedToTerms } = useViewModelInstanceBoolean('agreedToTerms', rive?.viewModelInstance); 317 | const { value: colorNum, setRgb } = useViewModelInstanceColor('favColor', rive?.viewModelInstance); 318 | const { value: country, setValue: setCountry, values: countries } = useViewModelInstanceEnum('country', rive?.viewModelInstance); 319 | const { trigger: onFormSubmit } = useViewModelInstanceTrigger('onFormSubmit', rive?.viewModelInstance); 320 | const { trigger: onFormReset } = useViewModelInstanceTrigger('onFormReset', rive?.viewModelInstance); 321 | 322 | 323 | // Drink properties (nested viewmodel) 324 | const { value: drinkType, setValue: setDrinkType, values: drinkTypes } = useViewModelInstanceEnum('favDrink/type', rive?.viewModelInstance); 325 | 326 | const handleReset = () => { 327 | setName(''); 328 | setAge(0); 329 | setAgreedToTerms(false); 330 | setRgb(0, 0, 0); 331 | setCountry(countries[0]); 332 | setDrinkType(drinkTypes[0]); 333 | onFormReset(); 334 | }; 335 | 336 | const handleSubmit = (e: React.FormEvent) => { 337 | e.preventDefault(); 338 | onFormSubmit(); 339 | }; 340 | 341 | return ( 342 |
343 | 344 | 345 | {(rive === null) ?
Loading…
: ( 346 |
347 |
348 | 357 |
{name}
358 |
359 | 360 |
361 | 370 |
{age}
371 |
372 | 373 |
374 | 383 |
{agreedToTerms ? 'true' : 'false'}
384 |
385 | 386 |
387 | 397 | 404 | 411 |
412 | 413 |
414 | 426 |
{country}
427 |
428 | 429 |
430 | 442 |
{drinkType}
443 |
444 | 445 |
446 | 447 | 448 |
449 |
450 | )} 451 |
452 | ); 453 | }; 454 | 455 | 456 | // Component to demonstrate different viewmodel instances 457 | export const PersonInstances = ({ src }: { src: string }) => { 458 | const [activeInstance, setActiveInstance] = useState('Steve'); 459 | const [useDefaultInstance, setUseDefaultInstance] = useState(false); 460 | 461 | const { rive, RiveComponent } = useRive({ 462 | src, 463 | autoplay: true, 464 | artboard: "Artboard", 465 | stateMachines: "State Machine 1", 466 | }); 467 | 468 | const viewModel = useViewModel(rive, { name: 'PersonViewModel' }); 469 | const params = useDefaultInstance ? { useDefault: true, rive } : { name: activeInstance, rive } 470 | const viewModelInstance = useViewModelInstance(viewModel, params); 471 | 472 | 473 | const { value: name } = useViewModelInstanceString('name', viewModelInstance); 474 | const { value: age } = useViewModelInstanceNumber('age', viewModelInstance); 475 | const { value: country } = useViewModelInstanceEnum('country', viewModelInstance); 476 | 477 | const switchToNamedInstance = (instanceName: string) => { 478 | setActiveInstance(instanceName); 479 | setUseDefaultInstance(false); 480 | }; 481 | 482 | const switchToDefaultInstance = () => { 483 | setUseDefaultInstance(true); 484 | }; 485 | 486 | return ( 487 |
488 | 489 | 490 | {(rive === null) ?
Loading…
: ( 491 |
492 | 499 | 506 | 513 |
514 | )} 515 | 516 |
517 |

Instance: {useDefaultInstance ? 'Default' : activeInstance}

518 |

Name: {name}

519 |

Age: {age}

520 |

Country: {country}

521 |
522 |
523 | ); 524 | }; 525 | -------------------------------------------------------------------------------- /examples/src/components/Events.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import Events from './Events'; 4 | 5 | const meta = { 6 | title: 'Events', 7 | component: Events, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | args: {}, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | type Story = StoryObj; 16 | 17 | export const Default: Story = {}; 18 | -------------------------------------------------------------------------------- /examples/src/components/Events.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useRive, EventType, RiveEventType } from '@rive-app/react-canvas'; 3 | 4 | const Events = () => { 5 | const { rive, RiveComponent } = useRive({ 6 | src: 'rating.riv', 7 | stateMachines: 'State Machine 1', 8 | autoplay: true, 9 | automaticallyHandleEvents: true, 10 | }); 11 | 12 | const onRiveEventReceived = (riveEvent: any) => { 13 | console.log('Rive event received:', riveEvent); 14 | const eventData = riveEvent.data; 15 | const eventProperties = eventData.properties; 16 | if (eventData.type === RiveEventType.General) { 17 | console.log('Event name', eventData.name); 18 | console.log('Rating', eventProperties.rating); 19 | console.log('Message', eventProperties.message); 20 | } 21 | }; 22 | 23 | // Wait until the rive object is instantiated before adding the Rive 24 | // event listener 25 | useEffect(() => { 26 | if (rive) { 27 | rive.on(EventType.RiveEvent, onRiveEventReceived); 28 | } 29 | }, [rive]); 30 | 31 | return ; 32 | }; 33 | 34 | export default Events; 35 | -------------------------------------------------------------------------------- /examples/src/components/Http.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import Http from './Http'; 4 | 5 | const meta = { 6 | title: 'Http', 7 | component: Http, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | args: {}, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | type Story = StoryObj; 16 | 17 | export const Default: Story = {}; 18 | -------------------------------------------------------------------------------- /examples/src/components/Http.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRive } from '@rive-app/react-canvas'; 3 | 4 | const Http = () => { 5 | const { RiveComponent } = useRive({ 6 | src: 'https://cdn.rive.app/animations/vehicles.riv', 7 | stateMachines: 'bumpy', 8 | autoplay: true, 9 | }); 10 | 11 | return ; 12 | }; 13 | 14 | export default Http; 15 | -------------------------------------------------------------------------------- /examples/src/components/ResponsiveLayout.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import ResponsiveLayout from './ResponsiveLayout'; 4 | 5 | const meta = { 6 | title: 'ResponsiveLayout', 7 | component: ResponsiveLayout, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | args: {}, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | type Story = StoryObj; 16 | 17 | export const Default: Story = {}; 18 | -------------------------------------------------------------------------------- /examples/src/components/ResponsiveLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Fit, useRive, Layout } from '@rive-app/react-canvas'; 3 | 4 | const ResponsiveLayout = () => { 5 | const { RiveComponent } = useRive({ 6 | src: 'layout_test.riv', 7 | artboard: 'Artboard', 8 | stateMachines: 'State Machine 1', 9 | autoplay: true, 10 | layout: new Layout({ 11 | fit: Fit.Layout, 12 | }), 13 | }); 14 | 15 | return ; 16 | }; 17 | 18 | export default ResponsiveLayout; 19 | -------------------------------------------------------------------------------- /examples/src/components/Simple.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import Simple from './Simple'; 4 | 5 | const meta = { 6 | title: 'Simple', 7 | component: Simple, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | args: {}, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | type Story = StoryObj; 16 | 17 | export const Default: Story = {}; 18 | -------------------------------------------------------------------------------- /examples/src/components/Simple.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRive } from '@rive-app/react-canvas'; 3 | 4 | const Simple = () => { 5 | const { RiveComponent } = useRive({ 6 | src: 'avatars.riv', 7 | artboard: 'Avatar 3', 8 | autoplay: true, 9 | }); 10 | 11 | return ; 12 | }; 13 | 14 | export default Simple; 15 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "paths": { 10 | "@rive-app/react-canvas": ["../"], 11 | "@rive-app/react-webgl": ["../"], 12 | "@rive-app/react-webgl2": ["../"], 13 | "@rive-app/react-canvas-lite": ["../"] 14 | }, 15 | "allowJs": true, 16 | "skipLibCheck": true, 17 | "esModuleInterop": true, 18 | "allowSyntheticDefaultImports": true, 19 | "strict": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "module": "esnext", 23 | "moduleResolution": "node", 24 | "resolveJsonModule": true, 25 | "isolatedModules": true, 26 | "noEmit": true, 27 | "jsx": "react-jsx" 28 | }, 29 | "include": [ 30 | "src" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testRegex: '/test/.*\\.test\\.tsx$', 4 | setupFilesAfterEnv: ['/setupTests.ts'], 5 | testEnvironment: 'jsdom', 6 | globals: { 7 | 'ts-jest': { 8 | tsconfig: 'tsconfig.test.json', 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /npm/react-canvas-lite/README.md: -------------------------------------------------------------------------------- 1 | # @rive-app/react-canvas-lite 2 | 3 | Output for `rive-react` using the backing `@rive-app/canvas-lite` JS runtime. 4 | 5 | ## Why Lite? 6 | 7 | The current `@rive-app/react-canvas` dependency supports all Rive features and contains the necessary backing dependencies to render those graphics. This `lite` version has the same API, but does not compile and build with certain dependencies in order to keep the package size as small as possible. 8 | 9 | At this time, this lite version of `@rive-app/react-canvas-lite` will not render Rive Text onto the canvas or play Rive Audio. Note however, that even if your Rive file may include Rive Text components, rendering the graphic should not cause any app errors, or cease to render. The same is true for playing audio. 10 | -------------------------------------------------------------------------------- /npm/react-canvas-lite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rive-app/react-canvas-lite", 3 | "version": "4.21.2", 4 | "description": "React wrapper around the @rive-app/canvas-lite library", 5 | "main": "dist/index.js", 6 | "typings": "dist/types/index.d.ts", 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/rive-app/rive-react.git" 13 | }, 14 | "author": "", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/rive-app/rive-react/issues" 18 | }, 19 | "homepage": "https://github.com/rive-app/rive-react#readme", 20 | "dependencies": { 21 | "@rive-app/canvas-lite": "2.29.2" 22 | }, 23 | "peerDependencies": { 24 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0" 25 | } 26 | } -------------------------------------------------------------------------------- /npm/react-canvas/README.md: -------------------------------------------------------------------------------- 1 | # @rive-app/react-canvas 2 | 3 | Output for `rive-react` using the backing `@rive-app/canvas` JS runtime 4 | -------------------------------------------------------------------------------- /npm/react-canvas/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rive-app/react-canvas", 3 | "version": "4.21.2", 4 | "description": "React wrapper around the @rive-app/canvas library", 5 | "main": "dist/index.js", 6 | "typings": "dist/types/index.d.ts", 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/rive-app/rive-react.git" 13 | }, 14 | "author": "", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/rive-app/rive-react/issues" 18 | }, 19 | "homepage": "https://github.com/rive-app/rive-react#readme", 20 | "dependencies": { 21 | "@rive-app/canvas": "2.29.2" 22 | }, 23 | "peerDependencies": { 24 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0" 25 | } 26 | } -------------------------------------------------------------------------------- /npm/react-webgl/README.md: -------------------------------------------------------------------------------- 1 | # @rive-app/react-webgl 2 | 3 | Output for `rive-react` using the backing `@rive-app/webgl` JS runtime 4 | -------------------------------------------------------------------------------- /npm/react-webgl/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rive-app/react-webgl", 3 | "version": "4.21.2", 4 | "description": "React wrapper around the @rive-app/webgl library", 5 | "main": "dist/index.js", 6 | "typings": "dist/types/index.d.ts", 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/rive-app/rive-react.git" 13 | }, 14 | "author": "", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/rive-app/rive-react/issues" 18 | }, 19 | "homepage": "https://github.com/rive-app/rive-react#readme", 20 | "dependencies": { 21 | "@rive-app/webgl": "2.29.2" 22 | }, 23 | "peerDependencies": { 24 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0" 25 | } 26 | } -------------------------------------------------------------------------------- /npm/react-webgl2/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rive-app/rive-react/d310f1c96dbff6cbb7397d4bea2687c8d3f271f4/npm/react-webgl2/README.md -------------------------------------------------------------------------------- /npm/react-webgl2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rive-app/react-webgl2", 3 | "version": "4.21.2", 4 | "description": "React wrapper around the @rive-app/webgl2 library", 5 | "main": "dist/index.js", 6 | "typings": "dist/types/index.d.ts", 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/rive-app/rive-react.git" 13 | }, 14 | "author": "", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/rive-app/rive-react/issues" 18 | }, 19 | "homepage": "https://github.com/rive-app/rive-react#readme", 20 | "dependencies": { 21 | "@rive-app/webgl2": "2.29.2" 22 | }, 23 | "peerDependencies": { 24 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0" 25 | } 26 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rive-react", 3 | "version": "4.21.2", 4 | "description": "React wrapper around the rive-js library", 5 | "main": "dist/index.js", 6 | "typings": "dist/types/index.d.ts", 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "test": "jest", 12 | "build": "bunchee src/index.ts -m --no-sourcemap", 13 | "dev": "watch 'npm run build' src", 14 | "lint": "eslint -c .eslintrc.js 'src/**/*{.ts,.tsx}'", 15 | "format": "prettier --write src", 16 | "types:check": "tsc --noEmit", 17 | "release": "release-it", 18 | "release:patch": "npm run release -- --ci", 19 | "release:minor": "npm run release -- minor --ci", 20 | "release:major": "npm run release -- major --ci", 21 | "setup-builds": "./scripts/build.sh", 22 | "setup-packages": "./scripts/setup_all_packages.sh", 23 | "bump-versions": "./scripts/bump_all_versions.sh $npm_package_version", 24 | "publish:all": "./scripts/publish_all.sh --access public", 25 | "storybook": "yarn --cwd examples storybook" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/rive-app/rive-react.git" 30 | }, 31 | "author": "", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/rive-app/rive-react/issues" 35 | }, 36 | "homepage": "https://github.com/rive-app/rive-react#readme", 37 | "dependencies": { 38 | "@rive-app/canvas": "2.29.2", 39 | "@rive-app/canvas-lite": "2.29.2", 40 | "@rive-app/webgl": "2.29.2", 41 | "@rive-app/webgl2": "2.29.2" 42 | }, 43 | "peerDependencies": { 44 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0" 45 | }, 46 | "devDependencies": { 47 | "@babel/core": "^7.18.0", 48 | "@testing-library/jest-dom": "^5.13.0", 49 | "@testing-library/react": "^16.3.0", 50 | "@types/jest": "^27.0.3", 51 | "@types/offscreencanvas": "^2019.6.4", 52 | "@types/react": "^18.0.0", 53 | "@types/testing-library__jest-dom": "^5.9.5", 54 | "@typescript-eslint/eslint-plugin": "^5.7.0", 55 | "@typescript-eslint/parser": "^5.7.0", 56 | "auto-changelog": "^2.3.0", 57 | "babel-loader": "^8.2.5", 58 | "bunchee": "1.8.5", 59 | "eslint": "^7.28.0", 60 | "eslint-config-prettier": "^8.3.0", 61 | "eslint-config-standard": "^16.0.3", 62 | "eslint-plugin-import": "^2.23.4", 63 | "eslint-plugin-node": "^11.1.0", 64 | "eslint-plugin-prettier": "^3.4.0", 65 | "eslint-plugin-promise": "^5.1.0", 66 | "eslint-plugin-react": "^7.27.1", 67 | "eslint-plugin-react-hooks": "^4.6.0", 68 | "eslint-plugin-storybook": "^0.5.12", 69 | "jest": "^27.0.4", 70 | "prettier": "^2.3.1", 71 | "react": "^18.0.0", 72 | "react-dom": "^18.0.0", 73 | "release-it": "^14.10.0", 74 | "ts-jest": "^27.1.1", 75 | "typescript": "^4.5.4", 76 | "watch": "^1.0.2" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Copy the build to each react-variant build for npm release 6 | cp -r ./dist ./npm/react-webgl 7 | cp -r ./dist ./npm/react-canvas 8 | cp -r ./dist ./npm/react-canvas-lite 9 | cp -r ./dist ./npm/react-webgl2 10 | 11 | echo "Replacing the canvas with webgl references in react-webgl" 12 | pushd ./npm/react-webgl/dist 13 | if [[ "$OSTYPE" == "darwin"* ]]; then 14 | find . -type f -name "*.ts" -print0 | xargs -0 sed -i '' -e 's/@rive-app\/canvas/@rive-app\/webgl/g' 15 | find . -type f -name "*.js" -print0 | xargs -0 sed -i '' -e 's/@rive-app\/canvas/@rive-app\/webgl/g' 16 | else 17 | find . -type f -name "*.ts" -print0 | xargs -0 sed -i -e 's/@rive-app\/canvas/@rive-app\/webgl/g' 18 | find . -type f -name "*.js" -print0 | xargs -0 sed -i -e 's/@rive-app\/canvas/@rive-app\/webgl/g' 19 | fi 20 | popd 21 | 22 | echo "Replacing the canvas with webgl2 references in react-webgl2" 23 | pushd ./npm/react-webgl2/dist 24 | if [[ "$OSTYPE" == "darwin"* ]]; then 25 | find . -type f -name "*.ts" -print0 | xargs -0 sed -i '' -e 's/@rive-app\/canvas/@rive-app\/webgl2/g' 26 | find . -type f -name "*.js" -print0 | xargs -0 sed -i '' -e 's/@rive-app\/canvas/@rive-app\/webgl2/g' 27 | else 28 | find . -type f -name "*.ts" -print0 | xargs -0 sed -i -e 's/@rive-app\/canvas/@rive-app\/webgl2/g' 29 | find . -type f -name "*.js" -print0 | xargs -0 sed -i -e 's/@rive-app\/canvas/@rive-app\/webgl2/g' 30 | fi 31 | popd 32 | 33 | echo "Replacing the canvas with canvas-lite references in react-canvas-lite" 34 | pushd ./npm/react-canvas-lite/dist 35 | if [[ "$OSTYPE" == "darwin"* ]]; then 36 | find . -type f -name "*.ts" -print0 | xargs -0 sed -i '' -e 's/@rive-app\/canvas/@rive-app\/canvas-lite/g' 37 | find . -type f -name "*.js" -print0 | xargs -0 sed -i '' -e 's/@rive-app\/canvas/@rive-app\/canvas-lite/g' 38 | else 39 | find . -type f -name "*.ts" -print0 | xargs -0 sed -i -e 's/@rive-app\/canvas/@rive-app\/canvas-lite/g' 40 | find . -type f -name "*.js" -print0 | xargs -0 sed -i -e 's/@rive-app\/canvas/@rive-app\/canvas-lite/g' 41 | fi 42 | popd 43 | -------------------------------------------------------------------------------- /scripts/publish_all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Bump the version number of every npm module in the npm folder. 5 | for dir in ./npm/*; do 6 | pushd $dir > /dev/null 7 | echo Publishing `echo $dir | sed 's:.*/::'` 8 | npm publish $@ 9 | popd > /dev/null 10 | done 11 | -------------------------------------------------------------------------------- /scripts/setup_all_packages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Copying package.json to rive-react npm package folders" 5 | 6 | # Bump the version number of every npm module in the npm folder. 7 | for dir in ./npm/*; do 8 | echo $dir 9 | pushd $dir > /dev/null 10 | echo $dir 11 | if [ -f "./package.json" ]; then 12 | echo "Removing existing package.json..." 13 | rm "./package.json" 14 | echo "package.json deleted from $dir" 15 | fi 16 | cp ../../package.json ./ 17 | repo_name=`echo $dir | sed 's:.*/::' | sed 's/_/-/g'` 18 | echo Setting package.json on npm packages 19 | echo $repo_name 20 | ../../scripts/setup_package.sh $repo_name 21 | echo Finished setting up package.json 22 | popd > /dev/null 23 | done 24 | 25 | -------------------------------------------------------------------------------- /scripts/setup_package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Setup the package.json for a given npm module 5 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 6 | node $SCRIPT_DIR/trimPackageJson.js `pwd` "$1" 7 | -------------------------------------------------------------------------------- /scripts/trimPackageJson.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = process.argv[2]; 3 | const npmPackageSplit = process.argv[3].split('-'); 4 | // extracts "webgl" or "canvas-lite" from the npm package name 5 | const renderer = npmPackageSplit.slice(1).join('-'); 6 | const package = require(path + '/package.json'); 7 | 8 | function trimNpmPackage() { 9 | package.name = `@rive-app/react-${renderer}`; 10 | package.description = `React wrapper around the @rive-app/${renderer} library`; 11 | const webDependencyName = `@rive-app/${renderer}`; 12 | const canvasDep = package.dependencies[webDependencyName]; 13 | package.dependencies = { 14 | [webDependencyName]: canvasDep, 15 | }; 16 | delete package.devDependencies; 17 | delete package.scripts; 18 | fs.writeFileSync(path + '/package.json', JSON.stringify(package, null, 2)); 19 | } 20 | 21 | if (renderer) { 22 | trimNpmPackage(); 23 | } 24 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | window.IntersectionObserver = class IntersectionObserver { 4 | readonly root: Element | null; 5 | 6 | readonly rootMargin: string; 7 | 8 | readonly thresholds: ReadonlyArray; 9 | 10 | constructor() { 11 | this.root = null; 12 | this.rootMargin = ''; 13 | this.thresholds = []; 14 | } 15 | 16 | disconnect() {} 17 | 18 | observe() {} 19 | 20 | takeRecords(): IntersectionObserverEntry[] { 21 | return []; 22 | } 23 | 24 | unobserve() {} 25 | }; 26 | 27 | jest.mock('@rive-app/canvas', () => ({ 28 | Rive: jest.fn().mockImplementation(() => ({ 29 | on: jest.fn(), 30 | stop: jest.fn(), 31 | })), 32 | Layout: jest.fn(), 33 | Fit: { 34 | Cover: 'cover', 35 | }, 36 | Alignment: { 37 | Center: 'center', 38 | }, 39 | EventType: { 40 | Load: 'load', 41 | }, 42 | StateMachineInputType: { 43 | Number: 1, 44 | Boolean: 2, 45 | Trigger: 3, 46 | }, 47 | })); 48 | -------------------------------------------------------------------------------- /src/components/Rive.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from '@rive-app/canvas'; 2 | import React, { ComponentProps } from 'react'; 3 | import useRive from '../hooks/useRive'; 4 | 5 | export interface RiveProps { 6 | /** 7 | * URL of the Rive asset, or path to where the public asset is stored. 8 | */ 9 | src: string; 10 | /** 11 | * Artboard to render from the Rive asset. 12 | * Defaults to the first artboard created. 13 | */ 14 | artboard?: string; 15 | /** 16 | * Specify a starting animation to play. 17 | */ 18 | animations?: string | string[]; 19 | /** 20 | * Specify a starting state machine to play. 21 | */ 22 | stateMachines?: string | string[]; 23 | /** 24 | * Specify a starting Layout object to set Fill and Alignment for the drawing surface. See docs at https://rive.app/community/doc/layout/docBl81zd1GB for more on layout configuration. 25 | */ 26 | layout?: Layout; 27 | /** 28 | * 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. 29 | */ 30 | useOffscreenRenderer?: boolean; 31 | /** 32 | * Specify whether to disable Rive listeners on the canvas, thus preventing any event listeners to be attached to the canvas element 33 | */ 34 | shouldDisableRiveListeners?: boolean; 35 | /** 36 | * Specify whether to resize the canvas to its container automatically 37 | */ 38 | shouldResizeCanvasToContainer?: boolean; 39 | /** 40 | * Enable Rive Events to be handled by the runtime. This means any special Rive Event may have 41 | * functionality that can be invoked implicitly when detected. 42 | * 43 | * For example, if during the render loop an OpenUrlEvent is detected, the 44 | * browser may try to open the specified URL in the payload. 45 | * 46 | * This flag is false by default to prevent any unwanted behaviors from taking place. 47 | * This means any special Rive Event will have to be handled manually by subscribing to 48 | * EventType.RiveEvent 49 | */ 50 | automaticallyHandleEvents?: boolean; 51 | } 52 | 53 | const Rive = ({ 54 | src, 55 | artboard, 56 | animations, 57 | stateMachines, 58 | layout, 59 | useOffscreenRenderer = true, 60 | shouldDisableRiveListeners = false, 61 | shouldResizeCanvasToContainer = true, 62 | automaticallyHandleEvents = false, 63 | children, 64 | ...rest 65 | }: RiveProps & ComponentProps<'canvas'>) => { 66 | const params = { 67 | src, 68 | artboard, 69 | animations, 70 | layout, 71 | stateMachines, 72 | autoplay: true, 73 | shouldDisableRiveListeners, 74 | automaticallyHandleEvents, 75 | }; 76 | 77 | const options = { 78 | useOffscreenRenderer, 79 | shouldResizeCanvasToContainer, 80 | }; 81 | 82 | const { RiveComponent } = useRive(params, options); 83 | return {children}; 84 | }; 85 | 86 | export default Rive; 87 | -------------------------------------------------------------------------------- /src/hooks/elementObserver.ts: -------------------------------------------------------------------------------- 1 | class FakeIntersectionObserver { 2 | observe() {} 3 | unobserve() {} 4 | disconnect() {} 5 | } 6 | 7 | const MyIntersectionObserver = 8 | globalThis.IntersectionObserver || FakeIntersectionObserver; 9 | 10 | class ElementObserver { 11 | private observer; 12 | 13 | private elementsMap: Map = new Map(); 14 | 15 | constructor() { 16 | this.observer = new MyIntersectionObserver(this.onObserved); 17 | } 18 | public onObserved = (entries: IntersectionObserverEntry[]) => { 19 | entries.forEach((entry) => { 20 | const elementCallback = this.elementsMap.get(entry.target as Element); 21 | if (elementCallback) { 22 | elementCallback(entry); 23 | } 24 | }); 25 | }; 26 | 27 | public registerCallback(element: Element, callback: Function) { 28 | this.observer.observe(element); 29 | this.elementsMap.set(element, callback); 30 | } 31 | 32 | public removeCallback(element: Element) { 33 | this.observer.unobserve(element); 34 | this.elementsMap.delete(element); 35 | } 36 | } 37 | 38 | export default ElementObserver; 39 | -------------------------------------------------------------------------------- /src/hooks/useContainerSize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { Dimensions } from '../types'; 3 | 4 | // There are polyfills for this, but they add hundreds of lines of code 5 | class FakeResizeObserver { 6 | observe() {} 7 | unobserve() {} 8 | disconnect() {} 9 | } 10 | 11 | function throttle(f: Function, delay: number) { 12 | let timer = 0; 13 | return function (this: Function, ...args: any) { 14 | clearTimeout(timer); 15 | timer = window.setTimeout(() => f.apply(this, args), delay); 16 | }; 17 | } 18 | 19 | const MyResizeObserver = globalThis.ResizeObserver || FakeResizeObserver; 20 | const hasResizeObserver = globalThis.ResizeObserver !== undefined; 21 | 22 | const useResizeObserver = hasResizeObserver; 23 | const useWindowListener = !useResizeObserver; 24 | 25 | /** 26 | * Hook to listen for a ref element's resize events being triggered. When resized, 27 | * it sets state to an object of {width: number, height: number} indicating the contentRect 28 | * size of the element at the new resize. 29 | * 30 | * @param containerRef - Ref element to listen for resize events on 31 | * @returns - Size object with width and height attributes 32 | */ 33 | export default function useSize( 34 | containerRef: React.MutableRefObject, 35 | shouldResizeCanvasToContainer = true 36 | ) { 37 | const [size, setSize] = useState({ 38 | width: 0, 39 | height: 0, 40 | }); 41 | 42 | // internet explorer does not support ResizeObservers. 43 | useEffect(() => { 44 | if (typeof window !== 'undefined' && shouldResizeCanvasToContainer) { 45 | const handleResize = () => { 46 | setSize({ 47 | width: window.innerWidth, 48 | height: window.innerHeight, 49 | }); 50 | }; 51 | 52 | if (useWindowListener) { 53 | // only pay attention to window size changes when we do not have the resizeObserver (IE only) 54 | handleResize(); 55 | window.addEventListener('resize', handleResize); 56 | } 57 | 58 | return () => window.removeEventListener('resize', handleResize); 59 | } 60 | }, []); 61 | const observer = useRef( 62 | new MyResizeObserver( 63 | throttle((entries: any) => { 64 | if (useResizeObserver) { 65 | setSize({ 66 | width: entries[entries.length - 1].contentRect.width, 67 | height: entries[entries.length - 1].contentRect.height, 68 | }); 69 | } 70 | }, 0) 71 | ) 72 | ); 73 | 74 | useEffect(() => { 75 | const currentObserver = observer.current; 76 | if (!shouldResizeCanvasToContainer) { 77 | currentObserver.disconnect(); 78 | return; 79 | } 80 | const containerEl = containerRef.current; 81 | if (containerRef.current && useResizeObserver) { 82 | currentObserver.observe(containerRef.current); 83 | } 84 | 85 | return () => { 86 | currentObserver.disconnect(); 87 | if (containerEl && useResizeObserver) { 88 | currentObserver.unobserve(containerEl); 89 | } 90 | }; 91 | }, [containerRef, observer]); 92 | 93 | return size; 94 | } 95 | -------------------------------------------------------------------------------- /src/hooks/useDevicePixelRatio.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | /** 3 | * Listen for devicePixelRatio changes and set the new value accordingly. This could 4 | * happen for reasons such as: 5 | * - User moves window from retina screen display to a separate monitor 6 | * - User controls zoom settings on the browser 7 | * 8 | * Source: https://github.com/rexxars/use-device-pixel-ratio/blob/main/index.ts 9 | * 10 | * @param customDevicePixelRatio - Number to force a dpr to abide by, rather than using the window's 11 | * 12 | * @returns dpr: Number - Device pixel ratio; ratio of physical px to resolution in CSS pixels for current device 13 | */ 14 | export default function useDevicePixelRatio(customDevicePixelRatio?: number) { 15 | const dpr = customDevicePixelRatio || getDevicePixelRatio(); 16 | const [currentDpr, setCurrentDpr] = useState(dpr); 17 | 18 | useEffect(() => { 19 | const canListen = typeof window !== 'undefined' && 'matchMedia' in window; 20 | if (!canListen) { 21 | return; 22 | } 23 | 24 | const updateDpr = () => { 25 | const newDpr = customDevicePixelRatio || getDevicePixelRatio(); 26 | setCurrentDpr(newDpr); 27 | }; 28 | const mediaMatcher = window.matchMedia( 29 | `screen and (resolution: ${currentDpr}dppx)` 30 | ); 31 | mediaMatcher.hasOwnProperty('addEventListener') 32 | ? mediaMatcher.addEventListener('change', updateDpr) 33 | : mediaMatcher.addListener(updateDpr); 34 | 35 | return () => { 36 | mediaMatcher.hasOwnProperty('removeEventListener') 37 | ? mediaMatcher.removeEventListener('change', updateDpr) 38 | : mediaMatcher.removeListener(updateDpr); 39 | }; 40 | }, [currentDpr, customDevicePixelRatio]); 41 | 42 | return currentDpr; 43 | } 44 | 45 | function getDevicePixelRatio(): number { 46 | const hasDprProp = 47 | typeof window !== 'undefined' && 48 | typeof window.devicePixelRatio === 'number'; 49 | const dpr = hasDprProp ? window.devicePixelRatio : 1; 50 | return Math.min(Math.max(1, dpr), 3); 51 | } 52 | -------------------------------------------------------------------------------- /src/hooks/useIntersectionObserver.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import ElementObserver from './elementObserver'; 3 | 4 | let observer: ElementObserver; 5 | const getObserver = () => { 6 | if(!observer) { 7 | observer = new ElementObserver(); 8 | } 9 | return observer; 10 | } 11 | 12 | /** 13 | * Hook to observe elements when they are intersecting with the viewport 14 | * 15 | * @returns - API to observer and unobserve elements 16 | */ 17 | export default function useIntersectionObserver() { 18 | const observe = useCallback((element: Element, callback: Function) => { 19 | const observer = getObserver(); 20 | observer.registerCallback(element, callback); 21 | }, []); 22 | 23 | const unobserve = useCallback((element: Element) => { 24 | const observer = getObserver(); 25 | observer.removeCallback(element); 26 | }, []); 27 | 28 | return { 29 | observe, 30 | unobserve, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/hooks/useResizeCanvas.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, MutableRefObject, useCallback } from 'react'; 2 | import { Bounds } from '@rive-app/canvas'; 3 | import { Dimensions, UseRiveOptions } from '../types'; 4 | import useDevicePixelRatio from './useDevicePixelRatio'; 5 | import useContainerSize from './useContainerSize'; 6 | import { getOptions } from '../utils'; 7 | 8 | interface UseResizeCanvasProps { 9 | /** 10 | * Whether or not Rive is loaded and renderer is associated with the canvas 11 | */ 12 | riveLoaded: boolean; 13 | /** 14 | * Ref to the canvas element 15 | */ 16 | canvasElem: HTMLCanvasElement | null; 17 | /** 18 | * Ref to the container element of the canvas 19 | */ 20 | containerRef: MutableRefObject; 21 | /** 22 | * (Optional) Callback to be invoked after the canvas has been resized due to a resize 23 | * of its parent container. This is where you would want to reset the layout 24 | * dimensions for the Rive renderer to dictate the new min/max bounds of the 25 | * canvas. 26 | * 27 | * Using the high-level JS runtime, this might be a simple call to `rive.resizeToCanvas()` 28 | * Using the low-level JSruntime, this might be invoking the renderer's `.align()` method 29 | * with the Layout and min/max X/Y values of the canvas. 30 | * 31 | * @returns void 32 | */ 33 | onCanvasHasResized?: () => void; 34 | /** 35 | * (Optional) Options passed to the useRive hook, including the shouldResizeCanvasToContainer option 36 | * which prevents the canvas element from resizing to its parent container 37 | */ 38 | options?: Partial; 39 | /** 40 | * (Optional) AABB bounds of the artboard. If provided, the canvas will be sized to the artboard 41 | * height if the fitCanvasToArtboardHeight option is true. 42 | */ 43 | artboardBounds?: Bounds; 44 | } 45 | 46 | /** 47 | * Helper hook to listen for changes in the parent container size and size the 48 | * to match. If a resize event has occurred, a supplied callback (onCanvasHasResized) 49 | * will be inokved to allow for any re-calculation needed (i.e. Rive layout on the canvas). 50 | * 51 | * This hook is useful if you are not intending to use the `useRive` hook yourself, but still 52 | * want to use the auto-sizing logic on the canvas/container. 53 | * 54 | * @param props - Object to supply necessary props to the hook 55 | */ 56 | export default function useResizeCanvas({ 57 | riveLoaded = false, 58 | canvasElem, 59 | containerRef, 60 | options = {}, 61 | onCanvasHasResized, 62 | artboardBounds, 63 | }: UseResizeCanvasProps) { 64 | const presetOptions = getOptions(options); 65 | const [ 66 | { height: lastContainerHeight, width: lastContainerWidth }, 67 | setLastContainerDimensions, 68 | ] = useState({ 69 | height: 0, 70 | width: 0, 71 | }); 72 | const [ 73 | { height: lastCanvasHeight, width: lastCanvasWidth }, 74 | setLastCanvasSize, 75 | ] = useState({ 76 | height: 0, 77 | width: 0, 78 | }); 79 | 80 | const [isFirstSizing, setIsFirstSizing] = useState(true); 81 | 82 | const { 83 | fitCanvasToArtboardHeight, 84 | shouldResizeCanvasToContainer, 85 | useDevicePixelRatio: shouldUseDevicePixelRatio, 86 | customDevicePixelRatio, 87 | } = presetOptions; 88 | 89 | const containerSize = useContainerSize( 90 | containerRef, 91 | shouldResizeCanvasToContainer 92 | ); 93 | const currentDevicePixelRatio = useDevicePixelRatio(customDevicePixelRatio); 94 | 95 | const { maxX, maxY } = artboardBounds ?? {}; 96 | 97 | const getContainerDimensions = useCallback(() => { 98 | const width = containerRef.current?.clientWidth ?? 0; 99 | const height = containerRef.current?.clientHeight ?? 0; 100 | if (fitCanvasToArtboardHeight && artboardBounds) { 101 | const { maxY, maxX } = artboardBounds; 102 | return { width, height: width * (maxY / maxX) }; 103 | } 104 | return { 105 | width, 106 | height, 107 | }; 108 | }, [containerRef, fitCanvasToArtboardHeight, maxX, maxY]); 109 | 110 | useEffect(() => { 111 | // If Rive is not ready, the container is not ready, or the user supplies a flag 112 | // to not resize the canvas to the container, then return early 113 | if ( 114 | !shouldResizeCanvasToContainer || 115 | !containerRef.current || 116 | !riveLoaded 117 | ) { 118 | return; 119 | } 120 | 121 | const { width, height } = getContainerDimensions(); 122 | let hasResized = false; 123 | if (canvasElem) { 124 | // Check if the canvas parent container bounds have changed and set 125 | // new values accordingly 126 | const boundsChanged = 127 | width !== lastContainerWidth || height !== lastContainerHeight; 128 | if (presetOptions.fitCanvasToArtboardHeight && boundsChanged) { 129 | containerRef.current.style.height = height + 'px'; 130 | hasResized = true; 131 | } 132 | if (presetOptions.useDevicePixelRatio) { 133 | // Check if devicePixelRatio may have changed and get new canvas 134 | // width/height values to set the size 135 | const canvasSizeChanged = 136 | width * currentDevicePixelRatio !== lastCanvasWidth || 137 | height * currentDevicePixelRatio !== lastCanvasHeight; 138 | if (boundsChanged || canvasSizeChanged) { 139 | const newCanvasWidthProp = currentDevicePixelRatio * width; 140 | const newCanvasHeightProp = currentDevicePixelRatio * height; 141 | canvasElem.width = newCanvasWidthProp; 142 | canvasElem.height = newCanvasHeightProp; 143 | canvasElem.style.width = width + 'px'; 144 | canvasElem.style.height = height + 'px'; 145 | setLastCanvasSize({ 146 | width: newCanvasWidthProp, 147 | height: newCanvasHeightProp, 148 | }); 149 | hasResized = true; 150 | } 151 | } else if (boundsChanged) { 152 | canvasElem.width = width; 153 | canvasElem.height = height; 154 | setLastCanvasSize({ 155 | width: width, 156 | height: height, 157 | }); 158 | hasResized = true; 159 | } 160 | setLastContainerDimensions({ width, height }); 161 | } 162 | 163 | // Callback to perform any Rive-related actions after resizing the canvas 164 | // (i.e., reset the Rive layout in the render loop) 165 | if (onCanvasHasResized && (isFirstSizing || hasResized)) { 166 | onCanvasHasResized && onCanvasHasResized(); 167 | } 168 | isFirstSizing && setIsFirstSizing(false); 169 | }, [ 170 | canvasElem, 171 | containerRef, 172 | containerSize, 173 | currentDevicePixelRatio, 174 | getContainerDimensions, 175 | isFirstSizing, 176 | setIsFirstSizing, 177 | lastCanvasHeight, 178 | lastCanvasWidth, 179 | lastContainerHeight, 180 | lastContainerWidth, 181 | onCanvasHasResized, 182 | shouldResizeCanvasToContainer, 183 | fitCanvasToArtboardHeight, 184 | shouldUseDevicePixelRatio, 185 | riveLoaded, 186 | ]); 187 | 188 | // Reset width and height values when the canvas changes 189 | useEffect(() => { 190 | setLastCanvasSize({ 191 | width: 0, 192 | height: 0, 193 | }); 194 | }, [canvasElem]); 195 | } 196 | -------------------------------------------------------------------------------- /src/hooks/useRive.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useCallback, 3 | useEffect, 4 | useRef, 5 | useState, 6 | ComponentProps, 7 | RefCallback, 8 | } from 'react'; 9 | import { Rive, EventType, Fit } from '@rive-app/canvas'; 10 | import { UseRiveParameters, UseRiveOptions, RiveState } from '../types'; 11 | import useResizeCanvas from './useResizeCanvas'; 12 | import useDevicePixelRatio from './useDevicePixelRatio'; 13 | import { getOptions } from '../utils'; 14 | import useIntersectionObserver from './useIntersectionObserver'; 15 | 16 | type RiveComponentProps = { 17 | setContainerRef: RefCallback; 18 | setCanvasRef: RefCallback; 19 | }; 20 | 21 | function RiveComponent({ 22 | setContainerRef, 23 | setCanvasRef, 24 | className = '', 25 | style, 26 | children, 27 | ...rest 28 | }: RiveComponentProps & ComponentProps<'canvas'>) { 29 | const containerStyle = { 30 | width: '100%', 31 | height: '100%', 32 | ...style, 33 | }; 34 | 35 | return ( 36 |
41 | 46 | {children} 47 | 48 |
49 | ); 50 | } 51 | 52 | /** 53 | * Custom Hook for loading a Rive file. 54 | * 55 | * Waits until the load event has fired before returning it. 56 | * We can then listen for changes to this animation in other hooks to detect 57 | * when it has loaded. 58 | * 59 | * @param riveParams - Object containing parameters accepted by the Rive object 60 | * in the rive-js runtime, with the exception of Canvas as that is attached 61 | * via the ref callback `setCanvasRef`. 62 | * 63 | * @param opts - Optional list of options that are specific for this hook. 64 | * @returns {RiveAnimationState} 65 | */ 66 | export default function useRive( 67 | riveParams?: UseRiveParameters, 68 | opts: Partial = {} 69 | ): RiveState { 70 | const [canvasElem, setCanvasElem] = useState(null); 71 | const containerRef = useRef(null); 72 | 73 | const [rive, setRive] = useState(null); 74 | 75 | const isParamsLoaded = Boolean(riveParams); 76 | const options = getOptions(opts); 77 | 78 | const devicePixelRatio = useDevicePixelRatio(); 79 | 80 | /** 81 | * When the canvas/parent container resize, reset the Rive layout to match the 82 | * new (0, 0, canvas.width, canvas.height) bounds in the render loop 83 | */ 84 | const onCanvasHasResized = useCallback(() => { 85 | if (rive) { 86 | if (rive.layout && rive.layout.fit === Fit.Layout) { 87 | if (canvasElem) { 88 | const resizeFactor = devicePixelRatio * rive.layout.layoutScaleFactor; 89 | rive.devicePixelRatioUsed = devicePixelRatio; 90 | rive.artboardWidth = canvasElem?.width / resizeFactor; 91 | rive.artboardHeight = canvasElem?.height / resizeFactor; 92 | } 93 | } 94 | 95 | rive.startRendering(); 96 | rive.resizeToCanvas(); 97 | } 98 | }, [rive, devicePixelRatio]); 99 | 100 | // Watch the canvas parent container resize and size the canvas to match 101 | useResizeCanvas({ 102 | riveLoaded: !!rive, 103 | canvasElem, 104 | containerRef, 105 | options, 106 | onCanvasHasResized, 107 | artboardBounds: rive?.bounds, 108 | }); 109 | 110 | /** 111 | * Ref callback called when the canvas element mounts and unmounts. 112 | */ 113 | const setCanvasRef: RefCallback = useCallback( 114 | (canvas: HTMLCanvasElement | null) => { 115 | if (canvas === null && canvasElem) { 116 | canvasElem.height = 0; 117 | canvasElem.width = 0; 118 | } 119 | 120 | setCanvasElem(canvas); 121 | }, 122 | [] 123 | ); 124 | 125 | useEffect(() => { 126 | if (!canvasElem || !riveParams) { 127 | return; 128 | } 129 | let isLoaded = rive != null; 130 | let r: Rive | null; 131 | if (rive == null) { 132 | const { useOffscreenRenderer } = options; 133 | r = new Rive({ 134 | useOffscreenRenderer, 135 | ...riveParams, 136 | canvas: canvasElem, 137 | }); 138 | r.on(EventType.Load, () => { 139 | isLoaded = true; 140 | // Check if the component/canvas is mounted before setting state to avoid setState 141 | // on an unmounted component in some rare cases 142 | if (canvasElem) { 143 | setRive(r); 144 | } else { 145 | // If unmounted, cleanup the rive object immediately 146 | r!.cleanup(); 147 | } 148 | }); 149 | } 150 | return () => { 151 | if (!isLoaded) { 152 | r?.cleanup(); 153 | } 154 | }; 155 | }, [canvasElem, isParamsLoaded, rive]); 156 | /** 157 | * Ref callback called when the container element mounts 158 | */ 159 | const setContainerRef: RefCallback = useCallback( 160 | (container: HTMLElement | null) => { 161 | containerRef.current = container; 162 | }, 163 | [] 164 | ); 165 | 166 | /** 167 | * Set up IntersectionObserver to stop rendering if the animation is not in 168 | * view. 169 | */ 170 | const { observe, unobserve } = useIntersectionObserver(); 171 | 172 | useEffect(() => { 173 | let timeoutId: ReturnType; 174 | let isPaused = false; 175 | // This is a workaround to retest whether an element is offscreen or not. 176 | // There seems to be a bug in Chrome that triggers an intersection change when an element 177 | // is moved within the DOM using insertBefore. 178 | // For some reason, when this is called whithin the context of a React application, the 179 | // intersection callback is called only once reporting isIntersecting as false but never 180 | // triggered back with isIntersecting as true. 181 | // For this reason we retest after 10 millisecond whether the element is actually off the 182 | // viewport or not. 183 | const retestIntersection = () => { 184 | if (canvasElem && isPaused) { 185 | const size = canvasElem.getBoundingClientRect(); 186 | const isIntersecting = 187 | size.width > 0 && 188 | size.height > 0 && 189 | size.top < 190 | (window.innerHeight || document.documentElement.clientHeight) && 191 | size.bottom > 0 && 192 | size.left < 193 | (window.innerWidth || document.documentElement.clientWidth) && 194 | size.right > 0; 195 | if (isIntersecting) { 196 | rive?.startRendering(); 197 | isPaused = false; 198 | } 199 | } 200 | }; 201 | const onChange = (entry: IntersectionObserverEntry) => { 202 | entry.isIntersecting 203 | ? rive && rive.startRendering() 204 | : rive && rive.stopRendering(); 205 | isPaused = !entry.isIntersecting; 206 | clearTimeout(timeoutId); 207 | if (!entry.isIntersecting && entry.boundingClientRect.width === 0) { 208 | timeoutId = setTimeout(retestIntersection, 10); 209 | } 210 | }; 211 | if (canvasElem && options.shouldUseIntersectionObserver !== false) { 212 | observe(canvasElem, onChange); 213 | } 214 | return () => { 215 | if (canvasElem) { 216 | unobserve(canvasElem); 217 | } 218 | }; 219 | }, [ 220 | observe, 221 | unobserve, 222 | rive, 223 | canvasElem, 224 | options.shouldUseIntersectionObserver, 225 | ]); 226 | 227 | /** 228 | * On unmount, call cleanup to cleanup any WASM generated objects that need 229 | * to be manually destroyed. 230 | */ 231 | useEffect(() => { 232 | return () => { 233 | if (rive) { 234 | rive.cleanup(); 235 | setRive(null); 236 | } 237 | }; 238 | }, [rive, canvasElem]); 239 | 240 | /** 241 | * Listen for changes in the animations params 242 | */ 243 | const animations = riveParams?.animations; 244 | useEffect(() => { 245 | if (rive && animations) { 246 | if (rive.isPlaying) { 247 | rive.stop(rive.animationNames); 248 | rive.play(animations); 249 | } else if (rive.isPaused) { 250 | rive.stop(rive.animationNames); 251 | rive.pause(animations); 252 | } 253 | } 254 | }, [animations, rive]); 255 | 256 | const Component = useCallback( 257 | (props: ComponentProps<'canvas'>): JSX.Element => { 258 | return ( 259 | 264 | ); 265 | }, 266 | [setCanvasRef, setContainerRef] 267 | ); 268 | 269 | return { 270 | canvas: canvasElem, 271 | container: containerRef.current, 272 | setCanvasRef, 273 | setContainerRef, 274 | rive, 275 | RiveComponent: Component, 276 | }; 277 | } 278 | -------------------------------------------------------------------------------- /src/hooks/useRiveFile.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import type { 3 | UseRiveFileParameters, 4 | RiveFileState, 5 | FileStatus, 6 | } from '../types'; 7 | import { EventType, RiveFile } from '@rive-app/canvas'; 8 | 9 | /** 10 | * Custom hook for initializing and managing a RiveFile instance within a component. 11 | * It sets up a RiveFile based on provided source parameters (URL or ArrayBuffer) and ensures 12 | * proper cleanup to avoid memory leaks when the component unmounts or inputs change. 13 | * 14 | * @param params - Object containing parameters accepted by the Rive file in the @rive-app/canvas runtime, 15 | * 16 | * @returns {RiveFileState} Contains the active RiveFile instance (`riveFile`) and the loading status. 17 | */ 18 | function useRiveFile(params: UseRiveFileParameters): RiveFileState { 19 | const [riveFile, setRiveFile] = useState(null); 20 | const [status, setStatus] = useState('idle'); 21 | 22 | useEffect(() => { 23 | let file: RiveFile | null = null; 24 | 25 | const loadRiveFile = async () => { 26 | try { 27 | setStatus('loading'); 28 | file = new RiveFile(params); 29 | file.init(); 30 | file.on(EventType.Load, () => { 31 | // We request an instance to add +1 to the referencesCount so it doesn't get destroyed 32 | // while this hook is active 33 | file?.getInstance(); 34 | setRiveFile(file); 35 | setStatus('success'); 36 | }); 37 | file.on(EventType.LoadError, () => { 38 | setStatus('failed'); 39 | }); 40 | setRiveFile(file); 41 | } catch (error) { 42 | console.error(error); 43 | setStatus('failed'); 44 | } 45 | }; 46 | 47 | loadRiveFile(); 48 | 49 | return () => { 50 | file?.cleanup(); 51 | }; 52 | }, [params.src, params.buffer]); 53 | 54 | return { riveFile, status }; 55 | } 56 | 57 | export default useRiveFile; 58 | -------------------------------------------------------------------------------- /src/hooks/useStateMachineInput.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { EventType, Rive, StateMachineInput } from '@rive-app/canvas'; 3 | 4 | /** 5 | * Custom hook for fetching a stateMachine input from a rive file. 6 | * 7 | * @param rive - Rive instance 8 | * @param stateMachineName - Name of the state machine 9 | * @param inputName - Name of the input 10 | * @returns 11 | */ 12 | export default function useStateMachineInput( 13 | rive: Rive | null, 14 | stateMachineName?: string, 15 | inputName?: string, 16 | initialValue?: number | boolean 17 | ) { 18 | const [input, setInput] = useState(null); 19 | 20 | useEffect(() => { 21 | function setStateMachineInput() { 22 | if (!rive || !stateMachineName || !inputName) { 23 | setInput(null); 24 | } 25 | 26 | if (rive && stateMachineName && inputName) { 27 | const inputs = rive.stateMachineInputs(stateMachineName); 28 | if (inputs) { 29 | const selectedInput = inputs.find( 30 | (input) => input.name === inputName 31 | ); 32 | if (initialValue !== undefined && selectedInput) { 33 | selectedInput.value = initialValue; 34 | } 35 | setInput(selectedInput || null); 36 | } 37 | } else { 38 | setInput(null); 39 | } 40 | } 41 | setStateMachineInput(); 42 | if (rive) { 43 | rive.on(EventType.Load, () => { 44 | // Check if the component/canvas is mounted before setting state to avoid setState 45 | // on an unmounted component in some rare cases 46 | setStateMachineInput(); 47 | }); 48 | } 49 | }, [rive]); 50 | 51 | return input; 52 | } 53 | -------------------------------------------------------------------------------- /src/hooks/useViewModel.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { Rive, ViewModel, EventType } from '@rive-app/canvas'; 3 | import { UseViewModelParameters } from '../types'; 4 | 5 | /** 6 | * Hook for fetching a ViewModel from a Rive instance. 7 | * 8 | * @param rive - The Rive instance to retrieve the ViewModel from 9 | * @param params - Options for retrieving a ViewModel 10 | * @param params.name - When provided, specifies the name of the ViewModel to retrieve 11 | * @param params.useDefault - When true, uses the default ViewModel from the Rive instance 12 | * @returns The ViewModel or null if not found 13 | */ 14 | export default function useViewModel( 15 | rive: Rive | null, 16 | params?: UseViewModelParameters 17 | ): ViewModel | null { 18 | const { name, useDefault = false } = params ?? {}; 19 | const [viewModel, setViewModel] = useState(null); 20 | 21 | useEffect(() => { 22 | function fetchViewModel() { 23 | if (!rive) { 24 | setViewModel(null); 25 | return; 26 | } 27 | 28 | let model: ViewModel | null = null; 29 | 30 | if (name != null) { 31 | model = rive.viewModelByName?.(name) || null; 32 | } else if (useDefault) { 33 | model = rive.defaultViewModel() || null; 34 | } else { 35 | model = rive.defaultViewModel() || null; 36 | } 37 | 38 | setViewModel(model); 39 | } 40 | 41 | fetchViewModel(); 42 | 43 | if (rive) { 44 | rive.on(EventType.Load, fetchViewModel); 45 | } 46 | 47 | return () => { 48 | if (rive) { 49 | rive.off(EventType.Load, fetchViewModel); 50 | } 51 | }; 52 | }, [rive, name, useDefault]); 53 | 54 | return viewModel; 55 | } -------------------------------------------------------------------------------- /src/hooks/useViewModelInstance.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { ViewModel, ViewModelInstance } from '@rive-app/canvas'; 3 | import { UseViewModelInstanceParameters } from '../types'; 4 | 5 | /** 6 | * Hook for fetching a ViewModelInstance from a ViewModel. 7 | * 8 | * @param viewModel - The ViewModel to get an instance from 9 | * @param params - Options for retrieving a ViewModelInstance 10 | * @param params.name - When provided, specifies the name of the instance to retrieve 11 | * @param params.useDefault - When true, uses the default instance from the ViewModel 12 | * @param params.useNew - When true, creates a new instance of the ViewModel 13 | * @param params.rive - If provided, automatically binds the instance to this Rive instance 14 | * @returns The ViewModelInstance or null if not found 15 | */ 16 | export default function useViewModelInstance( 17 | viewModel: ViewModel | null, 18 | params?: UseViewModelInstanceParameters 19 | ): ViewModelInstance | null { 20 | const { name, useDefault = false, useNew = false, rive } = params ?? {}; 21 | const [instance, setInstance] = useState(null); 22 | 23 | useEffect(() => { 24 | if (!viewModel) { 25 | setInstance(null); 26 | return; 27 | } 28 | 29 | let result: ViewModelInstance | null = null; 30 | 31 | if (name != null) { 32 | result = viewModel.instanceByName(name) || null; 33 | } else if (useDefault) { 34 | result = viewModel.defaultInstance?.() || null; 35 | } else if (useNew) { 36 | result = viewModel.instance?.() || null; 37 | } else { 38 | result = viewModel.defaultInstance?.() || null; 39 | } 40 | 41 | setInstance(result); 42 | 43 | if (rive && result && rive.viewModelInstance !== result) { 44 | rive.bindViewModelInstance(result); 45 | } 46 | }, [viewModel, name, useDefault, useNew, rive]); 47 | 48 | return instance; 49 | } -------------------------------------------------------------------------------- /src/hooks/useViewModelInstanceBoolean.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { ViewModelInstanceBoolean, ViewModelInstance } from '@rive-app/canvas'; 3 | import { UseViewModelInstanceBooleanResult } from '../types'; 4 | import { useViewModelInstanceProperty } from './useViewModelInstanceProperty'; 5 | 6 | /** 7 | * Hook for interacting with boolean ViewModel instance properties. 8 | * 9 | * @param path - The path to the boolean property 10 | * @param viewModelInstance - The ViewModelInstance containing the boolean property to operate on 11 | * @returns An object with the boolean value and a setter function 12 | */ 13 | export default function useViewModelInstanceBoolean( 14 | path: string, 15 | viewModelInstance?: ViewModelInstance | null 16 | ): UseViewModelInstanceBooleanResult { 17 | const result = useViewModelInstanceProperty>( 18 | path, 19 | viewModelInstance, 20 | { 21 | getProperty: useCallback((vm, p) => vm.boolean(p), []), 22 | getValue: useCallback((prop) => prop.value, []), 23 | defaultValue: null, 24 | buildPropertyOperations: useCallback((safePropertyAccess) => ({ 25 | setValue: (newValue: boolean) => { 26 | safePropertyAccess(prop => { prop.value = newValue; }); 27 | } 28 | }), []) 29 | } 30 | ); 31 | 32 | return { 33 | value: result.value, 34 | setValue: result.setValue 35 | }; 36 | } -------------------------------------------------------------------------------- /src/hooks/useViewModelInstanceColor.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { ViewModelInstanceColor, ViewModelInstance } from '@rive-app/canvas'; 3 | import { UseViewModelInstanceColorResult } from '../types'; 4 | import { useViewModelInstanceProperty } from './useViewModelInstanceProperty'; 5 | 6 | /** 7 | * Hook for interacting with color properties of a ViewModelInstance. 8 | * 9 | * @param path - Path to the color property 10 | * @param viewModelInstance - The ViewModelInstance containing the color property 11 | * @returns An object with the color value and setter functions for different color formats 12 | */ 13 | export default function useViewModelInstanceColor( 14 | path: string, 15 | viewModelInstance?: ViewModelInstance | null 16 | ): UseViewModelInstanceColorResult { 17 | const result = useViewModelInstanceProperty>( 18 | path, 19 | viewModelInstance, 20 | { 21 | getProperty: useCallback((vm, p) => vm.color(p), []), 22 | getValue: useCallback((prop) => prop.value, []), 23 | defaultValue: null, 24 | buildPropertyOperations: useCallback((safePropertyAccess) => ({ 25 | setValue: (newValue: number) => { 26 | safePropertyAccess(prop => { prop.value = newValue; }); 27 | }, 28 | 29 | setRgb: (r: number, g: number, b: number) => { 30 | safePropertyAccess(prop => { prop.rgb(r, g, b); }); 31 | }, 32 | 33 | setRgba: (r: number, g: number, b: number, a: number) => { 34 | safePropertyAccess(prop => { prop.rgba(r, g, b, a); }); 35 | }, 36 | 37 | setAlpha: (a: number) => { 38 | safePropertyAccess(prop => { prop.alpha(a); }); 39 | }, 40 | 41 | setOpacity: (o: number) => { 42 | safePropertyAccess(prop => { prop.opacity(o); }); 43 | } 44 | }), []) 45 | } 46 | ); 47 | 48 | return { 49 | value: result.value, 50 | setValue: result.setValue, 51 | setRgb: result.setRgb, 52 | setRgba: result.setRgba, 53 | setAlpha: result.setAlpha, 54 | setOpacity: result.setOpacity 55 | }; 56 | } -------------------------------------------------------------------------------- /src/hooks/useViewModelInstanceEnum.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { ViewModelInstance, ViewModelInstanceEnum } from '@rive-app/canvas'; 3 | import { UseViewModelInstanceEnumResult } from '../types'; 4 | import { useViewModelInstanceProperty } from './useViewModelInstanceProperty'; 5 | 6 | /** 7 | * Hook for interacting with enum properties of a ViewModelInstance. 8 | * 9 | * @param params - Parameters for interacting with enum properties 10 | * @param params.path - Path to the enum property (e.g. "state" or "group/state") 11 | * @param params.viewModelInstance - The ViewModelInstance containing the enum property 12 | * @returns An object with the enum value, available values, and a setter function 13 | */ 14 | export default function useViewModelInstanceEnum( 15 | path: string, 16 | viewModelInstance?: ViewModelInstance | null 17 | ): UseViewModelInstanceEnumResult { 18 | const result = useViewModelInstanceProperty< 19 | ViewModelInstanceEnum, 20 | string, 21 | Omit, 22 | string[] 23 | >(path, viewModelInstance, { 24 | getProperty: useCallback((vm, p) => vm.enum(p), []), 25 | getValue: useCallback((prop) => prop.value, []), 26 | defaultValue: null, 27 | getExtendedData: useCallback((prop: any) => prop.values, []), 28 | buildPropertyOperations: useCallback( 29 | (safePropertyAccess) => ({ 30 | setValue: (newValue: string) => { 31 | safePropertyAccess((prop) => { 32 | prop.value = newValue; 33 | }); 34 | }, 35 | }), 36 | [] 37 | ), 38 | }); 39 | 40 | return { 41 | value: result.value, 42 | values: result.extendedData || [], 43 | setValue: result.setValue, 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/hooks/useViewModelInstanceNumber.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { ViewModelInstance, ViewModelInstanceNumber } from '@rive-app/canvas'; 3 | import { UseViewModelInstanceNumberResult } from '../types'; 4 | import { useViewModelInstanceProperty } from './useViewModelInstanceProperty'; 5 | 6 | /** 7 | * Hook for interacting with number properties of a ViewModelInstance. 8 | * 9 | * @param params - Parameters for interacting with number properties 10 | * @param params.path - Path to the number property (e.g. "speed" or "group/speed") 11 | * @param params.viewModelInstance - The ViewModelInstance containing the number property 12 | * @returns An object with the number value and a setter function 13 | */ 14 | export default function useViewModelInstanceNumber( 15 | path: string, 16 | viewModelInstance?: ViewModelInstance | null 17 | ): UseViewModelInstanceNumberResult { 18 | const result = useViewModelInstanceProperty>( 19 | path, 20 | viewModelInstance, 21 | { 22 | getProperty: useCallback((vm, p) => vm.number(p), []), 23 | getValue: useCallback((prop) => prop.value, []), 24 | defaultValue: null, 25 | buildPropertyOperations: useCallback((safePropertyAccess) => ({ 26 | setValue: (newValue: number) => { 27 | safePropertyAccess(prop => { prop.value = newValue; }); 28 | } 29 | }), []) 30 | } 31 | ); 32 | 33 | return { 34 | value: result.value, 35 | setValue: result.setValue 36 | }; 37 | } -------------------------------------------------------------------------------- /src/hooks/useViewModelInstanceProperty.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; 2 | import { ViewModelInstance, ViewModelInstanceValue } from '@rive-app/canvas'; 3 | 4 | /** 5 | * Base hook for all ViewModelInstance property interactions. 6 | * 7 | * This hook handles the common tasks needed when working with Rive properties: 8 | * 1. Safely accessing properties (even during hot-reload) 9 | * 2. Keeping React state in sync with property changes 10 | * 3. Providing type safety for all operations 11 | * 12 | * @param path - Property path in the ViewModelInstance 13 | * @param viewModelInstance - The source ViewModelInstance 14 | * @param options - Configuration for working with the property 15 | * @returns Object with the value and operations 16 | */ 17 | export function useViewModelInstanceProperty

( 18 | path: string, 19 | viewModelInstance: ViewModelInstance | null | undefined, 20 | options: { 21 | /** Function to get the property from a ViewModelInstance */ 22 | getProperty: (vm: ViewModelInstance, path: string) => P | null; 23 | 24 | /** Function to get the current value from the property */ 25 | getValue: (prop: P) => V; 26 | 27 | /** Default value to use when property is unavailable */ 28 | defaultValue: V | null; 29 | 30 | /** 31 | * Function to create the property-specific operations 32 | * 33 | * @param safePropertyAccess - Helper function for safely working with properties. Handles stale property references. 34 | * @returns Object with operations like setValue, trigger, etc. 35 | */ 36 | buildPropertyOperations: (safePropertyAccess: (callback: (prop: P) => void) => void) => R; 37 | 38 | /** Optional callback for property events (mainly used by triggers) */ 39 | onPropertyEvent?: () => void; 40 | 41 | /** 42 | * Optional function to extract additional property data (like enum values) 43 | * Returns undefined if not provided 44 | */ 45 | getExtendedData?: (prop: P) => E; 46 | } 47 | ): R & { value: V | null } & (E extends undefined ? {} : { extendedData: E | null }) { 48 | const [property, setProperty] = useState

(null); 49 | const [value, setValue] = useState(options.defaultValue); 50 | const [extendedData, setExtendedData] = useState(null); 51 | 52 | const instanceRef = useRef(null); 53 | const pathRef = useRef(path); 54 | const optionsRef = useRef(options); 55 | 56 | useEffect(() => { 57 | optionsRef.current = options; 58 | }, [options]); 59 | 60 | const updateProperty = useCallback(() => { 61 | const currentInstance = instanceRef.current; 62 | const currentPath = pathRef.current; 63 | const currentOptions = optionsRef.current; 64 | 65 | if (!currentInstance || !currentPath) { 66 | setProperty(null); 67 | setValue(currentOptions.defaultValue); 68 | setExtendedData(null); 69 | return () => { }; 70 | } 71 | 72 | const prop = currentOptions.getProperty(currentInstance, currentPath); 73 | if (prop) { 74 | setProperty(prop); 75 | setValue(currentOptions.getValue(prop)); 76 | 77 | if (currentOptions.getExtendedData) { 78 | setExtendedData(currentOptions.getExtendedData(prop)); 79 | } 80 | 81 | const handleChange = () => { 82 | setValue(currentOptions.getValue(prop)); 83 | 84 | if (currentOptions.getExtendedData) { 85 | setExtendedData(currentOptions.getExtendedData(prop)); 86 | } 87 | 88 | if (currentOptions.onPropertyEvent) { 89 | currentOptions.onPropertyEvent(); 90 | } 91 | }; 92 | 93 | prop.on(handleChange); 94 | 95 | return () => { 96 | prop.off(handleChange); 97 | }; 98 | } 99 | 100 | return () => { }; 101 | }, []); 102 | 103 | useEffect(() => { 104 | instanceRef.current = viewModelInstance; 105 | pathRef.current = path; 106 | 107 | // subscribe & get our unsubscribe function 108 | const cleanup = updateProperty(); 109 | return cleanup; 110 | }, [viewModelInstance, path, updateProperty]); 111 | 112 | /** 113 | * Helper function that safely accesses properties, even during hot-reload. 114 | * 115 | * It tries to: 116 | * 1. Use the existing property reference when possible 117 | * 2. Fetch a fresh reference when needed 118 | * 3. Apply the callback to whichever reference works 119 | */ 120 | const safePropertyAccess = useCallback( 121 | (callback: (prop: P) => void) => { 122 | // Try the fast path first 123 | if (property && instanceRef.current === viewModelInstance) { 124 | try { 125 | callback(property); 126 | 127 | // Update extended data after callback if available 128 | if (optionsRef.current.getExtendedData) { 129 | setExtendedData(optionsRef.current.getExtendedData(property)); 130 | } 131 | return; 132 | } catch (e) { 133 | // Property might be stale - so we silently catch and try alternative 134 | // This commonly happens during hot module replacement 135 | } 136 | } 137 | 138 | // Get a fresh property if needed 139 | if (instanceRef.current) { 140 | try { 141 | const freshProp = optionsRef.current.getProperty(instanceRef.current, pathRef.current); 142 | if (freshProp) { 143 | setProperty(freshProp); 144 | callback(freshProp); 145 | 146 | // Update extended data after callback if available 147 | if (optionsRef.current.getExtendedData) { 148 | setExtendedData(optionsRef.current.getExtendedData(freshProp)); 149 | } 150 | } 151 | } catch (e) { 152 | // Silently fail during hot-reload - this is expected behavior 153 | // We don't want to crash the app during development 154 | } 155 | } 156 | }, 157 | [property, viewModelInstance] 158 | ); 159 | 160 | const operations = useMemo( 161 | () => optionsRef.current.buildPropertyOperations(safePropertyAccess), 162 | [safePropertyAccess] 163 | ); 164 | 165 | const result = { 166 | value, 167 | ...operations 168 | } as R & { value: V | null } & (E extends undefined ? {} : { extendedData: E | null }); 169 | 170 | if (options.getExtendedData) { 171 | (result as any).extendedData = extendedData; 172 | } 173 | 174 | return result; 175 | } -------------------------------------------------------------------------------- /src/hooks/useViewModelInstanceString.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { ViewModelInstance, ViewModelInstanceString } from '@rive-app/canvas'; 3 | import { UseViewModelInstanceStringResult } from '../types'; 4 | import { useViewModelInstanceProperty } from './useViewModelInstanceProperty'; 5 | 6 | /** 7 | * Hook for interacting with string properties of a ViewModelInstance. 8 | * 9 | * @param params - Parameters for interacting with string properties 10 | * @param params.path - Path to the property (e.g. "text" or "nested/text") 11 | * @param params.viewModelInstance - The ViewModelInstance containing the string property 12 | * @returns An object with the string value and a setter function 13 | */ 14 | export default function useViewModelInstanceString( 15 | path: string, 16 | viewModelInstance?: ViewModelInstance | null 17 | ): UseViewModelInstanceStringResult { 18 | 19 | const result = useViewModelInstanceProperty>( 20 | path, 21 | viewModelInstance, 22 | { 23 | getProperty: useCallback((vm, p) => vm.string(p), []), 24 | getValue: useCallback((prop) => prop.value, []), 25 | defaultValue: null, 26 | buildPropertyOperations: useCallback((safePropertyAccess) => ({ 27 | setValue: (newValue: string) => { 28 | safePropertyAccess(prop => { prop.value = newValue; }); 29 | } 30 | }), []) 31 | } 32 | ); 33 | 34 | return { 35 | value: result.value, 36 | setValue: result.setValue 37 | }; 38 | } -------------------------------------------------------------------------------- /src/hooks/useViewModelInstanceTrigger.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { ViewModelInstance, ViewModelInstanceTrigger } from '@rive-app/canvas'; 3 | import { UseViewModelInstanceTriggerParameters, UseViewModelInstanceTriggerResult } from '../types'; 4 | import { useViewModelInstanceProperty } from './useViewModelInstanceProperty'; 5 | 6 | /** 7 | * Hook for interacting with trigger properties of a ViewModelInstance. 8 | * 9 | * @param params - Parameters for interacting with trigger properties 10 | * @param params.path - Path to the trigger property (e.g. "onTap" or "group/onTap") 11 | * @param params.viewModelInstance - The ViewModelInstance containing the trigger property 12 | * @param params.onTrigger - Callback that runs when the trigger is fired 13 | * @returns An object with a trigger function 14 | */ 15 | export default function useViewModelInstanceTrigger( 16 | path: string, 17 | viewModelInstance?: ViewModelInstance | null, 18 | params?: UseViewModelInstanceTriggerParameters 19 | ): UseViewModelInstanceTriggerResult { 20 | const { onTrigger } = params ?? {}; 21 | 22 | const { trigger } = useViewModelInstanceProperty( 23 | path, 24 | viewModelInstance, 25 | { 26 | getProperty: useCallback((vm, p) => vm.trigger(p), []), 27 | getValue: useCallback(() => undefined, []), // Triggers don't have a 'value' 28 | defaultValue: null, 29 | onPropertyEvent: onTrigger, 30 | buildPropertyOperations: useCallback((safePropertyAccess) => ({ 31 | trigger: () => { 32 | 33 | safePropertyAccess(prop => { 34 | prop.trigger(); 35 | }); 36 | } 37 | }), []) 38 | } 39 | ); 40 | 41 | return { trigger }; 42 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Rive, { RiveProps } from './components/Rive'; 2 | import useRive from './hooks/useRive'; 3 | import useStateMachineInput from './hooks/useStateMachineInput'; 4 | import useViewModel from './hooks/useViewModel'; 5 | import useViewModelInstance from './hooks/useViewModelInstance'; 6 | import useViewModelInstanceNumber from './hooks/useViewModelInstanceNumber'; 7 | import useViewModelInstanceString from './hooks/useViewModelInstanceString'; 8 | import useViewModelInstanceBoolean from './hooks/useViewModelInstanceBoolean'; 9 | import useViewModelInstanceColor from './hooks/useViewModelInstanceColor'; 10 | import useViewModelInstanceEnum from './hooks/useViewModelInstanceEnum'; 11 | import useViewModelInstanceTrigger from './hooks/useViewModelInstanceTrigger'; 12 | import useResizeCanvas from './hooks/useResizeCanvas'; 13 | import useRiveFile from './hooks/useRiveFile'; 14 | 15 | export default Rive; 16 | export { 17 | useRive, 18 | useStateMachineInput, 19 | useResizeCanvas, 20 | useRiveFile, 21 | useViewModel, 22 | useViewModelInstance, 23 | useViewModelInstanceNumber, 24 | useViewModelInstanceString, 25 | useViewModelInstanceBoolean, 26 | useViewModelInstanceColor, 27 | useViewModelInstanceEnum, 28 | useViewModelInstanceTrigger, 29 | RiveProps, 30 | }; 31 | export { 32 | RiveState, 33 | UseRiveParameters, 34 | UseRiveFileParameters, 35 | UseRiveOptions, 36 | } from './types'; 37 | export * from '@rive-app/canvas'; -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Rive, 3 | RiveFile, 4 | RiveFileParameters, 5 | RiveParameters, 6 | } from '@rive-app/canvas'; 7 | import { ComponentProps, RefCallback } from 'react'; 8 | 9 | export type UseRiveParameters = Partial> | null; 10 | 11 | export type UseRiveOptions = { 12 | useDevicePixelRatio: boolean; 13 | customDevicePixelRatio: number; 14 | fitCanvasToArtboardHeight: boolean; 15 | useOffscreenRenderer: boolean; 16 | shouldResizeCanvasToContainer: boolean; 17 | shouldUseIntersectionObserver?: boolean; 18 | }; 19 | 20 | export type Dimensions = { 21 | width: number; 22 | height: number; 23 | }; 24 | 25 | /** 26 | * @typedef RiveState 27 | * @property canvas - Canvas element the Rive Animation is attached to. 28 | * @property container - Container element of the canvas. 29 | * @property setCanvasRef - Ref callback to be passed to the canvas element. 30 | * @property setContainerRef - Ref callback to be passed to the container 31 | * element of the canvas. This is optional, however if not used then the hook 32 | * will not take care of automatically resizing the canvas to it's outer 33 | * container if the window resizes. 34 | * @property rive - The loaded Rive Animation 35 | */ 36 | export type RiveState = { 37 | canvas: HTMLCanvasElement | null; 38 | container: HTMLElement | null; 39 | setCanvasRef: RefCallback; 40 | setContainerRef: RefCallback; 41 | rive: Rive | null; 42 | RiveComponent: (props: ComponentProps<'canvas'>) => JSX.Element; 43 | }; 44 | 45 | export type UseRiveFileParameters = Partial< 46 | Omit 47 | >; 48 | 49 | export type FileStatus = 'idle' | 'loading' | 'failed' | 'success'; 50 | 51 | /** 52 | * @typedef RiveFileState 53 | * @property data - The RiveFile instance 54 | * @property status - The status of the file 55 | */ 56 | export type RiveFileState = { 57 | riveFile: RiveFile | null; 58 | status: FileStatus; 59 | }; 60 | 61 | /** 62 | * Parameters for useViewModel hook. 63 | * 64 | * @property name - When provided, specifies the name of the ViewModel to retrieve. 65 | * @property useDefault - When true, uses the default ViewModel from the Rive instance. 66 | */ 67 | export type UseViewModelParameters = 68 | | { name: string; useDefault?: never } 69 | | { useDefault?: boolean; name?: never }; 70 | 71 | /** 72 | * Parameters for useViewModelInstance hook. 73 | * 74 | * @property name - When provided, specifies the name of the instance to retrieve. 75 | * @property useDefault - When true, uses the default instance from the ViewModel. 76 | * @property useNew - When true, creates a new instance of the ViewModel. 77 | * @property rive - If provided, automatically binds the instance to this Rive instance. 78 | */ 79 | export type UseViewModelInstanceParameters = 80 | | { name: string; useDefault?: never; useNew?: never; rive?: Rive | null } 81 | | { useDefault?: boolean; name?: never; useNew?: never; rive?: Rive | null } 82 | | { useNew?: boolean; name?: never; useDefault?: never; rive?: Rive | null }; 83 | 84 | 85 | 86 | /** 87 | * Parameters for interacting with trigger properties of a ViewModelInstance 88 | * @property onTrigger - Callback that runs when the trigger fires 89 | */ 90 | export type UseViewModelInstanceTriggerParameters = { 91 | onTrigger?: () => void; 92 | }; 93 | 94 | 95 | 96 | export type UseViewModelInstanceNumberResult = { 97 | /** 98 | * The current value of the number. 99 | */ 100 | value: number | null; 101 | /** 102 | * Set the value of the number. 103 | * @param value - The value to set the number to. 104 | */ 105 | setValue: (value: number) => void; 106 | }; 107 | export type UseViewModelInstanceStringResult = { 108 | /** 109 | * The current value of the string. 110 | */ 111 | value: string | null; 112 | /** 113 | * Set the value of the string. 114 | * @param value - The value to set the string to. 115 | */ 116 | setValue: (value: string) => void; 117 | }; 118 | export type UseViewModelInstanceBooleanResult = { 119 | /** 120 | * The current value of the boolean. 121 | */ 122 | value: boolean | null; 123 | /** 124 | * Set the value of the boolean. 125 | * @param value - The value to set the boolean to. 126 | */ 127 | setValue: (value: boolean) => void; 128 | }; 129 | 130 | export type UseViewModelInstanceColorResult = { 131 | /** 132 | * The current value of the color. 133 | */ 134 | value: number | null; 135 | /** 136 | * Set the value of the color. 137 | * @param value - The value to set the color to. 138 | */ 139 | setValue: (value: number) => void; 140 | /** 141 | * Set the red value of the color. 142 | * @param r - The red value to set the color to. 143 | */ 144 | setRgb: (r: number, g: number, b: number) => void; 145 | /** 146 | * Set the red, green, blue, and alpha values of the color. 147 | * @param r - The red value to set the color to. 148 | * @param g - The green value to set the color to. 149 | * @param b - The blue value to set the color to. 150 | * @param a - The alpha value to set the color to. 151 | */ 152 | setRgba: (r: number, g: number, b: number, a: number) => void; 153 | /** 154 | * Set the alpha value of the color. 155 | * @param a - The alpha value to set the color to. 156 | */ 157 | setAlpha: (a: number) => void; 158 | /** 159 | * Set the opacity value of the color. 160 | * @param o - The opacity value to set the color to. 161 | */ 162 | setOpacity: (o: number) => void; 163 | }; 164 | 165 | export type UseViewModelInstanceEnumResult = { 166 | /** 167 | * The current value of the enum. 168 | */ 169 | value: string | null; 170 | /** 171 | * Set the value of the enum. 172 | * @param value - The value to set the enum to. 173 | */ 174 | setValue: (value: string) => void; 175 | /** 176 | * The values of the enum. 177 | */ 178 | values: string[]; 179 | }; 180 | 181 | export type UseViewModelInstanceTriggerResult = { 182 | /** 183 | * Fires the property trigger. 184 | */ 185 | trigger: () => void; 186 | }; -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { UseRiveOptions } from './types'; 2 | 3 | const defaultOptions = { 4 | useDevicePixelRatio: true, 5 | fitCanvasToArtboardHeight: false, 6 | useOffscreenRenderer: true, 7 | shouldResizeCanvasToContainer: true, 8 | }; 9 | 10 | export function getOptions(opts: Partial) { 11 | return Object.assign({}, defaultOptions, opts); 12 | } 13 | -------------------------------------------------------------------------------- /test/Rive.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import RiveComponent from '../src/components/Rive'; 3 | import { render } from '@testing-library/react'; 4 | 5 | jest.mock('@rive-app/canvas', () => ({ 6 | Rive: jest.fn().mockImplementation(() => ({ 7 | on: jest.fn(), 8 | stop: jest.fn(), 9 | cleanup: jest.fn(), 10 | })), 11 | Layout: jest.fn(), 12 | Fit: { 13 | Cover: 'cover', 14 | }, 15 | Alignment: { 16 | Center: 'center', 17 | }, 18 | EventType: { 19 | Load: 'load', 20 | }, 21 | StateMachineInputType: { 22 | Number: 1, 23 | Boolean: 2, 24 | Trigger: 3, 25 | }, 26 | })); 27 | 28 | describe('Rive Component', () => { 29 | it('renders the component as a canvas and a div wrapper', () => { 30 | const { container, getByLabelText } = render( 31 | 36 | ); 37 | expect(container.firstChild).toHaveClass('container-styles'); 38 | expect(getByLabelText('Foo label').tagName).toEqual('CANVAS'); 39 | }); 40 | 41 | it('allows children to render in the canvas body', () => { 42 | const accessibleFallbackText = 'An animated test'; 43 | const { getByText } = render( 44 | 49 |

{accessibleFallbackText}

50 |
51 | ); 52 | 53 | expect(getByText(accessibleFallbackText)).not.toBeNull(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/elementObserver.test.tsx: -------------------------------------------------------------------------------- 1 | // TODO move this 2 | const observe = jest.fn(); 3 | const unobserve = jest.fn(); 4 | const disconnect = jest.fn(); 5 | 6 | jest.spyOn(globalThis, 'IntersectionObserver').mockImplementation(() => { 7 | return { 8 | observe, 9 | unobserve, 10 | disconnect, 11 | root: null, 12 | thresholds: [], 13 | rootMargin: '', 14 | takeRecords: () => [], 15 | }; 16 | }); 17 | 18 | import ElementObserver from '../src/hooks/elementObserver'; 19 | 20 | describe('elementObserver', () => { 21 | it('registers a callback and observes the element', () => { 22 | const observer = new ElementObserver(); 23 | const element = document.createElement('li'); 24 | observer.registerCallback(element, ()=>{}); 25 | expect(observe).toHaveBeenCalled(); 26 | expect(observe).toHaveBeenCalledWith(element); 27 | }); 28 | 29 | it('unregisters a callback and unobserves the element', () => { 30 | const observer = new ElementObserver(); 31 | const element = document.createElement('li'); 32 | observer.removeCallback(element); 33 | expect(unobserve).toHaveBeenCalled(); 34 | expect(unobserve).toHaveBeenCalledWith(element); 35 | }); 36 | 37 | }); 38 | 39 | jest.clearAllMocks(); 40 | -------------------------------------------------------------------------------- /test/useIntersectionObserver.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react'; 2 | import ElementObserver from '../src/hooks/elementObserver'; 3 | jest.mock('../src/hooks/elementObserver'); 4 | 5 | import useIntersectionObserver from '../src/hooks/useIntersectionObserver'; 6 | 7 | describe('useIntersectionObserver', () => { 8 | it('returns an object on initialization', () => { 9 | const { result } = renderHook(() => useIntersectionObserver()); 10 | expect(result.current).toBeDefined(); 11 | }); 12 | 13 | it('registers a callback', () => { 14 | const { result } = renderHook(() => useIntersectionObserver()); 15 | const element = document.createElement('li'); 16 | const callback = () => {}; 17 | act(() => { 18 | result.current.observe(element, callback); 19 | }); 20 | const mockElementObserver = (ElementObserver as jest.Mock).mock 21 | .instances[0]; 22 | const registerCallback = mockElementObserver.registerCallback; 23 | expect(registerCallback.mock.calls.length).toBe(1); 24 | expect(registerCallback.mock.calls[0].length).toBe(2); 25 | expect(registerCallback.mock.calls[0][0]).toBe(element); 26 | expect(registerCallback.mock.calls[0][1]).toBe(callback); 27 | }); 28 | 29 | it('unregisters a callback', () => { 30 | const { result } = renderHook(() => useIntersectionObserver()); 31 | const element = document.createElement('li'); 32 | act(() => { 33 | result.current.unobserve(element); 34 | }); 35 | const mockElementObserver = (ElementObserver as jest.Mock).mock 36 | .instances[0]; 37 | const removeCallback = mockElementObserver.removeCallback; 38 | expect(removeCallback.mock.calls.length).toBe(1); 39 | expect(removeCallback.mock.calls[0].length).toBe(1); 40 | expect(removeCallback.mock.calls[0][0]).toBe(element); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/useRive.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mocked } from 'jest-mock'; 3 | import { renderHook, act, render, waitFor } from '@testing-library/react'; 4 | 5 | import useRive from '../src/hooks/useRive'; 6 | import * as rive from '@rive-app/canvas'; 7 | 8 | jest.mock('@rive-app/canvas', () => ({ 9 | Rive: jest.fn().mockImplementation(() => ({ 10 | on: jest.fn(), 11 | stop: jest.fn(), 12 | })), 13 | Layout: jest.fn(), 14 | Fit: { 15 | Cover: 'cover', 16 | }, 17 | Alignment: { 18 | Center: 'center', 19 | }, 20 | EventType: { 21 | Load: 'load', 22 | }, 23 | StateMachineInputType: { 24 | Number: 1, 25 | Boolean: 2, 26 | Trigger: 3, 27 | }, 28 | })); 29 | 30 | describe('useRive', () => { 31 | let controlledRiveloadCb: () => void; 32 | let baseRiveMock: Partial; 33 | 34 | beforeEach(() => { 35 | baseRiveMock = { 36 | on: (_: rive.EventType, cb: rive.EventCallback) => 37 | ((controlledRiveloadCb as rive.EventCallback) = cb), 38 | stop: jest.fn(), 39 | stopRendering: jest.fn(), 40 | startRendering: jest.fn(), 41 | cleanup: jest.fn(), 42 | resizeToCanvas: jest.fn(), 43 | }; 44 | }); 45 | 46 | afterEach(() => { 47 | controlledRiveloadCb = () => {}; 48 | }); 49 | 50 | it('returns rive as null if no params are passed', () => { 51 | const { result } = renderHook(() => useRive()); 52 | expect(result.current.rive).toBe(null); 53 | expect(result.current.canvas).toBe(null); 54 | }); 55 | 56 | it('returns a rive object if the src object is set on the rive params and setCanvas is called', async () => { 57 | const params = { 58 | src: 'file-src', 59 | }; 60 | 61 | // @ts-ignore 62 | mocked(rive.Rive).mockImplementation(() => baseRiveMock); 63 | 64 | const canvasSpy = document.createElement('canvas'); 65 | const { result } = renderHook(() => useRive(params)); 66 | 67 | await act(async () => { 68 | result.current.setCanvasRef(canvasSpy); 69 | }); 70 | await waitFor(() => { 71 | expect(result.current.canvas).toBe(canvasSpy); 72 | }); 73 | await act(async () => { 74 | controlledRiveloadCb(); 75 | }); 76 | expect(result.current.rive).toBe(baseRiveMock); 77 | expect(result.current.canvas).toBe(canvasSpy); 78 | }); 79 | 80 | it('updates the bounds if the container ref is set', async () => { 81 | const params = { 82 | src: 'file-src', 83 | }; 84 | 85 | const resizeToCanvasMock = jest.fn(); 86 | 87 | const riveMock = { 88 | ...baseRiveMock, 89 | resizeToCanvas: resizeToCanvasMock, 90 | }; 91 | 92 | // @ts-ignore 93 | mocked(rive.Rive).mockImplementation(() => riveMock); 94 | 95 | const canvasSpy = document.createElement('canvas'); 96 | const containerSpy = document.createElement('div'); 97 | const { result } = renderHook(() => useRive(params)); 98 | 99 | await act(async () => { 100 | result.current.setCanvasRef(canvasSpy); 101 | result.current.setContainerRef(containerSpy); 102 | }); 103 | await waitFor(() => { 104 | expect(result.current.canvas).toBe(canvasSpy); 105 | }); 106 | await act(async () => { 107 | controlledRiveloadCb(); 108 | }); 109 | await act(async () => { 110 | jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(500); 111 | jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(500); 112 | containerSpy.dispatchEvent(new Event('resize')); 113 | }); 114 | 115 | expect(result.current.rive).toBe(riveMock); 116 | expect(result.current.canvas).toBe(canvasSpy); 117 | 118 | expect(resizeToCanvasMock).toBeCalled(); 119 | }); 120 | 121 | it('calls cleanup on the rive object on unmount', async () => { 122 | const params = { 123 | src: 'file-src', 124 | }; 125 | 126 | const cleanupMock = jest.fn(); 127 | 128 | const riveMock = { 129 | ...baseRiveMock, 130 | cleanup: cleanupMock, 131 | }; 132 | 133 | // @ts-ignore 134 | mocked(rive.Rive).mockImplementation(() => riveMock); 135 | 136 | const canvasSpy = document.createElement('canvas'); 137 | const { result, unmount } = renderHook(() => useRive(params)); 138 | 139 | await act(async () => { 140 | result.current.setCanvasRef(canvasSpy); 141 | }); 142 | await waitFor(() => { 143 | expect(result.current.canvas).toBe(canvasSpy); 144 | }); 145 | await act(async () => { 146 | controlledRiveloadCb(); 147 | }); 148 | 149 | unmount(); 150 | 151 | expect(cleanupMock).toBeCalled(); 152 | }); 153 | 154 | it('sets the bounds with the devicePixelRatio by default', async () => { 155 | const params = { 156 | src: 'file-src', 157 | }; 158 | 159 | global.devicePixelRatio = 2; 160 | 161 | // @ts-ignore 162 | mocked(rive.Rive).mockImplementation(() => baseRiveMock); 163 | 164 | const canvasSpy = document.createElement('canvas'); 165 | const containerSpy = document.createElement('div'); 166 | jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(100); 167 | jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(100); 168 | 169 | const { result } = renderHook(() => useRive(params)); 170 | 171 | await act(async () => { 172 | result.current.setCanvasRef(canvasSpy); 173 | result.current.setContainerRef(containerSpy); 174 | }); 175 | await waitFor(() => { 176 | expect(result.current.canvas).toBe(canvasSpy); 177 | }); 178 | await act(async () => { 179 | controlledRiveloadCb(); 180 | }); 181 | 182 | // Height and width should be 2* the width and height returned from containers 183 | // bounding rect 184 | expect(canvasSpy).toHaveAttribute('height', '200'); 185 | expect(canvasSpy).toHaveAttribute('width', '200'); 186 | 187 | // Style height and width should be the same as returned from containers 188 | // bounding rect 189 | expect(canvasSpy).toHaveAttribute('style', 'width: 100px; height: 100px;'); 190 | }); 191 | 192 | it('sets the bounds with a specified customDevicePixelRatio if one is set', async () => { 193 | const params = { 194 | src: 'file-src', 195 | }; 196 | 197 | global.devicePixelRatio = 2; 198 | 199 | // @ts-ignore 200 | mocked(rive.Rive).mockImplementation(() => baseRiveMock); 201 | 202 | const canvasSpy = document.createElement('canvas'); 203 | const containerSpy = document.createElement('div'); 204 | jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(100); 205 | jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(100); 206 | 207 | const { result } = renderHook(() => 208 | useRive(params, { customDevicePixelRatio: 1 }) 209 | ); 210 | 211 | await act(async () => { 212 | result.current.setCanvasRef(canvasSpy); 213 | result.current.setContainerRef(containerSpy); 214 | }); 215 | await waitFor(() => { 216 | expect(result.current.canvas).toBe(canvasSpy); 217 | }); 218 | await act(async () => { 219 | controlledRiveloadCb(); 220 | }); 221 | 222 | // Height and width should be 2* the width and height returned from containers 223 | // bounding rect 224 | expect(canvasSpy).toHaveAttribute('height', '100'); 225 | expect(canvasSpy).toHaveAttribute('width', '100'); 226 | 227 | // Style height and width should be the same as returned from containers 228 | // bounding rect 229 | expect(canvasSpy).toHaveAttribute('style', 'width: 100px; height: 100px;'); 230 | }); 231 | 232 | it('sets the a bounds without the devicePixelRatio if useDevicePixelRatio is false', async () => { 233 | const params = { 234 | src: 'file-src', 235 | }; 236 | const opts = { 237 | useDevicePixelRatio: false, 238 | }; 239 | 240 | // @ts-ignore 241 | mocked(rive.Rive).mockImplementation(() => baseRiveMock); 242 | 243 | const canvasSpy = document.createElement('canvas'); 244 | const containerSpy = document.createElement('div'); 245 | jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(100); 246 | jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(100); 247 | 248 | const { result } = renderHook(() => useRive(params, opts)); 249 | 250 | await act(async () => { 251 | result.current.setCanvasRef(canvasSpy); 252 | result.current.setContainerRef(containerSpy); 253 | }); 254 | await waitFor(() => { 255 | expect(result.current.canvas).toBe(canvasSpy); 256 | }); 257 | await act(async () => { 258 | controlledRiveloadCb(); 259 | }); 260 | 261 | // Height and width should be same as containers bounding rect 262 | expect(canvasSpy).toHaveAttribute('height', '100'); 263 | expect(canvasSpy).toHaveAttribute('width', '100'); 264 | }); 265 | 266 | it('uses artboard height to set bounds if fitCanvasToArtboardHeight is true', async () => { 267 | const params = { 268 | src: 'file-src', 269 | }; 270 | const opts = { 271 | useDevicePixelRatio: false, 272 | fitCanvasToArtboardHeight: true, 273 | }; 274 | 275 | const riveMock = { 276 | ...baseRiveMock, 277 | bounds: { 278 | maxX: 100, 279 | maxY: 50, 280 | }, 281 | }; 282 | 283 | // @ts-ignore 284 | mocked(rive.Rive).mockImplementation(() => riveMock); 285 | 286 | const canvasSpy = document.createElement('canvas'); 287 | const containerSpy = document.createElement('div'); 288 | jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(100); 289 | jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(100); 290 | 291 | const { result } = renderHook(() => useRive(params, opts)); 292 | 293 | await act(async () => { 294 | result.current.setContainerRef(containerSpy); 295 | result.current.setCanvasRef(canvasSpy); 296 | }); 297 | await waitFor(() => { 298 | expect(result.current.canvas).toBe(canvasSpy); 299 | }); 300 | await act(async () => { 301 | controlledRiveloadCb(); 302 | }); 303 | 304 | // Height and width should be same as containers bounding rect 305 | expect(canvasSpy).toHaveAttribute('height', '50'); 306 | expect(canvasSpy).toHaveAttribute('width', '100'); 307 | 308 | // Container should have style set to height 309 | expect(containerSpy).toHaveAttribute('style', 'height: 50px;'); 310 | }); 311 | 312 | it('updates the playing animations when the animations param changes', async () => { 313 | const params = { 314 | src: 'file-src', 315 | animations: 'light', 316 | }; 317 | 318 | const playMock = jest.fn(); 319 | const stopMock = jest.fn(); 320 | 321 | const riveMock = { 322 | ...baseRiveMock, 323 | stop: stopMock, 324 | play: playMock, 325 | animationNames: ['light'], 326 | isPlaying: true, 327 | }; 328 | 329 | // @ts-ignore 330 | mocked(rive.Rive).mockImplementation(() => riveMock); 331 | 332 | const canvasSpy = document.createElement('canvas'); 333 | 334 | const { result, rerender } = renderHook((params) => useRive(params), { 335 | initialProps: params, 336 | }); 337 | 338 | await act(async () => { 339 | result.current.setCanvasRef(canvasSpy); 340 | }); 341 | await waitFor(() => { 342 | expect(result.current.canvas).toBe(canvasSpy); 343 | }); 344 | await act(async () => { 345 | controlledRiveloadCb(); 346 | }); 347 | 348 | rerender({ 349 | src: 'file-src', 350 | animations: 'dark', 351 | }); 352 | 353 | expect(stopMock).toBeCalledWith(['light']); 354 | expect(playMock).toBeCalledWith('dark'); 355 | }); 356 | 357 | it('updates the paused animation when the animations param changes if the animation is paused', async () => { 358 | const params = { 359 | src: 'file-src', 360 | animations: 'light', 361 | }; 362 | 363 | const playMock = jest.fn(); 364 | const pauseMock = jest.fn(); 365 | const stopMock = jest.fn(); 366 | 367 | const riveMock = { 368 | ...baseRiveMock, 369 | stop: stopMock, 370 | play: playMock, 371 | pause: pauseMock, 372 | animationNames: ['light'], 373 | isPlaying: false, 374 | isPaused: true, 375 | }; 376 | 377 | // @ts-ignore 378 | mocked(rive.Rive).mockImplementation(() => riveMock); 379 | 380 | const canvasSpy = document.createElement('canvas'); 381 | 382 | const { result, rerender } = renderHook((params) => useRive(params), { 383 | initialProps: params, 384 | }); 385 | 386 | await act(async () => { 387 | result.current.setCanvasRef(canvasSpy); 388 | }); 389 | await waitFor(() => { 390 | expect(result.current.canvas).toBe(canvasSpy); 391 | }); 392 | await act(async () => { 393 | controlledRiveloadCb(); 394 | }); 395 | 396 | rerender({ 397 | src: 'file-src', 398 | animations: 'dark', 399 | }); 400 | 401 | expect(stopMock).toBeCalledWith(['light']); 402 | expect(pauseMock).toBeCalledWith('dark'); 403 | expect(playMock).not.toBeCalled(); 404 | }); 405 | 406 | it('does not set styles if className is passed in for the canvas container', async () => { 407 | const params = { 408 | src: 'file-src', 409 | }; 410 | 411 | // @ts-ignore 412 | mocked(rive.Rive).mockImplementation(() => baseRiveMock); 413 | 414 | const canvasSpy = document.createElement('canvas'); 415 | const { result } = renderHook(() => useRive(params)); 416 | 417 | await act(async () => { 418 | result.current.setCanvasRef(canvasSpy); 419 | }); 420 | await waitFor(() => { 421 | expect(result.current.canvas).toBe(canvasSpy); 422 | }); 423 | await act(async () => { 424 | controlledRiveloadCb(); 425 | }); 426 | 427 | const { RiveComponent: RiveTestComponent } = result.current; 428 | const { container } = render( 429 | 430 | ); 431 | expect(container.firstChild).not.toHaveStyle('width: 50%'); 432 | }); 433 | 434 | it('has a canvas size of 0 by default', async () => { 435 | const params = { 436 | src: 'file-src', 437 | }; 438 | 439 | // @ts-ignore 440 | mocked(rive.Rive).mockImplementation(() => baseRiveMock); 441 | 442 | const canvasSpy = document.createElement('canvas'); 443 | const { result } = renderHook(() => useRive(params)); 444 | 445 | await act(async () => { 446 | result.current.setCanvasRef(canvasSpy); 447 | }); 448 | await waitFor(() => { 449 | expect(result.current.canvas).toBe(canvasSpy); 450 | }); 451 | await act(async () => { 452 | controlledRiveloadCb(); 453 | }); 454 | 455 | const { RiveComponent: RiveTestComponent } = result.current; 456 | const { container } = render(); 457 | expect(container.querySelector('canvas')).toHaveStyle('width: 0'); 458 | }); 459 | 460 | it('sets the canvas width and height after calculating the container size', async () => { 461 | const params = { 462 | src: 'file-src', 463 | }; 464 | 465 | global.devicePixelRatio = 2; 466 | 467 | // @ts-ignore 468 | mocked(rive.Rive).mockImplementation(() => baseRiveMock); 469 | 470 | const canvasSpy = document.createElement('canvas'); 471 | const containerSpy = document.createElement('div'); 472 | jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(100); 473 | jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(100); 474 | 475 | const { result } = renderHook(() => useRive(params)); 476 | 477 | await act(async () => { 478 | result.current.setCanvasRef(canvasSpy); 479 | result.current.setContainerRef(containerSpy); 480 | }); 481 | await waitFor(() => { 482 | expect(result.current.canvas).toBe(canvasSpy); 483 | }); 484 | await act(async () => { 485 | controlledRiveloadCb(); 486 | }); 487 | 488 | expect(canvasSpy).toHaveStyle('height: 100px'); 489 | expect(canvasSpy).toHaveStyle('width: 100px'); 490 | }); 491 | 492 | it('updates the canvas dimensions and size if there is a new canvas size calculation', async () => { 493 | const params = { 494 | src: 'file-src', 495 | }; 496 | 497 | window.devicePixelRatio = 2; 498 | 499 | // @ts-ignore 500 | mocked(rive.Rive).mockImplementation(() => baseRiveMock); 501 | 502 | const canvasSpy = document.createElement('canvas'); 503 | const containerSpy = document.createElement('div'); 504 | jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(100); 505 | jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(100); 506 | 507 | const { result } = renderHook(() => useRive(params)); 508 | 509 | await act(async () => { 510 | result.current.setCanvasRef(canvasSpy); 511 | result.current.setContainerRef(containerSpy); 512 | jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(200); 513 | jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(200); 514 | }); 515 | await waitFor(() => { 516 | expect(result.current.canvas).toBe(canvasSpy); 517 | }); 518 | await act(async () => { 519 | controlledRiveloadCb(); 520 | }); 521 | 522 | await act(async () => { 523 | containerSpy.dispatchEvent(new Event('resize')); 524 | }); 525 | 526 | expect(canvasSpy).toHaveAttribute('width', '400'); 527 | expect(canvasSpy).toHaveAttribute('height', '400'); 528 | }); 529 | 530 | it('prevents resizing if shouldResizeCanvasToContainer option is false', async () => { 531 | const params = { 532 | src: 'file-src', 533 | }; 534 | const options = { 535 | shouldResizeCanvasToContainer: false, 536 | }; 537 | 538 | window.devicePixelRatio = 2; 539 | 540 | // @ts-ignore 541 | mocked(rive.Rive).mockImplementation(() => baseRiveMock); 542 | 543 | const canvasSpy = document.createElement('canvas'); 544 | canvasSpy.width = 200; 545 | canvasSpy.height = 200; 546 | const containerSpy = document.createElement('div'); 547 | 548 | const { result } = renderHook(() => useRive(params, options)); 549 | 550 | await act(async () => { 551 | result.current.setCanvasRef(canvasSpy); 552 | result.current.setContainerRef(containerSpy); 553 | }); 554 | 555 | await waitFor(() => { 556 | expect(result.current.canvas).toBe(canvasSpy); 557 | }); 558 | await act(async () => { 559 | controlledRiveloadCb(); 560 | }); 561 | 562 | await act(async () => { 563 | jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(500); 564 | jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(500); 565 | containerSpy.dispatchEvent(new Event('resize')); 566 | }); 567 | 568 | expect(canvasSpy.width).toBe(200); 569 | expect(canvasSpy.height).toBe(200); 570 | }); 571 | }); 572 | -------------------------------------------------------------------------------- /test/useRiveFile.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | import { mocked } from 'jest-mock'; 3 | 4 | import useRiveFile from '../src/hooks/useRiveFile'; 5 | import { RiveFile } from '@rive-app/canvas'; 6 | 7 | jest.mock('@rive-app/canvas', () => ({ 8 | RiveFile: jest.fn().mockImplementation(() => ({ 9 | cleanup: jest.fn(), 10 | on: jest.fn(), 11 | init: jest.fn(), 12 | getInstance: jest.fn(), 13 | })), 14 | EventType: { 15 | Load: 'load', 16 | loadError: 'loadError', 17 | }, 18 | })); 19 | 20 | describe('useRiveFile', () => { 21 | beforeEach(() => { 22 | mocked(RiveFile).mockClear(); 23 | }); 24 | 25 | it('initializes RiveFile with provided parameters', async () => { 26 | const params = { 27 | src: 'file-src', 28 | enableRiveAssetCDN: false, 29 | }; 30 | 31 | const { result } = renderHook(() => useRiveFile(params)); 32 | 33 | expect(RiveFile).toHaveBeenCalledWith(params); 34 | expect(result.current.riveFile).toBeDefined(); 35 | }); 36 | 37 | it('cleans up RiveFile on unmount', async () => { 38 | const params = { 39 | src: 'file-src', 40 | enableRiveAssetCDN: false, 41 | }; 42 | 43 | const { result, unmount } = renderHook(() => useRiveFile(params)); 44 | 45 | const riveInstance = result.current.riveFile; 46 | expect(riveInstance).toBeDefined(); 47 | 48 | unmount(); 49 | 50 | expect(riveInstance?.cleanup).toHaveBeenCalled(); 51 | }); 52 | 53 | it('does not reinitialize RiveFile if src has not changed', async () => { 54 | const params = { 55 | src: 'file-src', 56 | enableRiveAssetCDN: false, 57 | }; 58 | 59 | const { rerender } = renderHook(() => useRiveFile(params)); 60 | 61 | rerender(); 62 | 63 | expect(RiveFile).toHaveBeenCalledTimes(1); 64 | }); 65 | 66 | it('does not reinitialize RiveFile if buffer has not changed', async () => { 67 | const params = { 68 | buffer: new ArrayBuffer(10), 69 | enableRiveAssetCDN: false, 70 | }; 71 | 72 | const { rerender } = renderHook(() => useRiveFile(params)); 73 | 74 | rerender(); 75 | 76 | expect(RiveFile).toHaveBeenCalledTimes(1); 77 | }); 78 | 79 | it('reinitializes RiveFile if src changes', async () => { 80 | let params = { 81 | src: 'file-src', 82 | enableRiveAssetCDN: false, 83 | }; 84 | 85 | const { rerender } = renderHook(() => useRiveFile(params)); 86 | 87 | params = { 88 | src: 'new-file-src', 89 | enableRiveAssetCDN: false, 90 | }; 91 | 92 | rerender(); 93 | 94 | expect(RiveFile).toHaveBeenCalledTimes(2); 95 | }); 96 | 97 | it('reinitializes RiveFile if buffer changes', async () => { 98 | let params = { 99 | buffer: new ArrayBuffer(10), 100 | enableRiveAssetCDN: false, 101 | }; 102 | 103 | const { rerender } = renderHook(() => useRiveFile(params)); 104 | 105 | params = { 106 | buffer: new ArrayBuffer(20), 107 | enableRiveAssetCDN: false, 108 | }; 109 | 110 | rerender(); 111 | 112 | expect(RiveFile).toHaveBeenCalledTimes(2); 113 | }); 114 | 115 | it('handles RiveFile initialization failure gracefully', async () => { 116 | const consoleSpy = jest 117 | .spyOn(console, 'error') 118 | .mockImplementation(() => {}); 119 | const error = new Error('Initialization failed'); 120 | 121 | mocked(RiveFile).mockImplementation(() => { 122 | throw error; 123 | }); 124 | 125 | const params = { 126 | src: 'file-src', 127 | enableRiveAssetCDN: false, 128 | }; 129 | 130 | const { result, rerender } = renderHook(() => useRiveFile(params)); 131 | 132 | rerender(); 133 | 134 | expect(result.current.status).toBe('failed'); 135 | expect(result.current.riveFile).toBeNull(); 136 | expect(consoleSpy).toHaveBeenCalledWith(error); 137 | 138 | consoleSpy.mockRestore(); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /test/useStateMachine.test.tsx: -------------------------------------------------------------------------------- 1 | import { mocked } from 'jest-mock'; 2 | import { renderHook } from '@testing-library/react'; 3 | 4 | import useStateMachineInput from '../src/hooks/useStateMachineInput'; 5 | import { Rive, StateMachineInput } from '@rive-app/canvas'; 6 | 7 | jest.mock('@rive-app/canvas', () => ({ 8 | Rive: jest.fn().mockImplementation(() => ({ 9 | on: jest.fn(), 10 | stop: jest.fn(), 11 | stateMachineInputs: jest.fn(), 12 | })), 13 | Layout: jest.fn(), 14 | Fit: { 15 | Cover: 'cover', 16 | }, 17 | Alignment: { 18 | Center: 'center', 19 | }, 20 | EventType: { 21 | Load: 'load', 22 | }, 23 | StateMachineInputType: { 24 | Number: 1, 25 | Boolean: 2, 26 | Trigger: 3, 27 | }, 28 | })); 29 | 30 | function getRiveMock({ 31 | smiInputs, 32 | }: { 33 | smiInputs?: null | StateMachineInput[]; 34 | } = {}) { 35 | const riveMock = new Rive({ 36 | canvas: undefined as unknown as HTMLCanvasElement, 37 | }); 38 | if (smiInputs) { 39 | riveMock.stateMachineInputs = jest.fn().mockReturnValue(smiInputs); 40 | } 41 | 42 | return riveMock; 43 | } 44 | 45 | describe('useStateMachineInput', () => { 46 | it('returns null if there is null rive object passed', () => { 47 | const { result } = renderHook(() => useStateMachineInput(null)); 48 | expect(result.current).toBeNull(); 49 | }); 50 | 51 | it('returns null if there is no state machine name', () => { 52 | const riveMock = getRiveMock(); 53 | 54 | mocked(Rive).mockImplementation(() => riveMock); 55 | 56 | const { result } = renderHook(() => 57 | useStateMachineInput(riveMock, '', 'testInput') 58 | ); 59 | expect(result.current).toBeNull(); 60 | }); 61 | 62 | it('returns null if there is no state machine input name', () => { 63 | const riveMock = getRiveMock(); 64 | 65 | const { result } = renderHook(() => 66 | useStateMachineInput(riveMock, 'smName', '') 67 | ); 68 | expect(result.current).toBeNull(); 69 | }); 70 | 71 | it('returns null if there are no inputs for the state machine', () => { 72 | const riveMock = getRiveMock({ smiInputs: [] }); 73 | 74 | mocked(Rive).mockImplementation(() => riveMock); 75 | 76 | const { result } = renderHook(() => 77 | useStateMachineInput(riveMock as Rive, 'smName', '') 78 | ); 79 | expect(result.current).toBeNull(); 80 | }); 81 | 82 | it('returns null if the input has no association to the inputs of the state machine', () => { 83 | const smInput = { 84 | name: 'boolInput', 85 | } as StateMachineInput; 86 | const riveMock = getRiveMock({ smiInputs: [smInput] }); 87 | 88 | mocked(Rive).mockImplementation(() => riveMock); 89 | 90 | const { result } = renderHook(() => 91 | useStateMachineInput(riveMock, 'smName', 'numInput') 92 | ); 93 | expect(result.current).toBeNull(); 94 | }); 95 | 96 | it('returns a selected input if the input requested is part of the state machine', () => { 97 | const smInput = { 98 | name: 'boolInput', 99 | } as StateMachineInput; 100 | const riveMock = getRiveMock({ smiInputs: [smInput] }); 101 | 102 | mocked(Rive).mockImplementation(() => riveMock); 103 | 104 | const { result } = renderHook(() => 105 | useStateMachineInput(riveMock, 'smName', 'boolInput') 106 | ); 107 | expect(result.current).toBe(smInput); 108 | }); 109 | 110 | it('returns a selected input with an initial value if the input requested is part of the state machine', () => { 111 | const smInput = { 112 | name: 'boolInput', 113 | value: false, 114 | } as StateMachineInput; 115 | const riveMock = getRiveMock({ smiInputs: [smInput] }); 116 | mocked(Rive).mockImplementation(() => riveMock); 117 | 118 | const { result } = renderHook(() => 119 | useStateMachineInput(riveMock, 'smName', 'boolInput', true) 120 | ); 121 | expect(result.current).toStrictEqual({ 122 | ...smInput, 123 | value: true, 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "jsx": "react", 6 | "lib": ["esnext", "dom"], 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "noImplicitAny": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "outDir": "./dist", 14 | "types": ["node", "jest", "offscreencanvas"], 15 | "rootDir": "src", 16 | "strict": true, 17 | "target": "es5", 18 | "typeRoots": ["./types", "./node_modules/@types"] 19 | }, 20 | "include": ["src/**/*", "../examples/stories"] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "strict": false 6 | }, 7 | "include": ["test/**/*", "./setupTests.ts"] 8 | } 9 | --------------------------------------------------------------------------------