├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── .nvmrc ├── .prettierrc.js ├── .storybook ├── main.ts └── preview.ts ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── pic └── confetti-gif-800.gif ├── src ├── conductor │ ├── crossfire │ │ └── index.ts │ ├── explosion │ │ └── index.ts │ ├── fireworks │ │ └── index.ts │ ├── index.ts │ ├── photons │ │ └── index.ts │ ├── pride │ │ └── index.ts │ ├── realistic │ │ └── index.ts │ ├── snow │ │ └── index.ts │ └── vortex │ │ └── index.ts ├── helpers │ └── randomInRange.ts ├── index.tsx ├── presets │ ├── crossfire │ │ └── index.tsx │ ├── explosion │ │ └── index.tsx │ ├── fireworks │ │ └── index.tsx │ ├── index.tsx │ ├── photons │ │ └── index.tsx │ ├── pride │ │ └── index.tsx │ ├── realistic │ │ └── index.tsx │ ├── snow │ │ └── index.tsx │ └── vortex │ │ └── index.tsx └── types │ ├── index.ts │ └── normalization.ts ├── storybook ├── component.stories.tsx ├── index.css └── presets.stories.tsx ├── tests └── index.spec.tsx └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .eslintrc.js 4 | src/*.spec.* 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: ["airbnb", "airbnb-typescript", "plugin:prettier/recommended"], 8 | parserOptions: { 9 | ecmaVersion: "latest", 10 | sourceType: "module", 11 | project: "./tsconfig.json", 12 | }, 13 | overrides: [ 14 | { 15 | env: { 16 | node: true, 17 | }, 18 | files: [".eslintrc.{js,cjs}"], 19 | parserOptions: { 20 | sourceType: "script", 21 | }, 22 | }, 23 | ], 24 | plugins: ["@typescript-eslint", "react", "prettier"], 25 | rules: { 26 | "consistent-return": "off", 27 | "react/jsx-props-no-spreading": "off", 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dist 3 | node_modules 4 | .DS_Store 5 | .vscode 6 | storybook-static 7 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | *.stories.* 3 | *.test.* 4 | !dist/**/* 5 | !src/**/* 6 | dist/types/*.js 7 | **/.DS_Store 8 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | semi: true, 4 | singleQuote: false, 5 | trailingComma: "all", 6 | printWidth: 80, 7 | useTabs: false, 8 | endOfLine: "auto", 9 | }; 10 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-webpack5"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../storybook/**/*.stories.@(js|jsx|mjs|ts|tsx)"], 5 | addons: [ 6 | "@storybook/addon-links", 7 | "@storybook/addon-essentials", 8 | "@storybook/addon-interactions", 9 | ], 10 | framework: { 11 | name: "@storybook/react-webpack5", 12 | options: { 13 | builder: { 14 | useSWC: true, 15 | }, 16 | }, 17 | }, 18 | docs: { 19 | autodocs: "tag", 20 | }, 21 | }; 22 | export default config; 23 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/react"; 2 | 3 | import "../storybook/index.css"; 4 | 5 | const preview: Preview = { 6 | parameters: { 7 | actions: { argTypesRegex: "^on[A-Z].*" }, 8 | controls: { 9 | matchers: { 10 | color: /(background|color)$/i, 11 | date: /Date$/i, 12 | }, 13 | }, 14 | }, 15 | }; 16 | 17 | export default preview; 18 | -------------------------------------------------------------------------------- /LICENSE : -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ruslan Krokhin 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 | # [react-canvas-confetti](https://ulitcos.github.io/react-canvas-confetti/) 2 | 3 | The React component for [canvas-confetti library](https://github.com/catdad/canvas-confetti). In the [demo](https://ulitcos.github.io/react-canvas-confetti) version, you can play with the settings and see examples. 4 | 5 | ![npm](https://img.shields.io/npm/dm/react-canvas-confetti) ![NPM](https://img.shields.io/npm/l/react-canvas-confetti) 6 | 7 | ![GIF-confetti](./pic/confetti-gif-800.gif) 8 | 9 | - [Installation](#Installation) 10 | - [Usage](#Usage) 11 | - [API](#API) 12 | - [Examples](#Examples) 13 | 14 | ## Installation 15 | 16 | ```bash 17 | npm i react-canvas-confetti 18 | ``` 19 | 20 | :exclamation: It is expected that the [react](https://github.com/facebook/react) is already installed as peer dependency. 21 | 22 | ## Usage 23 | 24 | I recommend that you first familiarize yourself with the [canvas-confetti library](https://github.com/catdad/canvas-confetti) to better understand exactly how this module works. 25 | 26 | There are two ways to use this module: 27 | 28 | - Working with Presets (recommended) 29 | - Working with the [canvas-confetti library](https://github.com/catdad/canvas-confetti) instance directly 30 | 31 | ### Working with Presets 32 | 33 | A preset is an animation template that is already ready to use. Presets allow you to customize animation settings, but do not allow you to change the animation algorithm. Using presets is an easier way to work with the module. 34 | 35 |
36 | An example of the minimum required code: 37 | 38 | ```typescript 39 | import Fireworks from "react-canvas-confetti/dist/presets/fireworks"; 40 | 41 | function Example() { 42 | return ; 43 | } 44 | 45 | export default Example; 46 | ``` 47 | 48 | [Live example](https://codepen.io/ulitcos/pen/gOEEXKe) 49 | 50 |
51 | 52 | #### Conductor Instance 53 | 54 | The preset working can be controlled manually using the `Conductor instance`. This object allows you to start and stop animations on demand. Conductor can be accessed in the `onInit` callback. The interface of the object is shown below: 55 | 56 | ```typescript 57 | type TRunAnimationParams = { 58 | speed: number; 59 | duration?: number; 60 | delay?: number; 61 | }; 62 | 63 | type TConductorInstance = { 64 | run: (params: TRunAnimationParams) => void; 65 | shoot: () => void; 66 | pause: () => void; 67 | stop: () => void; 68 | }; 69 | ``` 70 | 71 | ### Working with the canvas-confetti instance 72 | 73 | Working with an instance is working with the module at a lower level. This is a more powerful approach that allows you to create your own animation algorithms, but requires more effort. 74 | 75 | #### Canvas-confetti instance 76 | 77 | [Confetti object](https://github.com/catdad/canvas-confetti?tab=readme-ov-file#confettioptions-object--promisenull), which will be received as a result of calling the [function create](https://github.com/catdad/canvas-confetti?tab=readme-ov-file#confetticreatecanvas-globaloptions--function). Gives you full control to create your own animations. Confetti can be accessed in the `onInit` callback. The interface can be viewed [here](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/canvas-confetti/index.d.ts#L173) 78 | 79 | ## API 80 | 81 | ### Base API 82 | 83 | The common settings are relevant for all use cases 84 | 85 | | Name | Type | Description | 86 | | ------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 87 | | width | string \| number | value is responsible for the width of the canvas. Alternative ways to control canvas sizes are className and style props. | 88 | | height | string \| number | value is responsible for the height of the canvas. Alternative ways to control canvas sizes are className and style props. | 89 | | className | string | value to set className to canvas element | 90 | | style | CSSProperties | value to set style to canvas element. If `style` and `className` are not passed, the default styles will be set | 91 | | globalOptions | [TGlobalOptions](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/canvas-confetti/index.d.ts#L126) | global animation settings that cannot be changed after initialization ([details](https://github.com/catdad/canvas-confetti?tab=readme-ov-file#confetticreatecanvas-globaloptions--function)) | 92 | | onInit | (params: {confetti: [TCanvasConfettiInstance](#canvas-confetti-instance)}) => void | the callback is called when the component is mounted on the page and allows you to access confetti instance ([details](https://github.com/catdad/canvas-confetti?tab=readme-ov-file#confettioptions-object--promisenull)) for manual animation creation | 93 | 94 | ### Advanced API 95 | 96 | Advanced settings only work for presets! 97 | 98 | | Name | Type | Description | 99 | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | 100 | | autorun | { speed: number; duration?: number; delay?: number; } | if it is passed, it automatically starts the animation when mounting the component on the page | 101 | | decorateOptions | (options: [TOptions](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/canvas-confetti/index.d.ts#L39)) => [TOptions](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/canvas-confetti/index.d.ts#L39) | the callback allows you to customize the animation settings and is called for each step of the animation | 102 | | onInit | (params: { confetti: [TCanvasConfettiInstance](#canvas-confetti-instance); conductor: [TConductorInstance](#conductor-instance) }) => void | the callback is called when the component is mounted on the page and allows you to access objects for manual creation and animation control | 103 | 104 | ## Examples 105 | 106 | [Fireworks Preset](https://codepen.io/ulitcos/pen/gOEEXKe) 107 | 108 | [Crossfire Preset](https://codepen.io/ulitcos/pen/MWxxOLW) 109 | 110 | [Snow Preset](https://codepen.io/ulitcos/pen/rNRRYRO) 111 | 112 | [Realistic Preset](https://codepen.io/ulitcos/pen/zYbbPXB) 113 | 114 | [Explosion Preset](https://codepen.io/ulitcos/pen/rNRRYgx) 115 | 116 | [Pride Preset](https://codepen.io/ulitcos/pen/qBvvVGm) 117 | 118 | [Vortex Preset](https://codepen.io/ulitcos/pen/XWQXZyP) 119 | 120 | [Photons Preset](https://codepen.io/ulitcos/pen/xxeZJjG) 121 | 122 | [Manual Control](https://codepen.io/ulitcos/pen/eYXXewp) 123 | 124 | [Decorating Options](https://codepen.io/ulitcos/pen/gOEEoYd) 125 | 126 | [Custom Stylization](https://codepen.io/ulitcos/pen/rNRRpVL) 127 | 128 | [Custom Animation](https://codepen.io/ulitcos/pen/eYXabaz) 129 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | /** @type {import('jest').Config} */ 7 | const config = { 8 | // All imported modules in your tests should be mocked automatically 9 | // automock: false, 10 | 11 | // Stop running tests after `n` failures 12 | // bail: 0, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/vz/x6t55zm92zj6q4qzq8tfylm40000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls, instances, contexts and results before every test 18 | // clearMocks: false, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: undefined, 25 | 26 | // The directory where Jest should output its coverage files 27 | // coverageDirectory: undefined, 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // Indicates which provider should be used to instrument code for coverage 35 | coverageProvider: "v8", 36 | 37 | // A list of reporter names that Jest uses when writing coverage reports 38 | // coverageReporters: [ 39 | // "json", 40 | // "text", 41 | // "lcov", 42 | // "clover" 43 | // ], 44 | 45 | // An object that configures minimum threshold enforcement for coverage results 46 | // coverageThreshold: undefined, 47 | 48 | // A path to a custom dependency extractor 49 | // dependencyExtractor: undefined, 50 | 51 | // Make calling deprecated APIs throw helpful error messages 52 | // errorOnDeprecated: false, 53 | 54 | // The default configuration for fake timers 55 | // fakeTimers: { 56 | // "enableGlobally": false 57 | // }, 58 | 59 | // Force coverage collection from ignored files using an array of glob patterns 60 | // forceCoverageMatch: [], 61 | 62 | // A path to a module which exports an async function that is triggered once before all test suites 63 | // globalSetup: undefined, 64 | 65 | // A path to a module which exports an async function that is triggered once after all test suites 66 | // globalTeardown: undefined, 67 | 68 | // A set of global variables that need to be available in all test environments 69 | // globals: {}, 70 | 71 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 72 | // maxWorkers: "50%", 73 | 74 | // An array of directory names to be searched recursively up from the requiring module's location 75 | // moduleDirectories: [ 76 | // "node_modules" 77 | // ], 78 | 79 | // An array of file extensions your modules use 80 | // moduleFileExtensions: [ 81 | // "js", 82 | // "mjs", 83 | // "cjs", 84 | // "jsx", 85 | // "ts", 86 | // "tsx", 87 | // "json", 88 | // "node" 89 | // ], 90 | 91 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 92 | // moduleNameMapper: {}, 93 | 94 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 95 | // modulePathIgnorePatterns: [], 96 | 97 | // Activates notifications for test results 98 | // notify: false, 99 | 100 | // An enum that specifies notification mode. Requires { notify: true } 101 | // notifyMode: "failure-change", 102 | 103 | // A preset that is used as a base for Jest's configuration 104 | preset: "ts-jest", 105 | 106 | // Run tests from one or more projects 107 | // projects: undefined, 108 | 109 | // Use this configuration option to add custom reporters to Jest 110 | // reporters: undefined, 111 | 112 | // Automatically reset mock state before every test 113 | // resetMocks: false, 114 | 115 | // Reset the module registry before running each individual test 116 | // resetModules: false, 117 | 118 | // A path to a custom resolver 119 | // resolver: undefined, 120 | 121 | // Automatically restore mock state and implementation before every test 122 | // restoreMocks: false, 123 | 124 | // The root directory that Jest should scan for tests and modules within 125 | // rootDir: undefined, 126 | 127 | // A list of paths to directories that Jest should use to search for files in 128 | // roots: [ 129 | // "" 130 | // ], 131 | 132 | // Allows you to use a custom runner instead of Jest's default test runner 133 | // runner: "jest-runner", 134 | 135 | // The paths to modules that run some code to configure or set up the testing environment before each test 136 | // setupFiles: [], 137 | 138 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 139 | // setupFilesAfterEnv: [], 140 | 141 | // The number of seconds after which a test is considered as slow and reported as such in the results. 142 | // slowTestThreshold: 5, 143 | 144 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 145 | // snapshotSerializers: [], 146 | 147 | // The test environment that will be used for testing 148 | testEnvironment: "jsdom", 149 | 150 | // Options that will be passed to the testEnvironment 151 | // testEnvironmentOptions: {}, 152 | 153 | // Adds a location field to test results 154 | // testLocationInResults: false, 155 | 156 | // The glob patterns Jest uses to detect test files 157 | // testMatch: [ 158 | // "**/__tests__/**/*.[jt]s?(x)", 159 | // "**/?(*.)+(spec|test).[tj]s?(x)" 160 | // ], 161 | 162 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 163 | // testPathIgnorePatterns: [ 164 | // "/node_modules/" 165 | // ], 166 | 167 | // The regexp pattern or array of patterns that Jest uses to detect test files 168 | // testRegex: [], 169 | 170 | // This option allows the use of a custom results processor 171 | // testResultsProcessor: undefined, 172 | 173 | // This option allows use of a custom test runner 174 | // testRunner: "jest-circus/runner", 175 | 176 | // A map from regular expressions to paths to transformers 177 | // transform: undefined, 178 | 179 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 180 | // transformIgnorePatterns: [ 181 | // "/node_modules/", 182 | // "\\.pnp\\.[^\\/]+$" 183 | // ], 184 | 185 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 186 | // unmockedModulePathPatterns: undefined, 187 | 188 | // Indicates whether each individual test should be reported during the run 189 | // verbose: undefined, 190 | 191 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 192 | // watchPathIgnorePatterns: [], 193 | 194 | // Whether to use watchman for file crawling 195 | // watchman: true, 196 | }; 197 | 198 | module.exports = config; 199 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-canvas-confetti", 3 | "version": "2.0.7", 4 | "description": "React component for canvas-confetti library", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "npm run check && rm -rf ./dist && tsc --build", 8 | "lint": "npx eslint ./src", 9 | "lint-fix": "npx eslint ./src --fix", 10 | "format": "npx prettier . --write", 11 | "check": "npm run lint && npm run format", 12 | "prepare": "husky install", 13 | "storybook": "storybook dev -p 6006", 14 | "build-storybook": "storybook build", 15 | "test": "jest", 16 | "publish-patch": "npm run build && npm version patch && npm publish", 17 | "publish-minor": "npm run build && npm version minor && npm publish", 18 | "publish-major": "npm run build && npm version major && npm publish", 19 | "update": "npx npm-check-updates -u && npm install" 20 | }, 21 | "devDependencies": { 22 | "@jest/globals": "^29.7.0", 23 | "@storybook/addon-essentials": "^7.6.17", 24 | "@storybook/addon-interactions": "^7.6.17", 25 | "@storybook/addon-links": "^7.6.17", 26 | "@storybook/addon-onboarding": "^1.0.11", 27 | "@storybook/blocks": "^7.6.17", 28 | "@storybook/react": "^7.6.17", 29 | "@storybook/react-webpack5": "^7.6.17", 30 | "@storybook/test": "^7.6.17", 31 | "@testing-library/jest-dom": "^6.4.2", 32 | "@testing-library/react": "^14.2.1", 33 | "@types/jest": "^29.5.12", 34 | "@types/react": "^18.2.64", 35 | "@types/react-dom": "^18.2.21", 36 | "@typescript-eslint/eslint-plugin": "^7.1.1", 37 | "@typescript-eslint/parser": "^7.1.1", 38 | "eslint": "^8.57.0", 39 | "eslint-config-airbnb": "^19.0.4", 40 | "eslint-config-airbnb-typescript": "^18.0.0", 41 | "eslint-config-prettier": "^9.1.0", 42 | "eslint-plugin-import": "^2.29.1", 43 | "eslint-plugin-jsx-a11y": "^6.8.0", 44 | "eslint-plugin-prettier": "^5.1.3", 45 | "eslint-plugin-react": "^7.34.0", 46 | "eslint-plugin-react-hooks": "^4.6.0", 47 | "husky": "^9.0.11", 48 | "jest": "^29.7.0", 49 | "jest-environment-jsdom": "^29.7.0", 50 | "lint-staged": "^15.2.2", 51 | "prettier": "^3.2.5", 52 | "react-dom": "^18.2.0", 53 | "storybook": "^7.6.17", 54 | "ts-jest": "^29.1.2", 55 | "typescript": "^5.4.2" 56 | }, 57 | "lint-staged": { 58 | "**/*": "npm run check" 59 | }, 60 | "peerDependencies": { 61 | "react": "*" 62 | }, 63 | "dependencies": { 64 | "@types/canvas-confetti": "^1.6.4", 65 | "canvas-confetti": "^1.9.2" 66 | }, 67 | "repository": { 68 | "type": "git", 69 | "url": "git+https://github.com/ulitcos/react-canvas-confetti.git" 70 | }, 71 | "author": "Ruslan Krokhin ", 72 | "license": "MIT", 73 | "bugs": { 74 | "url": "https://github.com/ulitcos/react-canvas-confetti/issues" 75 | }, 76 | "homepage": "https://github.com/ulitcos/react-canvas-confetti#readme", 77 | "keywords": [ 78 | "react", 79 | "canvas", 80 | "confetti", 81 | "animation", 82 | "burst", 83 | "fireworks", 84 | "snow", 85 | "particles" 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /pic/confetti-gif-800.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulitcos/react-canvas-confetti/c11155dffc5eb2afb3fed8f6b47eb45a391341fa/pic/confetti-gif-800.gif -------------------------------------------------------------------------------- /src/conductor/crossfire/index.ts: -------------------------------------------------------------------------------- 1 | import Conductor from "../index"; 2 | import randomInRange from "../../helpers/randomInRange"; 3 | 4 | class CrossfireConductor extends Conductor { 5 | tickAnimation = () => { 6 | const gravity = 0; 7 | const colors = ["#E8B837"]; 8 | const particleCount = randomInRange(13, 17); 9 | const spread = randomInRange(75, 85); 10 | const decay = randomInRange(0.97, 0.99); 11 | const startVelocity = randomInRange(9, 11); 12 | const ticks = randomInRange(40, 60); 13 | 14 | this.confetti( 15 | this.decorateOptions({ 16 | particleCount, 17 | spread, 18 | colors, 19 | decay, 20 | startVelocity, 21 | ticks, 22 | gravity, 23 | angle: 45, 24 | origin: { x: 0, y: 1 }, 25 | }), 26 | ); 27 | 28 | this.confetti( 29 | this.decorateOptions({ 30 | particleCount, 31 | spread, 32 | colors, 33 | decay, 34 | startVelocity, 35 | ticks, 36 | gravity, 37 | angle: -45, 38 | origin: { x: 0, y: 0 }, 39 | }), 40 | ); 41 | 42 | this.confetti( 43 | this.decorateOptions({ 44 | particleCount, 45 | spread, 46 | colors, 47 | decay, 48 | startVelocity, 49 | ticks, 50 | gravity, 51 | angle: -135, 52 | origin: { x: 1, y: 0 }, 53 | }), 54 | ); 55 | 56 | this.confetti( 57 | this.decorateOptions({ 58 | particleCount, 59 | spread, 60 | colors, 61 | decay, 62 | startVelocity, 63 | ticks, 64 | gravity, 65 | angle: 135, 66 | origin: { x: 1, y: 1 }, 67 | }), 68 | ); 69 | }; 70 | } 71 | 72 | export default CrossfireConductor; 73 | -------------------------------------------------------------------------------- /src/conductor/explosion/index.ts: -------------------------------------------------------------------------------- 1 | import Conductor from "../index"; 2 | 3 | class ExplosionConductor extends Conductor { 4 | tickAnimation = () => { 5 | this.confetti( 6 | this.decorateOptions({ 7 | spread: 360, 8 | ticks: 50, 9 | gravity: 0, 10 | decay: 0.94, 11 | startVelocity: 30, 12 | colors: ["FFE400", "FFBD00", "E89400", "FFCA6C", "FDFFB8"], 13 | particleCount: 40, 14 | scalar: 1.2, 15 | shapes: ["star"], 16 | }), 17 | ); 18 | 19 | this.confetti( 20 | this.decorateOptions({ 21 | spread: 360, 22 | ticks: 50, 23 | gravity: 0, 24 | decay: 0.94, 25 | startVelocity: 30, 26 | colors: ["FFE400", "FFBD00", "E89400", "FFCA6C", "FDFFB8"], 27 | particleCount: 10, 28 | scalar: 0.75, 29 | shapes: ["circle"], 30 | }), 31 | ); 32 | }; 33 | } 34 | 35 | export default ExplosionConductor; 36 | -------------------------------------------------------------------------------- /src/conductor/fireworks/index.ts: -------------------------------------------------------------------------------- 1 | import Conductor from "../index"; 2 | import randomInRange from "../../helpers/randomInRange"; 3 | 4 | class FireworksConductor extends Conductor { 5 | tickAnimation = () => { 6 | this.confetti( 7 | this.decorateOptions({ 8 | startVelocity: 30, 9 | spread: 360, 10 | ticks: 60, 11 | zIndex: 0, 12 | particleCount: 150, 13 | origin: { 14 | x: randomInRange(0.1, 0.3), 15 | y: Math.random() - 0.2, 16 | }, 17 | }), 18 | ); 19 | 20 | this.confetti( 21 | this.decorateOptions({ 22 | startVelocity: 30, 23 | spread: 360, 24 | ticks: 60, 25 | zIndex: 0, 26 | particleCount: 150, 27 | origin: { 28 | x: randomInRange(0.7, 0.9), 29 | y: Math.random() - 0.2, 30 | }, 31 | }), 32 | ); 33 | }; 34 | } 35 | 36 | export default FireworksConductor; 37 | -------------------------------------------------------------------------------- /src/conductor/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TCanvasConfettiInstance, 3 | TConductorInstance, 4 | TConductorOptions, 5 | TDecorateOptionsFn, 6 | TRunAnimationParams, 7 | } from "../types"; 8 | 9 | abstract class Conductor implements TConductorInstance { 10 | private interval: NodeJS.Timeout | null = null; 11 | 12 | protected readonly confetti: TCanvasConfettiInstance; 13 | 14 | protected readonly decorateOptions: TDecorateOptionsFn; 15 | 16 | constructor({ confetti, decorateOptions }: TConductorOptions) { 17 | this.confetti = confetti; 18 | this.decorateOptions = decorateOptions; 19 | } 20 | 21 | public shoot = () => { 22 | return this.tickAnimation(); 23 | }; 24 | 25 | public run = ({ speed, delay = 0, duration }: TRunAnimationParams) => { 26 | if (this.interval) { 27 | return; 28 | } 29 | 30 | setTimeout(() => { 31 | this.shoot(); 32 | this.interval = setInterval(this.shoot, 1000 / Math.min(speed, 1000)); 33 | 34 | if (duration) { 35 | setTimeout(this.pause, duration); 36 | } 37 | }, delay); 38 | }; 39 | 40 | public pause = () => { 41 | clearInterval(this.interval!); 42 | this.interval = null; 43 | }; 44 | 45 | public stop = () => { 46 | this.pause(); 47 | this.confetti.reset(); 48 | }; 49 | 50 | abstract tickAnimation: () => void; 51 | } 52 | 53 | export default Conductor; 54 | -------------------------------------------------------------------------------- /src/conductor/photons/index.ts: -------------------------------------------------------------------------------- 1 | import Conductor from "../index"; 2 | import randomInRange from "../../helpers/randomInRange"; 3 | 4 | const colors = [ 5 | "#f94144", 6 | "f3722c", 7 | "f8961e", 8 | "f9c74f", 9 | "90be6d", 10 | "43aa8b", 11 | "577590", 12 | ]; 13 | 14 | const config = [ 15 | { 16 | origin: () => ({ x: randomInRange(0, 1), y: -0.1 }), 17 | angle: () => randomInRange(0, -180), 18 | }, 19 | { 20 | origin: () => ({ x: randomInRange(0, 1), y: 1.1 }), 21 | angle: () => randomInRange(0, 180), 22 | }, 23 | { 24 | origin: () => ({ x: -0.1, y: randomInRange(0, 1) }), 25 | angle: () => randomInRange(-90, 90), 26 | }, 27 | { 28 | origin: () => ({ x: 1.1, y: randomInRange(0, 1) }), 29 | angle: () => randomInRange(90, 270), 30 | }, 31 | ]; 32 | 33 | class PhotonsConductor extends Conductor { 34 | private tickCount: number = 0; 35 | 36 | private get params() { 37 | // eslint-disable-next-line no-plusplus 38 | return config[this.tickCount++ % config.length]; 39 | } 40 | 41 | tickAnimation = () => { 42 | const colorIndex = Number(randomInRange(0, colors.length - 1).toFixed()); 43 | const { angle, origin } = this.params!; 44 | 45 | this.confetti( 46 | this.decorateOptions({ 47 | particleCount: 1, 48 | angle: angle(), 49 | spread: 0, 50 | gravity: 0, 51 | ticks: 600, 52 | decay: 1, 53 | startVelocity: 7, 54 | // @ts-ignore 55 | flat: true, 56 | origin: origin(), 57 | shapes: ["circle"], 58 | scalar: randomInRange(0.2, 6), 59 | colors: [colors[colorIndex]!], 60 | }), 61 | ); 62 | }; 63 | } 64 | 65 | export default PhotonsConductor; 66 | -------------------------------------------------------------------------------- /src/conductor/pride/index.ts: -------------------------------------------------------------------------------- 1 | import Conductor from "../index"; 2 | 3 | class PrideConductor extends Conductor { 4 | tickAnimation = () => { 5 | this.confetti( 6 | this.decorateOptions({ 7 | particleCount: 3, 8 | angle: 60, 9 | spread: 55, 10 | origin: { x: 0 }, 11 | colors: ["#bb0000", "#ffffff"], 12 | }), 13 | ); 14 | 15 | this.confetti( 16 | this.decorateOptions({ 17 | particleCount: 3, 18 | angle: 120, 19 | spread: 55, 20 | origin: { x: 1 }, 21 | colors: ["#bb0000", "#ffffff"], 22 | }), 23 | ); 24 | }; 25 | } 26 | 27 | export default PrideConductor; 28 | -------------------------------------------------------------------------------- /src/conductor/realistic/index.ts: -------------------------------------------------------------------------------- 1 | import Conductor from "../index"; 2 | 3 | class RealisticConductor extends Conductor { 4 | tickAnimation = () => { 5 | this.confetti( 6 | this.decorateOptions({ 7 | spread: 26, 8 | startVelocity: 55, 9 | origin: { y: 0.7 }, 10 | particleCount: Math.floor(200 * 0.25), 11 | }), 12 | ); 13 | this.confetti( 14 | this.decorateOptions({ 15 | spread: 60, 16 | origin: { y: 0.7 }, 17 | particleCount: Math.floor(200 * 0.2), 18 | }), 19 | ); 20 | this.confetti( 21 | this.decorateOptions({ 22 | spread: 100, 23 | decay: 0.91, 24 | scalar: 0.8, 25 | origin: { y: 0.7 }, 26 | particleCount: Math.floor(200 * 0.35), 27 | }), 28 | ); 29 | this.confetti( 30 | this.decorateOptions({ 31 | spread: 120, 32 | startVelocity: 25, 33 | decay: 0.92, 34 | scalar: 1.2, 35 | origin: { y: 0.7 }, 36 | particleCount: Math.floor(200 * 0.1), 37 | }), 38 | ); 39 | this.confetti( 40 | this.decorateOptions({ 41 | spread: 120, 42 | startVelocity: 45, 43 | origin: { y: 0.7 }, 44 | particleCount: Math.floor(200 * 0.1), 45 | }), 46 | ); 47 | }; 48 | } 49 | 50 | export default RealisticConductor; 51 | -------------------------------------------------------------------------------- /src/conductor/snow/index.ts: -------------------------------------------------------------------------------- 1 | import Conductor from "../index"; 2 | import randomInRange from "../../helpers/randomInRange"; 3 | 4 | class SnowConductor extends Conductor { 5 | tickAnimation = () => { 6 | this.confetti( 7 | this.decorateOptions({ 8 | particleCount: 1, 9 | startVelocity: 0, 10 | ticks: 200, 11 | gravity: 0.3, 12 | origin: { 13 | x: Math.random(), 14 | y: Math.random() * 0.999 - 0.2, 15 | }, 16 | colors: ["#ffffff"], 17 | shapes: ["circle"], 18 | scalar: randomInRange(0.4, 1), 19 | }), 20 | ); 21 | }; 22 | } 23 | 24 | export default SnowConductor; 25 | -------------------------------------------------------------------------------- /src/conductor/vortex/index.ts: -------------------------------------------------------------------------------- 1 | import Conductor from "../index"; 2 | 3 | const angels = [0, -45, -90, -135, -180, -225, -270, -315]; 4 | 5 | class VortexConductor extends Conductor { 6 | private tickCount: number = 0; 7 | 8 | private get angle() { 9 | // eslint-disable-next-line no-plusplus 10 | return angels[this.tickCount++ % angels.length]; 11 | } 12 | 13 | tickAnimation = () => { 14 | this.confetti( 15 | this.decorateOptions({ 16 | spread: 120, 17 | ticks: 60, 18 | gravity: 0, 19 | decay: 0.94, 20 | startVelocity: 20, 21 | colors: ["004e64", "00a5cf", "#9fffcb", "#25a18e", "#7ae582"], 22 | particleCount: 60, 23 | shapes: ["circle", "square"], 24 | angle: this.angle, 25 | }), 26 | ); 27 | }; 28 | } 29 | 30 | export default VortexConductor; 31 | -------------------------------------------------------------------------------- /src/helpers/randomInRange.ts: -------------------------------------------------------------------------------- 1 | function randomInRange(min: number, max: number): number { 2 | return Math.random() * (max - min) + min; 3 | } 4 | 5 | export default randomInRange; 6 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, useEffect, useRef } from "react"; 2 | import canvasConfetti from "canvas-confetti"; 3 | import { 4 | TCanvasConfettiInstance, 5 | TCanvasConfettiGlobalOptions, 6 | } from "./types/normalization"; 7 | import { TReactCanvasConfettiProps } from "./types"; 8 | 9 | const DEFAULT_GLOBAL_OPTIONS: TCanvasConfettiGlobalOptions = { 10 | resize: true, 11 | useWorker: false, 12 | }; 13 | 14 | const DEFAULT_STYLE: CSSProperties = { 15 | position: "fixed", 16 | pointerEvents: "none", 17 | width: "100%", 18 | height: "100%", 19 | top: 0, 20 | left: 0, 21 | }; 22 | 23 | function getFinalStyle(style?: CSSProperties, className?: string) { 24 | if (!style && !className) { 25 | return DEFAULT_STYLE; 26 | } 27 | 28 | return style; 29 | } 30 | 31 | function ReactCanvasConfetti({ 32 | style, 33 | className, 34 | width, 35 | height, 36 | globalOptions, 37 | onInit, 38 | }: TReactCanvasConfettiProps) { 39 | const canvasRef = useRef(null); 40 | const confetti = useRef(null); 41 | 42 | useEffect(() => { 43 | if (!canvasRef.current) { 44 | return; 45 | } 46 | 47 | confetti.current = canvasConfetti.create(canvasRef.current, { 48 | ...DEFAULT_GLOBAL_OPTIONS, 49 | ...globalOptions, 50 | }); 51 | 52 | onInit?.({ confetti: confetti.current }); 53 | 54 | return () => { 55 | confetti.current?.reset(); 56 | }; 57 | }, []); 58 | 59 | return ( 60 | 67 | ); 68 | } 69 | export default ReactCanvasConfetti; 70 | -------------------------------------------------------------------------------- /src/presets/crossfire/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TPresetInstanceProps } from "../../types"; 3 | import Preset from "../index"; 4 | import CrossfireConductor from "../../conductor/crossfire"; 5 | 6 | function Crossfire(props: TPresetInstanceProps) { 7 | return ; 8 | } 9 | export default Crossfire; 10 | -------------------------------------------------------------------------------- /src/presets/explosion/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ExplosionConductor from "../../conductor/explosion"; 3 | import Preset from "../index"; 4 | import { TPresetInstanceProps } from "../../types"; 5 | 6 | function Explosion(props: TPresetInstanceProps) { 7 | return ; 8 | } 9 | export default Explosion; 10 | -------------------------------------------------------------------------------- /src/presets/fireworks/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TPresetInstanceProps } from "../../types"; 3 | import Preset from "../index"; 4 | import FireworksConductor from "../../conductor/fireworks"; 5 | 6 | function Fireworks(props: TPresetInstanceProps) { 7 | return ; 8 | } 9 | export default Fireworks; 10 | -------------------------------------------------------------------------------- /src/presets/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from "react"; 2 | import { 3 | TCanvasConfettiInstance, 4 | TDecorateOptionsFn, 5 | TPresetProps, 6 | } from "../types"; 7 | import ReactCanvasConfetti from "../index"; 8 | 9 | const DEFAULT_DECORATE_OPTIONS: TDecorateOptionsFn = (o) => o; 10 | 11 | function Preset({ 12 | decorateOptions = DEFAULT_DECORATE_OPTIONS, 13 | Conductor, 14 | autorun, 15 | onInit, 16 | ...rest 17 | }: TPresetProps) { 18 | const [confetti, setConfetti] = useState(); 19 | const initHandler = useCallback( 20 | ({ confetti: confettiInstance }: { confetti: TCanvasConfettiInstance }) => { 21 | setConfetti(() => confettiInstance); 22 | }, 23 | [], 24 | ); 25 | 26 | useEffect(() => { 27 | if (!confetti) { 28 | return; 29 | } 30 | 31 | const conductor = new Conductor({ 32 | confetti, 33 | decorateOptions, 34 | }); 35 | 36 | if (autorun) { 37 | conductor.run(autorun); 38 | } 39 | 40 | onInit?.({ confetti, conductor }); 41 | 42 | return conductor.stop; 43 | }, [confetti]); 44 | 45 | return ; 46 | } 47 | export default Preset; 48 | -------------------------------------------------------------------------------- /src/presets/photons/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TPresetInstanceProps } from "../../types"; 3 | import Preset from "../index"; 4 | import PhotonsConductor from "../../conductor/photons"; 5 | 6 | function Photons(props: TPresetInstanceProps) { 7 | return ; 8 | } 9 | export default Photons; 10 | -------------------------------------------------------------------------------- /src/presets/pride/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TPresetInstanceProps } from "../../types"; 3 | import Preset from "../index"; 4 | import PrideConductor from "../../conductor/pride"; 5 | 6 | function Pride(props: TPresetInstanceProps) { 7 | return ; 8 | } 9 | export default Pride; 10 | -------------------------------------------------------------------------------- /src/presets/realistic/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TPresetInstanceProps } from "../../types"; 3 | import Preset from "../index"; 4 | import RealisticConductor from "../../conductor/realistic"; 5 | 6 | function Realistic(props: TPresetInstanceProps) { 7 | return ; 8 | } 9 | export default Realistic; 10 | -------------------------------------------------------------------------------- /src/presets/snow/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TPresetInstanceProps } from "../../types"; 3 | import Preset from "../index"; 4 | import SnowConductor from "../../conductor/snow"; 5 | 6 | function Snow(props: TPresetInstanceProps) { 7 | return ; 8 | } 9 | export default Snow; 10 | -------------------------------------------------------------------------------- /src/presets/vortex/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TPresetInstanceProps } from "../../types"; 3 | import Preset from "../index"; 4 | import VortexConductor from "../../conductor/vortex"; 5 | 6 | function Snow(props: TPresetInstanceProps) { 7 | return ; 8 | } 9 | export default Snow; 10 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | import { 3 | TCanvasConfettiAnimationOptions, 4 | TCanvasConfettiInstance, 5 | TCanvasConfettiGlobalOptions, 6 | } from "./normalization"; 7 | 8 | export type TDecorateOptionsFn = ( 9 | options: TCanvasConfettiAnimationOptions, 10 | ) => TCanvasConfettiAnimationOptions; 11 | 12 | export type TGetTickAnimationFn = ( 13 | confetti: TCanvasConfettiInstance, 14 | decorateOptions: TDecorateOptionsFn, 15 | ) => void; 16 | 17 | export type TOnInitComponentFn = (params: { 18 | confetti: TCanvasConfettiInstance; 19 | }) => void; 20 | export type TOnInitPresetFn = (params: { 21 | confetti: TCanvasConfettiInstance; 22 | conductor: TConductorInstance; 23 | }) => void; 24 | 25 | export type TRunAnimationParams = { 26 | speed: number; 27 | duration?: number; 28 | delay?: number; 29 | }; 30 | 31 | export type TReactCanvasConfettiProps = { 32 | className?: string; 33 | style?: CSSProperties; 34 | width?: string | number; 35 | height?: string | number; 36 | globalOptions?: TCanvasConfettiGlobalOptions; 37 | onInit?: TOnInitComponentFn; 38 | }; 39 | 40 | export type TPresetInstanceProps = Omit & { 41 | autorun?: TRunAnimationParams; 42 | decorateOptions?: TDecorateOptionsFn; 43 | onInit?: TOnInitPresetFn; 44 | }; 45 | 46 | export type TConductorOptions = { 47 | confetti: TCanvasConfettiInstance; 48 | decorateOptions: TDecorateOptionsFn; 49 | }; 50 | 51 | export type TPresetProps = TPresetInstanceProps & { 52 | Conductor: new (params: TConductorOptions) => TConductorInstance; 53 | }; 54 | 55 | export type TConductorInstance = { 56 | run: (params: TRunAnimationParams) => void; 57 | shoot: () => void; 58 | pause: () => void; 59 | stop: () => void; 60 | }; 61 | 62 | export { 63 | // @ts-ignore 64 | TCanvasConfettiInstance, 65 | // @ts-ignore 66 | TCanvasConfettiGlobalOptions, 67 | // @ts-ignore 68 | TCanvasConfettiAnimationOptions, 69 | } from "./normalization"; 70 | -------------------------------------------------------------------------------- /src/types/normalization.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | CreateTypes as TCanvasConfettiInstance, 3 | GlobalOptions as TCanvasConfettiGlobalOptions, 4 | Options as TCanvasConfettiAnimationOptions, 5 | } from "canvas-confetti"; 6 | -------------------------------------------------------------------------------- /storybook/component.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { Meta, StoryObj } from "@storybook/react"; 3 | 4 | import ReactCanvasConfetti from "../src"; 5 | import { 6 | TCanvasConfettiAnimationOptions, 7 | TCanvasConfettiInstance, 8 | TCanvasConfettiGlobalOptions, 9 | } from "../src/types/normalization"; 10 | import { useEffect, useRef, useState } from "react"; 11 | 12 | type TStoryProps = TCanvasConfettiAnimationOptions & 13 | TCanvasConfettiGlobalOptions; 14 | 15 | type TStoryComponent = (props: TStoryProps) => JSX.Element; 16 | 17 | const normalizeOptions = ( 18 | props: TStoryProps, 19 | ): { 20 | options: TCanvasConfettiAnimationOptions; 21 | globalOptions: TCanvasConfettiGlobalOptions; 22 | } => { 23 | const { colors, resize, useWorker, ...rest } = props; 24 | 25 | return { 26 | options: { 27 | ...rest, 28 | colors: colors?.map((item) => COLOR_MAP[item]) as string[], 29 | }, 30 | globalOptions: { 31 | resize, 32 | useWorker, 33 | }, 34 | }; 35 | }; 36 | 37 | const Component = (props: TStoryProps) => { 38 | const { options, globalOptions } = normalizeOptions(props); 39 | const [prevGlobalOptions, setPrevGlobalOptions] = useState(globalOptions); 40 | const [needRemount, setNeedRemount] = useState(false); 41 | 42 | useEffect(() => { 43 | if (JSON.stringify(globalOptions) !== JSON.stringify(prevGlobalOptions)) { 44 | setNeedRemount(true); 45 | setTimeout(() => { 46 | setNeedRemount(false); 47 | }, 1); 48 | } 49 | 50 | setPrevGlobalOptions(globalOptions); 51 | }); 52 | 53 | const instance = useRef(); 54 | 55 | const onInitHandler = ({ confetti }: { confetti: TCanvasConfettiInstance }) => 56 | (instance.current = confetti); 57 | 58 | const onShootHandler = () => { 59 | instance.current?.(options); 60 | }; 61 | 62 | return ( 63 |
64 |
65 | 66 |
67 | {needRemount ? null : ( 68 | 72 | )} 73 |
74 | ); 75 | }; 76 | 77 | const COLOR_MAP: Record = { 78 | blue: "#26ccff", 79 | purple: "#a25afd", 80 | red: "#ff5e7e", 81 | green: "#88ff5a", 82 | yellow: "#fcff42", 83 | orange: "#ffa62d", 84 | pink: "#ff36ff", 85 | }; 86 | 87 | const SHAPES = ["circle", "square", "star"]; 88 | 89 | const meta = { 90 | title: "Component/React Canvas Confetti", 91 | component: Component, 92 | args: { 93 | particleCount: 500, 94 | angle: 90, 95 | spread: 360, 96 | startVelocity: 45, 97 | decay: 0.9, 98 | gravity: 1, 99 | drift: 0, 100 | ticks: 600, 101 | origin: { 102 | x: 0.5, 103 | y: 0.5, 104 | }, 105 | colors: ["blue", "purple", "red", "green", "yellow", "orange", "pink"], 106 | shapes: ["circle", "square"], 107 | scalar: 1, 108 | zIndex: -1, 109 | disableForReducedMotion: false, 110 | resize: true, 111 | useWorker: true, 112 | }, 113 | argTypes: { 114 | particleCount: { 115 | control: { 116 | type: "range", 117 | min: 1, 118 | max: 2000, 119 | step: 1, 120 | }, 121 | }, 122 | angle: { 123 | control: { 124 | type: "range", 125 | min: 1, 126 | max: 360, 127 | step: 1, 128 | }, 129 | }, 130 | spread: { 131 | control: { 132 | type: "range", 133 | min: 1, 134 | max: 360, 135 | step: 1, 136 | }, 137 | }, 138 | startVelocity: { 139 | control: { 140 | type: "range", 141 | min: 1, 142 | max: 360, 143 | step: 1, 144 | }, 145 | }, 146 | decay: { 147 | control: { 148 | type: "range", 149 | min: 0, 150 | max: 1, 151 | step: 0.1, 152 | }, 153 | }, 154 | gravity: { 155 | control: { 156 | type: "range", 157 | min: -3, 158 | max: 3, 159 | step: 0.1, 160 | }, 161 | }, 162 | drift: { 163 | control: { 164 | type: "range", 165 | min: 1, 166 | max: 3, 167 | step: 0.1, 168 | }, 169 | }, 170 | ticks: { 171 | control: { 172 | type: "range", 173 | min: 1, 174 | max: 1000, 175 | step: 1, 176 | }, 177 | }, 178 | origin: { 179 | x: { 180 | control: { 181 | type: "range", 182 | min: -3, 183 | max: 3, 184 | step: 0.1, 185 | }, 186 | }, 187 | y: { 188 | control: { 189 | type: "range", 190 | min: -3, 191 | max: 3, 192 | step: 0.1, 193 | }, 194 | }, 195 | }, 196 | colors: { 197 | control: "inline-check", 198 | options: Object.keys(COLOR_MAP), 199 | }, 200 | shapes: { 201 | control: "inline-check", 202 | options: SHAPES, 203 | }, 204 | scalar: { 205 | control: { 206 | type: "range", 207 | min: 0, 208 | max: 5, 209 | step: 0.1, 210 | }, 211 | }, 212 | zIndex: { 213 | control: { 214 | type: "range", 215 | min: -1, 216 | max: 10, 217 | step: 1, 218 | }, 219 | }, 220 | disableForReducedMotion: { 221 | control: { 222 | type: "boolean", 223 | }, 224 | }, 225 | resize: { 226 | control: { 227 | type: "boolean", 228 | }, 229 | }, 230 | useWorker: { 231 | control: { 232 | type: "boolean", 233 | }, 234 | }, 235 | }, 236 | render: Component, 237 | } satisfies Meta; 238 | 239 | export default meta; 240 | type Story = StoryObj; 241 | 242 | export const Main: Story = {}; 243 | -------------------------------------------------------------------------------- /storybook/index.css: -------------------------------------------------------------------------------- 1 | .controls { 2 | bottom: 15%; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 5px; 6 | left: 50%; 7 | margin-left: -50px; 8 | position: fixed; 9 | } 10 | 11 | .controls button { 12 | height: 40px; 13 | width: 100px; 14 | pointer-events: auto; 15 | } 16 | 17 | .list { 18 | display: flex; 19 | 20 | flex-wrap: wrap; 21 | justify-content: space-between; 22 | max-width: 1400px; 23 | margin: auto; 24 | } 25 | 26 | .item { 27 | outline: 2px solid #fbebd1; 28 | width: 45%; 29 | margin: 2.5%; 30 | position: relative; 31 | aspect-ratio: 3 / 2; 32 | border-radius: 3px; 33 | text-transform: capitalize; 34 | cursor: pointer; 35 | } 36 | 37 | .item:hover { 38 | outline-color: #ee7b49; 39 | } 40 | 41 | .preset { 42 | position: absolute; 43 | top: 0; 44 | left: 0; 45 | width: 100%; 46 | height: 100%; 47 | } 48 | 49 | .label { 50 | font-size: min(5vw, 50px); 51 | position: absolute; 52 | top: 50%; 53 | left: 50%; 54 | transform: translate(-50%, -50%); 55 | font-family: Tahoma, sans-serif; 56 | z-index: 1; 57 | transition: opacity 0.25s; 58 | } 59 | 60 | .item:hover .label { 61 | opacity: 0.05; 62 | } 63 | -------------------------------------------------------------------------------- /storybook/presets.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { Meta, StoryObj } from "@storybook/react"; 3 | import { linkTo } from "@storybook/addon-links"; 4 | 5 | import ReactCanvasConfetti from "../src"; 6 | import FireworksPreset from "../src/presets/fireworks"; 7 | import RealisticPreset from "../src/presets/realistic"; 8 | import PridePreset from "../src/presets/pride"; 9 | import SnowPreset from "../src/presets/snow"; 10 | import ExplosionPreset from "../src/presets/explosion"; 11 | import CrossfirePreset from "../src/presets/crossfire"; 12 | import VortexPreset from "../src/presets/vortex"; 13 | import PhotonsPreset from "../src/presets/photons"; 14 | 15 | import { 16 | TConductorInstance, 17 | TDecorateOptionsFn, 18 | TPresetInstanceProps, 19 | TRunAnimationParams, 20 | } from "../src/types"; 21 | import { useRef } from "react"; 22 | 23 | const meta = { 24 | title: "Presets", 25 | component: ReactCanvasConfetti, 26 | render: () => { 27 | type TPresets = 28 | | "Fireworks" 29 | | "Pride" 30 | | "Realistic" 31 | | "Snow" 32 | | "Explosion" 33 | | "Crossfire" 34 | | "Vortex" 35 | | "Photons"; 36 | 37 | const config: Record = { 38 | Fireworks: { 39 | run: { speed: 2 }, 40 | link: linkTo("Presets", "Fireworks"), 41 | decorateOptions: {}, 42 | component: FireworksPreset, 43 | }, 44 | Crossfire: { 45 | run: { speed: 15 }, 46 | link: linkTo("Presets", "Crossfire"), 47 | decorateOptions: { decay: 0.93, particleCount: 5 }, 48 | component: CrossfirePreset, 49 | }, 50 | Snow: { 51 | run: { speed: 30 }, 52 | link: linkTo("Presets", "Snow"), 53 | decorateOptions: { colors: ["#C9DDF1"] }, 54 | component: SnowPreset, 55 | }, 56 | Realistic: { 57 | run: { speed: 1 }, 58 | link: linkTo("Presets", "Realistic"), 59 | decorateOptions: {}, 60 | component: RealisticPreset, 61 | }, 62 | Explosion: { 63 | run: { speed: 10 }, 64 | link: linkTo("Presets", "Explosion"), 65 | decorateOptions: {}, 66 | component: ExplosionPreset, 67 | }, 68 | Pride: { 69 | run: { speed: 30 }, 70 | link: linkTo("Presets", "Pride"), 71 | decorateOptions: { colors: ["#bb0000", "#00ff00"] }, 72 | component: PridePreset, 73 | }, 74 | Vortex: { 75 | run: { speed: 10 }, 76 | link: linkTo("Presets", "Vortex"), 77 | decorateOptions: {}, 78 | component: VortexPreset, 79 | }, 80 | Photons: { 81 | run: { speed: 50 }, 82 | link: linkTo("Presets", "Photons"), 83 | decorateOptions: {}, 84 | component: PhotonsPreset, 85 | }, 86 | }; 87 | const conductors = useRef>({}); 88 | const onInitHandler = 89 | (name: string) => 90 | ({ conductor }: { conductor: TConductorInstance }) => { 91 | conductors.current[name] = conductor; 92 | }; 93 | 94 | const onMouseEnterHandler = (name: string) => () => { 95 | // @ts-ignore 96 | conductors.current[name]?.run(config[name].run); 97 | }; 98 | 99 | const onMouseLeaveHandler = (name: string) => () => { 100 | conductors.current[name]?.pause(); 101 | }; 102 | return ( 103 |
104 |
105 | {(Object.keys(config) as TPresets[]).map((item) => { 106 | const Component = config[item].component; 107 | return ( 108 |
115 |
{item}
116 | ({ 121 | ...base, 122 | ...config[item].decorateOptions, 123 | scalar: 0.6, 124 | })} 125 | /> 126 |
127 | ); 128 | })} 129 |
130 |
131 | ); 132 | }, 133 | } satisfies Meta; 134 | 135 | export default meta; 136 | type Story = StoryObj; 137 | 138 | export const Main: Story = {}; 139 | 140 | export const Fireworks: StoryObj = { 141 | args: { 142 | speed: 3, 143 | duration: 3000, 144 | delay: 0, 145 | }, 146 | argTypes: { 147 | speed: { 148 | control: "number", 149 | }, 150 | duration: { 151 | control: "number", 152 | }, 153 | delay: { 154 | control: "number", 155 | }, 156 | }, 157 | render: (props: TRunAnimationParams) => { 158 | return ( 159 | } 161 | {...props} 162 | /> 163 | ); 164 | }, 165 | }; 166 | 167 | export const Crossfire: StoryObj = { 168 | args: { 169 | speed: 15, 170 | duration: 5000, 171 | delay: 0, 172 | }, 173 | argTypes: { 174 | speed: { 175 | control: "number", 176 | }, 177 | duration: { 178 | control: "number", 179 | }, 180 | delay: { 181 | control: "number", 182 | }, 183 | }, 184 | render: (props: TRunAnimationParams) => { 185 | return ( 186 | ( 188 | 189 | )} 190 | {...props} 191 | /> 192 | ); 193 | }, 194 | }; 195 | 196 | export const Snow: StoryObj = { 197 | args: { 198 | speed: 60, 199 | duration: 5000, 200 | delay: 0, 201 | }, 202 | argTypes: { 203 | speed: { 204 | control: "number", 205 | }, 206 | duration: { 207 | control: "number", 208 | }, 209 | delay: { 210 | control: "number", 211 | }, 212 | }, 213 | render: (props: TRunAnimationParams) => { 214 | return ( 215 | ( 217 | 218 | )} 219 | decorateOptions={(options) => ({ 220 | ...options, 221 | colors: ["#C9DDF1"], 222 | })} 223 | {...props} 224 | /> 225 | ); 226 | }, 227 | }; 228 | 229 | export const Realistic: StoryObj = { 230 | args: { 231 | speed: 1, 232 | duration: 5000, 233 | delay: 0, 234 | }, 235 | argTypes: { 236 | speed: { 237 | control: "number", 238 | }, 239 | duration: { 240 | control: "number", 241 | }, 242 | delay: { 243 | control: "number", 244 | }, 245 | }, 246 | render: (props: TRunAnimationParams) => { 247 | return ( 248 | } 250 | {...props} 251 | /> 252 | ); 253 | }, 254 | }; 255 | 256 | export const Explosion: StoryObj = { 257 | args: { 258 | speed: 10, 259 | duration: 5000, 260 | delay: 0, 261 | }, 262 | argTypes: { 263 | speed: { 264 | control: "number", 265 | }, 266 | duration: { 267 | control: "number", 268 | }, 269 | delay: { 270 | control: "number", 271 | }, 272 | }, 273 | render: (props: TRunAnimationParams) => { 274 | return ( 275 | ( 277 | 278 | )} 279 | {...props} 280 | /> 281 | ); 282 | }, 283 | }; 284 | 285 | export const Pride: StoryObj = { 286 | args: { 287 | speed: 60, 288 | duration: 5000, 289 | delay: 0, 290 | }, 291 | argTypes: { 292 | speed: { 293 | control: "number", 294 | }, 295 | duration: { 296 | control: "number", 297 | }, 298 | delay: { 299 | control: "number", 300 | }, 301 | }, 302 | render: (props: TRunAnimationParams) => { 303 | return ( 304 | ( 306 | 307 | )} 308 | decorateOptions={(options) => ({ 309 | ...options, 310 | // colors: ["#bb0000", "#00ff00"], 311 | })} 312 | {...props} 313 | /> 314 | ); 315 | }, 316 | }; 317 | 318 | export const Vortex: StoryObj = { 319 | args: { 320 | speed: 15, 321 | duration: 5000, 322 | delay: 0, 323 | }, 324 | argTypes: { 325 | speed: { 326 | control: "number", 327 | }, 328 | duration: { 329 | control: "number", 330 | }, 331 | delay: { 332 | control: "number", 333 | }, 334 | }, 335 | render: (props: TRunAnimationParams) => { 336 | return ( 337 | ( 339 | 340 | )} 341 | decorateOptions={(options) => ({ 342 | ...options, 343 | // colors: ["#bb0000", "#00ff00"], 344 | })} 345 | {...props} 346 | /> 347 | ); 348 | }, 349 | }; 350 | 351 | export const Photons: StoryObj = { 352 | args: { 353 | speed: 8, 354 | duration: 5000, 355 | delay: 0, 356 | }, 357 | argTypes: { 358 | speed: { 359 | control: "number", 360 | }, 361 | duration: { 362 | control: "number", 363 | }, 364 | delay: { 365 | control: "number", 366 | }, 367 | }, 368 | render: (props: TRunAnimationParams) => { 369 | return ( 370 | ( 372 | 373 | )} 374 | {...props} 375 | /> 376 | ); 377 | }, 378 | }; 379 | 380 | type TWrapperProps = TRunAnimationParams & { 381 | preset: (params: { 382 | onInit: ({ conductor }: { conductor: TConductorInstance }) => void; 383 | decorateOptions?: TDecorateOptionsFn; 384 | }) => React.ReactNode; 385 | decorateOptions?: TDecorateOptionsFn; 386 | }; 387 | 388 | const Wrapper = ({ 389 | preset, 390 | speed, 391 | duration, 392 | delay, 393 | decorateOptions, 394 | }: TWrapperProps) => { 395 | const [conductor, setConductor] = React.useState(); 396 | 397 | const onOnce = () => { 398 | conductor?.shoot(); 399 | }; 400 | const onRun = () => { 401 | conductor?.run({ speed, duration, delay }); 402 | }; 403 | const onPause = () => { 404 | conductor?.pause(); 405 | }; 406 | const onStop = () => { 407 | conductor?.stop(); 408 | }; 409 | 410 | const onInit = ({ conductor }: { conductor: TConductorInstance }) => { 411 | setConductor(conductor); 412 | }; 413 | return ( 414 |
415 |
416 | 417 | 418 | 419 | 420 |
421 | {preset({ onInit, decorateOptions })} 422 |
423 | ); 424 | }; 425 | -------------------------------------------------------------------------------- /tests/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import * as canvasConfetti from "canvas-confetti"; 4 | import ReactCanvasConfetti from "../src"; 5 | import Preset from "../src/presets"; 6 | import Conductor from "../src/conductor"; 7 | import FireworksConductor from "../src/conductor/fireworks"; 8 | 9 | const getCanvasElement = () => 10 | screen.getByText((_, element) => element?.tagName.toLowerCase() === "canvas"); 11 | 12 | const decorateOptions = jest.fn(); 13 | const conductorInstance = { run: jest.fn() }; 14 | 15 | jest.mock("canvas-confetti"); 16 | jest.mock("../src/conductor", () => 17 | jest.fn().mockImplementation(() => conductorInstance), 18 | ); 19 | 20 | beforeEach(() => { 21 | (canvasConfetti.create as jest.Mock).mockClear(); 22 | decorateOptions.mockClear(); 23 | conductorInstance.run.mockClear(); 24 | }); 25 | describe("Component", () => { 26 | describe("Init", () => { 27 | it("should render canvas", () => { 28 | render(); 29 | const canvas = getCanvasElement(); 30 | expect(canvas).toBeInstanceOf(HTMLCanvasElement); 31 | }); 32 | it("should init correctly with default options", () => { 33 | render(); 34 | const canvas = getCanvasElement(); 35 | const { mock: confetti } = canvasConfetti.create as jest.Mock; 36 | 37 | expect(canvasConfetti.create).toBeCalledTimes(1); 38 | 39 | expect(confetti.calls[0][0]).toEqual(canvas); 40 | expect(confetti.calls[0][1]).toEqual({ 41 | resize: true, 42 | useWorker: false, 43 | }); 44 | }); 45 | 46 | it("should init correctly with global options", () => { 47 | render( 48 | , 55 | ); 56 | const canvas = getCanvasElement(); 57 | const { mock: confetti } = canvasConfetti.create as jest.Mock; 58 | 59 | expect(canvasConfetti.create).toBeCalledTimes(1); 60 | 61 | expect(confetti.calls[0][0]).toEqual(canvas); 62 | expect(confetti.calls[0][1]).toEqual({ 63 | resize: false, 64 | useWorker: true, 65 | disableForReducedMotion: true, 66 | }); 67 | }); 68 | 69 | it("should handle onInit correctly", () => { 70 | const confetti = jest.fn(); 71 | // @ts-ignore 72 | confetti.reset = jest.fn(); 73 | (canvasConfetti.create as jest.Mock).mockReturnValueOnce(confetti); 74 | render( 75 | { 77 | confetti({ colors: ["#ffffff"] }); 78 | }} 79 | />, 80 | ); 81 | 82 | expect(confetti).toBeCalledTimes(1); 83 | expect(confetti.mock.calls[0][0]).toEqual({ 84 | colors: ["#ffffff"], 85 | }); 86 | }); 87 | }); 88 | 89 | describe("Canvas props", () => { 90 | it("should apply props correctly", () => { 91 | render( 92 | , 98 | ); 99 | 100 | const canvas = getCanvasElement(); 101 | 102 | expect(canvas.getAttribute("width")).toBe("200"); 103 | expect(canvas.getAttribute("height")).toBe("100"); 104 | expect(canvas.getAttribute("class")).toBe("class"); 105 | expect(canvas.getAttribute("style")).toBe("opacity: 0.5;"); 106 | }); 107 | }); 108 | }); 109 | 110 | describe("Preset", () => { 111 | describe("Init", () => { 112 | it("should render canvas", () => { 113 | render(); 114 | const canvas = getCanvasElement(); 115 | expect(canvas).toBeInstanceOf(HTMLCanvasElement); 116 | }); 117 | it("should init correctly with default options", () => { 118 | render(); 119 | const canvas = getCanvasElement(); 120 | const { mock: confetti } = canvasConfetti.create as jest.Mock; 121 | 122 | expect(canvasConfetti.create).toBeCalledTimes(1); 123 | 124 | expect(confetti.calls[0][0]).toEqual(canvas); 125 | expect(confetti.calls[0][1]).toEqual({ 126 | resize: true, 127 | useWorker: false, 128 | }); 129 | }); 130 | 131 | it("should init correctly with global options", () => { 132 | render( 133 | , 141 | ); 142 | const canvas = getCanvasElement(); 143 | const { mock: confetti } = canvasConfetti.create as jest.Mock; 144 | 145 | expect(canvasConfetti.create).toBeCalledTimes(1); 146 | 147 | expect(confetti.calls[0][0]).toEqual(canvas); 148 | expect(confetti.calls[0][1]).toEqual({ 149 | resize: false, 150 | useWorker: true, 151 | disableForReducedMotion: true, 152 | }); 153 | }); 154 | 155 | it("should handle onInit correctly", () => { 156 | const confetti = jest.fn(); 157 | // @ts-ignore 158 | confetti.reset = jest.fn(); 159 | (canvasConfetti.create as jest.Mock).mockReturnValueOnce(confetti); 160 | render( 161 | { 165 | confetti({ colors: ["#ffffff"] }); 166 | }} 167 | />, 168 | ); 169 | 170 | expect(Conductor).toBeCalledTimes(1); 171 | expect((Conductor as jest.Mock).mock.calls[0][0]).toEqual({ 172 | confetti, 173 | decorateOptions, 174 | }); 175 | 176 | expect(confetti).toBeCalledTimes(1); 177 | expect(confetti.mock.calls[0][0]).toEqual({ 178 | colors: ["#ffffff"], 179 | }); 180 | }); 181 | 182 | it("should handle autorun correctly", () => { 183 | const confettiInstance = jest.fn(); 184 | // @ts-ignore 185 | confettiInstance.reset = jest.fn(); 186 | 187 | (canvasConfetti.create as jest.Mock).mockReturnValueOnce( 188 | confettiInstance, 189 | ); 190 | 191 | render(); 192 | 193 | expect(conductorInstance.run).toBeCalledTimes(1); 194 | }); 195 | }); 196 | 197 | describe("Canvas props", () => { 198 | it("should apply props correctly", () => { 199 | render( 200 | , 207 | ); 208 | 209 | const canvas = getCanvasElement(); 210 | 211 | expect(canvas.getAttribute("width")).toBe("200"); 212 | expect(canvas.getAttribute("height")).toBe("100"); 213 | expect(canvas.getAttribute("class")).toBe("class"); 214 | expect(canvas.getAttribute("style")).toBe("opacity: 0.5;"); 215 | }); 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "module": "commonjs", 5 | "lib": ["DOM", "ES6"], 6 | "allowJs": false, 7 | "checkJs": false, 8 | "jsx": "react", 9 | "declaration": true, 10 | "outDir": "./dist", 11 | "rootDir": "./src", 12 | "removeComments": true, 13 | "noEmit": false, 14 | "importHelpers": false, 15 | "downlevelIteration": true, 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "moduleResolution": "node", 23 | "allowSyntheticDefaultImports": true, 24 | "esModuleInterop": true, 25 | "skipLibCheck": true, 26 | "forceConsistentCasingInFileNames": true 27 | }, 28 | "exclude": ["examples", "tests", "storybook"] 29 | } 30 | --------------------------------------------------------------------------------