├── .storybook ├── package.json ├── preview-head.html ├── main.js └── preview.js ├── docs ├── preview-2.webp └── preview.webp ├── .npmignore ├── tsconfig.json ├── .github └── workflows │ └── chromatic.yml ├── src ├── vertical.tsx └── index.tsx ├── README.md ├── package.json ├── README.template.md ├── .gitignore └── stories └── index.stories.tsx /.storybook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /docs/preview-2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitttttten/mechanical-counter/HEAD/docs/preview-2.webp -------------------------------------------------------------------------------- /docs/preview.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitttttten/mechanical-counter/HEAD/docs/preview.webp -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: [ 3 | "../stories/**/*.stories.mdx", 4 | "../stories/**/*.stories.@(js|jsx|ts|tsx)", 5 | ], 6 | addons: ["@storybook/addon-links", "@storybook/addon-essentials"], 7 | }; 8 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: "^on[A-Z].*" }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .circleci 2 | .github 3 | .storybook 4 | .vscode 5 | .babelrc 6 | babel.config.js 7 | .gitignore 8 | .prettierrc 9 | prettier.config.js 10 | tsconfig.* 11 | tslint.* 12 | jest.* 13 | webpack.* 14 | docs 15 | yarn-* 16 | __* 17 | src 18 | internal 19 | examples 20 | stories 21 | examples 22 | /*.png 23 | materials 24 | cypress* 25 | coverage 26 | jest.* 27 | tests 28 | .rts2_* 29 | node_modules 30 | .vscode/chrome 31 | src/ 32 | .eslintignore -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es2017", "es7", "es6", "dom"], 5 | "esModuleInterop": true, 6 | "allowSyntheticDefaultImports": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "preserve", 14 | "allowJs": true, 15 | "skipLibCheck": true, 16 | "resolveJsonModule": true 17 | }, 18 | "include": ["src/index.tsx"] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/chromatic.yml: -------------------------------------------------------------------------------- 1 | name: "chromatic" 2 | 3 | on: push 4 | 5 | jobs: 6 | chromatic-deployment: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | with: 11 | fetch-depth: 0 12 | 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: "14" 16 | 17 | - name: Cache dependencies 18 | id: cache 19 | uses: actions/cache@v2 20 | with: 21 | path: ./node_modules 22 | key: modules-${{ hashFiles('package-lock.json') }} 23 | 24 | - name: Install dependencies 25 | if: steps.cache.outputs.cache-hit != 'true' 26 | run: npm ci --ignore-scripts 27 | 28 | - uses: chromaui/action@v1 29 | with: 30 | token: ${{ secrets.GITHUB_TOKEN }} 31 | projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} 32 | -------------------------------------------------------------------------------- /src/vertical.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { motion } from "framer-motion"; 3 | 4 | interface VerticalProps { 5 | letter: string; 6 | } 7 | 8 | const chars = ["9", "8", "7", "6", "5", "4", "3", "2", "1", "0", ",", ".", "-"]; 9 | const amountOfItems = chars.length + 1; 10 | const containerHeight = `${amountOfItems}em`; 11 | 12 | // we make a map of char to index: 9 => 0, 8 => 1, 7 => 2, etc. 13 | const charsIndex = new Map( 14 | Array(chars.length).fill(true).map((_, index) => [chars[index], index]) 15 | ) 16 | 17 | const children = chars.map((char) =>
{char}
); 18 | 19 | export function Vertical({ letter }: VerticalProps) { 20 | const charIndex = charsIndex.get(letter); 21 | 22 | if (charIndex === undefined) { 23 | return {letter}; 24 | } 25 | 26 | const y = `${(-charIndex / (amountOfItems - 1)) * 100}%`; 27 | 28 | return ( 29 |
30 | 40 | {children} 41 | 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

✨ mechanical counter ✨

2 | 3 |
4 | robinhood inspired mechanical counter 5 |
6 |
7 | built with react and framer-motion 8 |
9 |
10 |
11 | 👉 live preview 👈 12 |
13 |
14 |
15 | 16 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 17 | [![Github release version](https://img.shields.io/github/tag/bitttttten/mechanical-counter.svg)](https://github.com/bitttttten/mechanical-counter/releases) 18 | [![Commits since release](https://img.shields.io/github/commits-since/bitttttten/mechanical-counter/v1.0.15.svg)](https://github.com/bitttttten/mechanical-counter/compare/v1.0.15...main) 19 | [![npm release version](https://img.shields.io/npm/v/mechanical-counter.svg)](https://www.npmjs.com/package/mechanical-counter) 20 | 21 |
22 | 23 | ![preview](./docs/preview.webp) 24 | 25 | ## Install 26 | 27 | ```sh 28 | npm i framer-motion mechanical-counter 29 | ``` 30 | 31 | ## Usage 32 | 33 | ```js 34 | import { MechanicalCounter } from "mechanical-counter"; 35 | 36 | export function App() { 37 | return ; 38 | } 39 | ``` 40 | 41 | ### Help 42 | 43 | The component will only animate numbers and common number separators: `,`, `.`, and `-`. If you want to include text before the number, then you must include that along side the component. It's totally fine to include non-supported characters in the text you send in to the component through the "text" prop, however, they must be added as a suffix to the text. 44 | 45 | Here is an example of adding text before the number, as a prefix, and also including some plain text—that is "unsupported characters"—after the number, as a suffix. 46 | 47 | ```js 48 |
49 | EU€ 50 | 51 |
52 | ``` 53 | 54 | The code above will result in the following: 55 | 56 | ![preview](./docs/preview-2.webp) 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mechanical-counter", 3 | "version": "1.0.15", 4 | "description": "mechanical counter for react built with framer-motion", 5 | "type": "module", 6 | "main": "./dist/index.cjs", 7 | "module": "./dist/index.module.js", 8 | "unpkg": "./dist/index.umd.js", 9 | "types": "./dist/index.d.ts", 10 | "source": "./src/index.tsx", 11 | "exports": { 12 | "require": "./dist/index.cjs", 13 | "default": "./dist/index.modern.js" 14 | }, 15 | "scripts": { 16 | "prebuild": "rimraf dist/", 17 | "build": "microbundle -i src/index.tsx --jsx React.createElement", 18 | "release": "npm run build && npm version patch && npm publish", 19 | "postrelease": "rm README.md && cp README.template.md README.md && sed -i \"\" \"s/__version__/$(git tag --sort=taggerdate | tail -1)/g\" README.md && git add README.md && git commit -m 'release' && git push --follow-tags", 20 | "postbuild": "rimraf dist/vertical*", 21 | "fix": "prettier --write 'src/**/*.tsx'", 22 | "storybook": "start-storybook -p 6006", 23 | "build-storybook": "build-storybook", 24 | "chromatic": "chromatic --exit-zero-on-changes" 25 | }, 26 | "keywords": [ 27 | "react", 28 | "framer", 29 | "framer-motion", 30 | "ticker", 31 | "mechanical counter", 32 | "mechanical", 33 | "counter" 34 | ], 35 | "author": "bitttttten ", 36 | "license": "ISC", 37 | "repository": "https://github.com/bitttttten/mechanical-counter", 38 | "devDependencies": { 39 | "@babel/core": "^7.15.8", 40 | "@storybook/addon-actions": "^6.3.12", 41 | "@storybook/addon-essentials": "^6.3.12", 42 | "@storybook/addon-links": "^6.3.12", 43 | "@storybook/react": "^6.3.12", 44 | "@types/react": "^18.0.21", 45 | "@types/react-dom": "^18.0.6", 46 | "babel-loader": "^8.2.3", 47 | "chromatic": "^6.0.4", 48 | "eslint": "^8.0.1", 49 | "framer-motion": "^4.1.17", 50 | "microbundle": "^0.14.1", 51 | "prettier": "^2.4.1", 52 | "react": "^17.0.2", 53 | "react-dom": "^17.0.2", 54 | "rimraf": "^3.0.2" 55 | }, 56 | "peerDependencies": { 57 | "framer-motion": "^4.1.17", 58 | "react": "^17.0.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /README.template.md: -------------------------------------------------------------------------------- 1 |

✨ mechanical counter ✨

2 | 3 |
4 | robinhood inspired mechanical counter 5 |
6 |
7 | built with react and framer-motion 8 |
9 |
10 |
11 | 👉 live preview 👈 12 |
13 |
14 |
15 | 16 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 17 | [![Github release version](https://img.shields.io/github/tag/bitttttten/mechanical-counter.svg)](https://github.com/bitttttten/mechanical-counter/releases) 18 | [![Commits since release](https://img.shields.io/github/commits-since/bitttttten/mechanical-counter/__version__.svg)](https://github.com/bitttttten/mechanical-counter/compare/__version__...main) 19 | [![npm release version](https://img.shields.io/npm/v/mechanical-counter.svg)](https://www.npmjs.com/package/mechanical-counter) 20 | 21 |
22 | 23 | ![preview](./docs/preview.webp) 24 | 25 | ## Install 26 | 27 | ```sh 28 | npm i framer-motion mechanical-counter 29 | ``` 30 | 31 | ## Usage 32 | 33 | ```js 34 | import { MechanicalCounter } from "mechanical-counter"; 35 | 36 | export function App() { 37 | return ; 38 | } 39 | ``` 40 | 41 | ### Help 42 | 43 | The component will only animate numbers and common number separators: `,`, `.`, and `-`. If you want to include text before the number, then you must include that along side the component. It's totally fine to include non-supported characters in the text you send in to the component through the "text" prop, however, they must be added as a suffix to the text. 44 | 45 | Here is an example of adding text before the number, as a prefix, and also including some plain text—that is "unsupported characters"—after the number, as a suffix. 46 | 47 | ```js 48 |
49 | EU€ 50 | 51 |
52 | ``` 53 | 54 | The code above will result in the following: 55 | 56 | ![preview](./docs/preview-2.webp) 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | .env.production 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | .parcel-cache 80 | 81 | # Next.js build output 82 | .next 83 | out 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and not Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | 110 | # Stores VSCode versions used for testing VSCode extensions 111 | .vscode-test 112 | 113 | # yarn v2 114 | .yarn/cache 115 | .yarn/unplugged 116 | .yarn/build-state.yml 117 | .yarn/install-state.gz 118 | .pnp.* 119 | 120 | 121 | .DS_Store 122 | 123 | .vscode 124 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useEffect, useRef, useState } from "react"; 2 | import { motion, AnimatePresence } from "framer-motion"; 3 | import { Vertical } from "./vertical"; 4 | 5 | export interface MechanicalCounterProps { 6 | text: string | number; 7 | height?: string | number; 8 | } 9 | 10 | const transition = { ease: "easeOut" }; 11 | 12 | export function MechanicalCounter({ 13 | text, 14 | height = "1em", 15 | }: MechanicalCounterProps) { 16 | const [isLoaded, set] = useState(false); 17 | const ref = useRef(null); 18 | const getTextStats = useMemo(() => generateTextStats(ref), [ref]); 19 | 20 | useEffect(() => { 21 | if ( 22 | typeof document !== "undefined" && 23 | typeof document.fonts.ready === "object" 24 | ) { 25 | document.fonts.ready.finally(() => set(true)); 26 | } else { 27 | set(true); 28 | } 29 | }, []); 30 | 31 | const baseStyles = { 32 | height, 33 | lineHeight: typeof height === "string" ? height : `${height}px`, 34 | }; 35 | 36 | if (!isLoaded) { 37 | // we need the height to calculate the font stats 38 | // so we need opacity 0 to hide the font until we have it 39 | return ( 40 |
41 | {text} 42 |
43 | ); 44 | } 45 | 46 | const textArray = String(text).split(""); 47 | const stats = textArray.map(getTextStats); 48 | const totalWidth = Math.ceil(stats.reduce(count, 0)); 49 | 50 | return ( 51 | 65 | {/* this is the text that the user can select and copy */} 66 | 75 | {text} 76 | 77 | 78 | 79 | {textArray.map((letter, index) => { 80 | const x = stats.slice(0, index).reduce(count, 0); 81 | const width = stats[index]; 82 | 83 | // animate from the right to left, so we need to invert the index 84 | const key = `${textArray.length - index}`; 85 | 86 | return ( 87 | 104 | ); 105 | })} 106 | 107 | 108 | ); 109 | } 110 | 111 | function count(acc: number, curr: number) { 112 | return acc + curr; 113 | } 114 | 115 | function generateTextStats(ref: React.RefObject) { 116 | const cache = new Map(); 117 | 118 | // safety for nodejs/ssr 119 | if (typeof document === "undefined") { 120 | return function (letter: string): number { 121 | return cache.get(letter) ?? 0; 122 | }; 123 | } 124 | 125 | let hasCalculatedFont = false; 126 | const canvas = document.createElement("canvas"); 127 | const context = canvas.getContext("2d") as CanvasRenderingContext2D; 128 | 129 | return function (letter: string): number { 130 | if (!cache.has(letter)) { 131 | if (!hasCalculatedFont) { 132 | context.font = getFont(ref.current ?? document.body); 133 | hasCalculatedFont = true; 134 | } 135 | cache.set(letter, context.measureText(letter)?.width ?? 0); 136 | } 137 | 138 | return cache.get(letter) ?? 0; 139 | }; 140 | } 141 | 142 | function getFont(element: HTMLElement) { 143 | const font = getComputedStyle(element).getPropertyValue("font"); 144 | 145 | if (font) { 146 | return font; 147 | } 148 | 149 | const fontFamily = getComputedStyle(element).getPropertyValue("font-family"); 150 | const fontSize = getComputedStyle(element).getPropertyValue("font-size"); 151 | 152 | return `${fontSize} / ${fontSize} ${fontFamily}`; 153 | } 154 | -------------------------------------------------------------------------------- /stories/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, Story } from "@storybook/react"; 2 | import { useState } from "react"; 3 | import { MechanicalCounter, MechanicalCounterProps } from "../src/index"; 4 | 5 | export default { title: "MechanicalCounter" } as Meta; 6 | 7 | const Template: Story = (props) => { 8 | const { format } = new Intl.NumberFormat("en", { currency: "EUR" }); 9 | const [text, set] = useState(1234567890); 10 | const [fontFamily, setFontFamily] = useState("system-ui"); 11 | 12 | const onRandomNumber = () => { 13 | set(generateRandomNumber()); 14 | }; 15 | 16 | const onRandomFontFamily = () => { 17 | const nextFont = generateRandomFont(); 18 | if (nextFont === fontFamily) { 19 | return onRandomFontFamily(); 20 | } 21 | setFontFamily(nextFont); 22 | }; 23 | 24 | return ( 25 |
31 | 32 | 38 | 44 |

45 | rendering {text} 46 |

47 |
48 | ); 49 | }; 50 | 51 | const generateRandomFont = () => { 52 | const fonts = [ 53 | "Arial", 54 | "Tangerine", 55 | "Verdana", 56 | "Inconsolata", 57 | "Helvetica", 58 | "'Droid Sans'", 59 | "'Passions Conflict'", 60 | "system-ui", 61 | "Comic Sans", 62 | "'Bungee Spice'", 63 | ]; 64 | 65 | return fonts[Math.floor(Math.random() * fonts.length)]; 66 | }; 67 | 68 | const generateRandomNumber = () => { 69 | return parseFloat( 70 | String(Math.random()) 71 | .split("") 72 | .slice(3, 7 + Math.floor(Math.random() * 4)) 73 | .join("") 74 | ); 75 | }; 76 | 77 | export const Default = Template.bind({}); 78 | export const Height = Template.bind({}); 79 | Height.args = { height: `2em` }; 80 | export const HeightAsNumber = Template.bind({}); 81 | HeightAsNumber.args = { height: 30 }; 82 | 83 | export const WithContainer: Story = (props) => { 84 | const { format } = new Intl.NumberFormat("en", { currency: "EUR" }); 85 | const [text, set] = useState(1234567890); 86 | const [fontFamily, setFontFamily] = useState("system-ui"); 87 | 88 | const onRandomNumber = () => { 89 | set(generateRandomNumber()); 90 | }; 91 | 92 | return ( 93 |
100 |
101 | 102 |
103 | 109 |
110 | ); 111 | }; 112 | 113 | export const WithPrefix = () => { 114 | const { format } = new Intl.NumberFormat("en", { currency: "EUR" }); 115 | const [text, set] = useState(123456789); 116 | const onRandomNumber = () => { 117 | set(generateRandomNumber()); 118 | }; 119 | 120 | return ( 121 |
127 |
128 | EU€ 129 |
130 | 136 |
137 | ); 138 | }; 139 | 140 | export const WithPrefixAndSuffix = () => { 141 | const { format } = new Intl.NumberFormat("en", { currency: "EUR" }); 142 | const [text, set] = useState(123456789); 143 | const onRandomNumber = () => { 144 | set(generateRandomNumber()); 145 | }; 146 | 147 | return ( 148 |
154 |
155 | EU€ 156 | 157 |
158 | 164 |
165 | ); 166 | }; 167 | 168 | export const WithChangeOfLocale = () => { 169 | const [text, set] = useState(123456789.42); 170 | const [locale, setLocale] = useState("en"); 171 | const { format } = new Intl.NumberFormat(locale, { currency: "EUR" }); 172 | const onRandomNumber = () => { 173 | set(generateRandomNumber() / 100); 174 | }; 175 | const onChangeLocale = () => { 176 | setLocale(locale === "en" ? "nl" : "en"); 177 | }; 178 | 179 | return ( 180 | <> 181 | 182 | 188 | 200 | 201 | ); 202 | }; 203 | --------------------------------------------------------------------------------