(
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 |
13 |
14 |
15 |
16 | [](https://github.com/prettier/prettier)
17 | [](https://github.com/bitttttten/mechanical-counter/releases)
18 | [](https://github.com/bitttttten/mechanical-counter/compare/v1.0.15...main)
19 | [](https://www.npmjs.com/package/mechanical-counter)
20 |
21 |
22 |
23 | 
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 | 
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 |
13 |
14 |
15 |
16 | [](https://github.com/prettier/prettier)
17 | [](https://github.com/bitttttten/mechanical-counter/releases)
18 | [](https://github.com/bitttttten/mechanical-counter/compare/__version__...main)
19 | [](https://www.npmjs.com/package/mechanical-counter)
20 |
21 |
22 |
23 | 
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 | 
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 |
102 |
103 |
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 |
36 | random text
37 |
38 |
42 | random font
43 |
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 |
107 | random
108 |
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 |
134 | random
135 |
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 |
162 | random
163 |
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 |
186 | random
187 |
188 |
198 | change locale
199 |
200 | >
201 | );
202 | };
203 |
--------------------------------------------------------------------------------