├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── example ├── .env.example ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── components │ ├── Table │ │ ├── Table.module.css │ │ └── index.tsx │ ├── TypeAnimation.tsx │ └── examples │ │ └── index.tsx ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package-prod.json ├── package.json ├── pages │ ├── _app.mdx │ ├── _meta.json │ ├── accessibility.mdx │ ├── examples.mdx │ ├── index.mdx │ ├── options.mdx │ └── wrapper-css.mdx ├── postcss.config.js ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── site.webmanifest ├── styles │ └── globals.css ├── tailwind.config.js ├── theme.config.tsx └── tsconfig.json ├── global.d.ts ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── .eslintrc ├── components │ ├── TypeAnimation │ │ ├── index.module.css │ │ ├── index.tsx │ │ └── index.types.ts │ └── index.ts ├── hooks │ ├── useEffectOnce.tsx │ └── useForwardRef.tsx ├── index.ts └── typical.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false 5 | }], 6 | "stage-0", 7 | "react" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "react-app"], 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "env": { 6 | "es6": true 7 | }, 8 | "parserOptions": { 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "no-shadow": "warn", 13 | "jsx-a11y/anchor-is-valid": "off", 14 | "space-before-function-paren": "off", 15 | "@typescript-eslint/ban-ts-comment": "off", 16 | "react/jsx-boolean-value": "off", 17 | "semi": "off", 18 | "react/prop-types": "off" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | .dccache -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 maxeth 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-type-animation 2 | 3 | [![npm version](https://badge.fury.io/js/react-type-animation.svg)][npm_url] 4 | [![downloads](https://img.shields.io/npm/dt/react-type-animation.svg)][npm_url] 5 | [![license](https://img.shields.io/npm/l/react-type-animation.svg)][npm_url] 6 | 7 | A customizable React typing animation component. 8 | 9 | [npm_url]: https://www.npmjs.org/package/react-type-animation 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm install react-type-animation 15 | ``` 16 | 17 | or 18 | 19 | ```bash 20 | yarn add react-type-animation 21 | ``` 22 | 23 | Requires a `react` and `react-dom` version of at least `15.0.0`. 24 | 25 | ## Live Demo 26 | 27 | A live demo and usage examples of the animation can be found at [https://react-type-animation.netlify.app/examples](https://react-type-animation.netlify.app/examples). 28 | 29 | ## Usage 30 | 31 | A common typewriter animation for a landing page could look like this: 32 | 33 | ```jsx 34 | import { TypeAnimation } from 'react-type-animation'; 35 | 36 | const ExampleComponent = () => { 37 | return ( 38 | 55 | ); 56 | }; 57 | ``` 58 | 59 | ## Documentation 60 | 61 | The docs with props, options and common problem solutions can be found at: [https://react-type-animation.netlify.app/](https://react-type-animation.netlify.app/). 62 | 63 | ## Migrating to v3 64 | 65 | The default wrapper is now `` instead of `
`: **To migrate**, add a `display: inline-block/block` or `wrapper="div"` to all `` occurances with unspecified wrapper. 66 | 67 | ## Usage Notes 68 | 69 | ### Immutability 70 | 71 | Due to the nature of the animation, this component is **permanently memoized**, which means that the component **never** re-renders unless you hard-reload the page, and hence **props changes will not be reflected**. 72 | 73 | ### Hot Reload NOT Supported 74 | 75 | Because the TypeAnimation component is memoized and **never** re-rendered (see above), yet Hot Reload attempts to re-render the component, **changes to the TypeAnimation component will not render until you hard-reload the page**. 76 | 77 | Hence, whenever you make changes to the TypeAnimation component, you unfortunately have to reload your page. 78 | 79 | ## Props 80 | 81 | See [https://react-type-animation.netlify.app/options](https://react-type-animation.netlify.app/options) for more details. 82 | 83 | | Prop | Required | Type | Example | Description | Default | 84 | | ----------------------- | -------- | -------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------ | 85 | | `sequence` | yes | Array void | Promise)> | `['One', 1000, 'Two', () => console.log("done")]` | Animation sequence: [TEXT, DELAY-MS, CALLBACK] | `-` | 86 | | `wrapper` | no | string | `p`,`h2`,`div`, `strong` | HTML element tag that wraps the typing animation | `span` | 87 | | `speed` | no | 1,2,..,99 | {type: "keyStrokeDelayInMs", value: number} | `45`, `{type: "keyStrokeDelayInMs", value: 100}` | Speed for the writing of the animation | `40` | 88 | | `deletionSpeed` | no | 1,2,..,99 | {type: "keyStrokeDelayInMs", value: number} | `45`, `{type: "keyStrokeDelayInMs", value: 100}` | Speed for deleting of the animation | `speed` | 89 | | `omitDeletionAnimation` | no | boolean | `false`, `true` | If true, deletions will be instant and without animation | `false` | 90 | | `repeat` | no | number | `0`, `3`, `Infinity` | Amount of animation repetitions | `0` | 91 | | `cursor` | no | boolean | `false`, `true` | Display default blinking cursor css-animation | `true` | 92 | | `preRenderFirstString` | no | boolean | `false`, `true` | If true, the first string of your sequence will not be animated and initially (pre-)rendered | `true` | 93 | | `className` | no | string | `custom-class-name` | HTML class name applied to the wrapper to style the text | `-` | 94 | | `style` | no | object | `{fontSize: '2em'}` | JSX inline style object | `-` | 95 | | `ref` | no | HTMLElement | null | `-` | `-` | `-` | 96 | | `splitter` | no | (text: string) => Array | `(str) => new GraphemeSplitter().splitGraphemes(str)` | Used for splitting complex characters, see [grapheme-splitter](https://github.com/orling/grapheme-splitter) for more details | `String.split('')` | 97 | 98 | --- 99 | 100 |
101 |
102 |
103 | 104 | [npm](https://www.npmjs.com/package/react-type-animation) / [github](https://github.com/maxeth/react-type-animation/) 105 | Credits: [typical](https://github.com/camwiegert/typical) 106 | -------------------------------------------------------------------------------- /example/.env.example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxeth/react-type-animation/512794fab28a5f21bb083efd6427a79502aa2dae/example/.env.example -------------------------------------------------------------------------------- /example/.eslintignore: -------------------------------------------------------------------------------- 1 | .next 2 | node_modules 3 | -------------------------------------------------------------------------------- /example/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | package-dev.json -------------------------------------------------------------------------------- /example/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /example/components/Table/Table.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | mask-image: linear-gradient( 3 | to right, 4 | transparent 0.8em, 5 | white 1.5em, 6 | white calc(100% - 1.5em), 7 | transparent calc(100% - 0.8em) 8 | ); 9 | } 10 | 11 | .container::-webkit-scrollbar { 12 | appearance: none; 13 | } 14 | -------------------------------------------------------------------------------- /example/components/Table/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Table.module.css'; 2 | 3 | export function OptionTable({ 4 | options, 5 | }: { 6 | options: [string, string, any, string, string]; 7 | }) { 8 | return ( 9 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {options.map(([option, type, description, example, defaultVal]) => ( 27 | 31 | 34 | 37 | 38 | 41 | 44 | 45 | ))} 46 | 47 |
NameTypeDescriptionExampleDefault
32 | {option} 33 | 35 | {type} 36 | {description} 39 | {example} 40 | 42 | {defaultVal} 43 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /example/components/TypeAnimation.tsx: -------------------------------------------------------------------------------- 1 | import { TypeAnimation } from 'react-type-animation'; 2 | import { TypeAnimationProps } from 'react-type-animation/dist/esm/components/TypeAnimation/index.types'; 3 | 4 | export default function _TypeAnimation(props: TypeAnimationProps) { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /example/components/examples/index.tsx: -------------------------------------------------------------------------------- 1 | import GraphemeSplitter from 'grapheme-splitter'; 2 | import { useState } from 'react'; 3 | import { TypeAnimation as RawTypeAnimation } from 'react-type-animation'; 4 | import TypeAnimation from '../TypeAnimation'; 5 | 6 | export function CallbackExample() { 7 | const [typingStatus, setTypingStatus] = useState('Initializing'); 8 | 9 | return ( 10 | <> 11 |
12 | { 16 | setTypingStatus('Typing...'); 17 | }, 18 | 'Use callback-functions to trigger events', 19 | () => { 20 | setTypingStatus('Done Typing'); 21 | }, 22 | 1000, 23 | () => { 24 | setTypingStatus('Deleting...'); 25 | }, 26 | 27 | '', 28 | () => { 29 | setTypingStatus('Done Deleting'); 30 | }, 31 | ]} 32 | speed={70} 33 | repeat={Infinity} 34 | /> 35 |
36 |
37 | typingStatus:{' '} 38 | {typingStatus} 39 |
40 | 41 | ); 42 | } 43 | 44 | export function LandingPageExample() { 45 | return ( 46 |
47 | 63 |
64 | ); 65 | } 66 | 67 | export function SplitterByWordExample() { 68 | return ( 69 |
70 | str.split(/(?= )/)} // 'Lorem ipsum dolor' -> ['Lorem', ' ipsum', ' dolor'] 80 | repeat={Infinity} 81 | /> 82 |
83 | ); 84 | } 85 | 86 | export function SplitterComplexCharactersExample() { 87 | const splitter = new GraphemeSplitter(); 88 | return ( 89 |
90 | splitter.splitGraphemes(str)} 109 | repeat={Infinity} 110 | /> 111 |
112 | ); 113 | } 114 | 115 | export function LandingPagePreTypedExample() { 116 | return ( 117 | 121 |
122 | 139 |
140 |
141 | ); 142 | } 143 | 144 | export function ContinuationExample() { 145 | return ( 146 | 165 | ); 166 | } 167 | 168 | export function ReplacementExample() { 169 | return ( 170 | 176 | ); 177 | } 178 | 179 | export function MultipleLinesExample() { 180 | return ( 181 |
182 | 193 |
194 | ); 195 | } 196 | 197 | export function StateManipulationColorExample() { 198 | const [textColor, setTextColor] = useState('red'); 199 | 200 | return ( 201 | <> 202 |
210 | setTextColor('aqua'), 215 | 'One Two', 216 | 800, 217 | () => setTextColor('deeppink'), 218 | 'One Two Three', 219 | 1000, 220 | () => setTextColor('darkkhaki'), 221 | '', 222 | ]} 223 | repeat={Infinity} 224 | /> 225 |
226 | 246 | 247 | ); 248 | } 249 | 250 | export function RemoveCursorExample() { 251 | const CURSOR_CLASS_NAME = 'custom-type-animation-cursor'; 252 | 253 | return ( 254 | <> 255 | 256 | { 269 | el.classList.remove(CURSOR_CLASS_NAME); 270 | }, 271 | 6000, 272 | (el) => { 273 | el.classList.add(CURSOR_CLASS_NAME); 274 | }, 275 | '', 276 | ]} 277 | repeat={Infinity} 278 | /> 279 | 280 | {/* Works the same with simple css classes or css modules */} 281 | 292 | 293 | ); 294 | } 295 | 296 | export function CustomSpeedExample() { 297 | return ( 298 | <> 299 |
300 | 305 |
306 |
{'speed={75}'}
307 |
308 | 313 |
314 |
{'speed={40} (default)'}
315 |
316 | 321 |
322 |
323 | {"speed={{type: 'keyStrokeDelayInMs', value: 250}}"} 324 |
325 |
326 | 331 |
332 |
{'deletionSpeed={90}'}
{' '} 333 |
334 | 347 |
348 |
{'omitDeletionAnimation={true}'}
{' '} 349 | 350 | ); 351 | } 352 | 353 | export function SpanCollapsingExample() { 354 | return ( 355 |
362 | 371 |
372 | ); 373 | } 374 | 375 | export function DisplayBlockCollapsingExample() { 376 | return ( 377 |
383 | 394 |
395 | ); 396 | } 397 | 398 | export function WordBreakExample(props: any) { 399 | return ( 400 |
406 |
407 |
default:
408 | 418 |
419 |
420 |
word-break: break-all
421 | 432 |
433 |
434 | ); 435 | } 436 | -------------------------------------------------------------------------------- /example/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /example/next.config.js: -------------------------------------------------------------------------------- 1 | const withNextra = require('nextra')({ 2 | theme: 'nextra-theme-docs', 3 | themeConfig: './theme.config.tsx', 4 | }); 5 | 6 | module.exports = withNextra(); 7 | -------------------------------------------------------------------------------- /example/package-prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "front-end", 3 | "version": "0.1.0", 4 | "private": false, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "next": "^12.2.4", 12 | "react": "^18.2.0", 13 | "react-dom": "^18.2.0", 14 | "react-syntax-highlighter": "^15.4.3", 15 | "react-type-animation": "^2.1.2" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^18.6.4", 19 | "@types/react": "^18.0.16", 20 | "@types/react-dom": "^18.0.6", 21 | "@types/tailwindcss": "^2.0.2", 22 | "@typescript-eslint/eslint-plugin": "^4.0.0", 23 | "@typescript-eslint/parser": "^4.0.0", 24 | "autoprefixer": "^10.2.5", 25 | "babel-eslint": "^10.0.0", 26 | "eslint": "^7.5.0", 27 | "eslint-config-react-app": "^6.0.0", 28 | "eslint-plugin-flowtype": "^5.2.0", 29 | "eslint-plugin-import": "^2.22.0", 30 | "eslint-plugin-jsx-a11y": "^6.3.1", 31 | "eslint-plugin-react": "^7.20.3", 32 | "eslint-plugin-react-hooks": "^4.0.8", 33 | "postcss": "^8.2.9", 34 | "prettier": "^2.2.1", 35 | "tailwindcss": "^2.1.1", 36 | "typescript": "^4.7.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "front-end", 3 | "version": "0.1.0", 4 | "private": false, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "grapheme-splitter": "^1.0.4", 12 | "next": "^12.3.4", 13 | "nextra": "^2.2.19", 14 | "nextra-theme-docs": "^2.2.19", 15 | "react-syntax-highlighter": "^15.4.3", 16 | "react-type-animation": "latest" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^18.6.4", 20 | "@types/react": "^18.0.16", 21 | "@types/react-dom": "^18.0.6", 22 | "@types/tailwindcss": "^2.0.2", 23 | "@typescript-eslint/eslint-plugin": "^4.0.0", 24 | "@typescript-eslint/parser": "^4.0.0", 25 | "autoprefixer": "^10.2.5", 26 | "babel-eslint": "^10.0.0", 27 | "eslint": "^7.5.0", 28 | "eslint-config-react-app": "^6.0.0", 29 | "eslint-plugin-flowtype": "^5.2.0", 30 | "eslint-plugin-import": "^2.22.0", 31 | "eslint-plugin-jsx-a11y": "^6.3.1", 32 | "eslint-plugin-react": "^7.20.3", 33 | "eslint-plugin-react-hooks": "^4.0.8", 34 | "postcss": "^8.2.9", 35 | "prettier": "^2.2.1", 36 | "react": "^18.2.0", 37 | "react-dom": "^18.2.0", 38 | "tailwindcss": "^3.2.7", 39 | "typescript": "^4.7.4" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /example/pages/_app.mdx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | 3 | export default function App({ Component, pageProps }) { 4 | return 5 | 6 | } 7 | -------------------------------------------------------------------------------- /example/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": "Get Started", 3 | "examples": "Examples", 4 | "options": "Options / Props", 5 | "wrapper-css": "Wrapper & CSS tips", 6 | "accessibility": "Accessibility" 7 | } 8 | -------------------------------------------------------------------------------- /example/pages/accessibility.mdx: -------------------------------------------------------------------------------- 1 | import { Callout } from 'nextra-theme-docs'; 2 | 3 | # Accessibility 4 | 5 | Because the typing animation _A)_ delays and _B)_ constantly manipulates the written text, it is not only bothersome but sometimes even **impossible for screen readers** to capture the entire text at once. 6 | 7 | If your type animation component includes **actual text ("content")** or conveys an important message that is not purely decorative, you should make your typewriter animation accessible to screen readers. 8 | 9 | ## Visually-hidden Class 10 | 11 | The perhaps best approach to make a typewriter animation accessible is **1.)** additionally rendering the conveyed message of the `` in a separate wrapper with a _visually-hidden_ class that only hides the content for sighted users and **2.)** setting `aria-hidden="true"` on the `` to remove it from the a11y tree: 12 | 13 | ```tsx {2-16,18} 14 | 15 | 27 | {/* The most important content of the typewriter animation: Hidden from sighted viewers but (in most cases) accessible to screen readers */} 28 | We produce food for Mice, Hamsters, Guinea Pigs and Chinchillas 29 | 30 | 45 | ``` 46 | 47 | ## Alternative: aria-label 48 | 49 | One can also add an `aria-label` directly on the `` component to convey the most important contents of the typewriter animation: 50 | 51 | ```tsx {2} 52 | 69 | ``` 70 | 71 | Setting an `aria-label` requires setting a `role` as well. The role [`marquee`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/marquee_role) is probably the most suitable for this situation. 72 | 73 | By applying an `aria-label`, the dynamically typed contents of your sequence will automatically be wrapped in a `` with `aria-hidden="true"` and removed from the accessibility tree: 74 | 75 | ```tsx {aria-hidden="true"} 76 | {/* Rendered HTML: */} 77 |
80 | 81 |
82 | ``` 83 | 84 | 85 | Note: `aria-label` should generally only be used on interactive elements, 86 | which may make this approach not ideal. Certain screen readers may even 87 | disregard this aria-label entirely as it is applied to a non-interactive 88 | wrapper element. 89 | 90 | -------------------------------------------------------------------------------- /example/pages/examples.mdx: -------------------------------------------------------------------------------- 1 | import { 2 | CallbackExample, 3 | ContinuationExample, 4 | StateManipulationColorExample, 5 | LandingPageExample, 6 | LandingPagePreTypedExample, 7 | ReplacementExample, 8 | CustomSpeedExample, 9 | MultipleLinesExample, 10 | RemoveCursorExample, 11 | SplitterByWordExample, 12 | SplitterComplexCharactersExample 13 | } from '../components/examples'; 14 | import { Callout, Tabs, Tab } from 'nextra-theme-docs'; 15 | 16 | # Examples 17 | 18 | --- 19 | 20 | ## Landing Page Animations 21 | 22 | ### Dynamic 23 | 24 | Include the initial text in every string, and the **static part will only be typed out once**. 25 | 26 |
27 | 28 | 29 | 30 | ```tsx copy 31 | 47 | ``` 48 | 49 | 50 | Note: Typing complex characters like emojis requires a custom [splitter function](#typing-complex-characters). 51 | 52 | 53 | 54 | 55 | ### Initially Pre-rendered 56 | 57 | By using the `preRenderFirstString` prop, you can initially (pre-)render the very first string of your sequence. When used with SSR (Next.js or similar), the initial string will be included in the static HTML, which may benefit SEO. 58 | 59 |
60 | 61 | 62 | 63 | ```tsx copy {3} 64 |
65 | 82 |
83 | ``` 84 | 85 | --- 86 | 87 | ## Continuation 88 | 89 |
90 | 91 | 92 | 93 | ```tsx copy 94 | 112 | ``` 113 | 114 | --- 115 | 116 | ## Replacement 117 | 118 |
119 | 120 | 121 | 122 | ```tsx copy 123 | 128 | ``` 129 | 130 | --- 131 | 132 | ## Custom Speed 133 | 134 | As mentioned in the [props](/options#component-props) section, you can specify both the typing `speed` and `deletionSpeed` with a simple relative number between 1-99 or an exactly specified keystroke delay. 135 | 136 |
137 | 138 | 139 | 140 | 141 | Note: The animation **adds a random delay relative to your 142 | provided `speed` and `deletionSpeed`** after each keystroke to make the typing 143 | animation look more natural. 144 | 145 | 146 | --- 147 | 148 | ## Multiple Lines 149 | 150 | By addding the `white-space: pre-line` css style and placing `'\n'` anywhere in your text, or making actual line breaks inside a string literal, you can write in multiple lines. 151 | 152 |
153 | 154 | 155 | 156 | ```tsx copy /whiteSpace: 'pre-line'/ /\n/ 157 | 168 | ``` 169 | 170 | 171 | Using the explicit `\n` new-line is preferred, because your code formatter may 172 | add spaces in new lines of the string literal that will be typed out as an 173 | empty string and hence unintentionally delay the animation. 174 | 175 | 176 | 177 | Pre-define the height and width of the parent element to prevent layout shift 178 | when typing multi-lines 179 | 180 | 181 | --- 182 | 183 | ## Callback Functions 184 | 185 | Use callback functions at any place inside of your animation 186 | sequence to perform any (global) actions you want. An exemplary 187 | use-case for this is calling functions or state updates that [manipulate the styles](#manipulation-via-state) of your animation component, or let your 188 | application know at which state of typing the animation currently 189 | is, and adjusting some other visual elements accordingly. 190 | 191 |
192 | 193 | 194 | 195 | ```jsx copy 196 | const [typingStatus, setTypingStatus] = useState('Initializing'); 197 | 198 | { 202 | setTypingStatus('Typing...'); 203 | }, 204 | 'Use callback-functions to trigger events', 205 | () => { 206 | setTypingStatus('Done Typing'); 207 | }, 208 | 1000, 209 | () => { 210 | setTypingStatus('Deleting...'); 211 | }, 212 | '', 213 | () => { 214 | setTypingStatus('Done Deleting'); 215 | }, 216 | ]} 217 | repeat={Infinity} 218 | />; 219 | ``` 220 | 221 | ### Manipulation via CSS Classes 222 | 223 | It's possible to manipulate the animation styles in order to, for example, **stop the cursor animation at a specific point within the animation sequence**: 224 | 225 |
226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | ```tsx {6,10,17,19} 234 | const CURSOR_CLASS_NAME = 'custom-type-animation-cursor'; 235 | 236 | return ( 237 | <> 238 | el.classList.remove(CURSOR_CLASS_NAME), // A reference to the element gets passed as the first argument of a callback function 251 | 6000, 252 | (el) => el.classList.add(CURSOR_CLASS_NAME), 253 | '', 254 | ]} 255 | repeat={Infinity} 256 | /> 257 | 258 | 269 | 270 | ); 271 | ``` 272 | 273 | 274 | 275 | 276 | ```tsx {1,10,12,14,22-23,27,34,36} 277 | const ref = React.createRef(); // HTMLSpanElement because 'span' is the default wrapper element of the component 278 | 279 | const CURSOR_CLASS_NAME = 'custom-type-animation-cursor'; 280 | 281 | const showCursorAnimation = (show: boolean) => { 282 | if (!ref.current) { 283 | return; 284 | } 285 | 286 | const el = ref.current; 287 | if (show) { 288 | el.classList.add(CURSOR_CLASS_NAME); 289 | } else { 290 | el.classList.remove(CURSOR_CLASS_NAME); 291 | } 292 | }; 293 | 294 | return ( 295 | 296 | <> 297 | showCursorAnimation(false), 311 | 2000, 312 | () => showCursorAnimation(true), 313 | '', 314 | ]} 315 | repeat={Infinity} 316 | /> 317 | 318 | {/* Copy over the default typing styles. Also works with simple global css files or css modules */} 319 | 330 | 331 | 332 | ); 333 | ``` 334 | 335 | 336 | 337 | 338 | ### Manipulation via State 339 | 340 | By applying dynamic styles to **the parent element** of the `TypeAnimation` component, you can easily manipulate styles without classNames and passing ref. 341 | 342 |
343 | 344 | 345 | 346 | ```tsx {1,8,17,20,23,43} 347 | const [textColor, setTextColor] = useState('red'); 348 | 349 | return ( 350 | <> 351 |
359 | setTextColor('aqua'), 364 | 'One Two', 365 | 800, 366 | () => setTextColor('deeppink'), 367 | 'One Two Three', 368 | 1000, 369 | () => setTextColor('darkkhaki'), 370 | '', 371 | ]} 372 | repeat={Infinity} 373 | /> 374 |
375 | 394 | 395 | ); 396 | ``` 397 | 398 |
399 | 400 | ## Custom String Splitter 401 | 402 | By default, strings placed inside the `sequence` are split character by character, to simulate keyboard-like typing. With he help of the `splitter` prop, it's possible to define a custom splitting of sequence strings. 403 | 404 | ### Typing Word by Word 405 | 406 | To create a word-level typing animation similar to ChatGPT or other AI chatbots, we can **split strings into single words**, rather than characters. 407 | 408 | 409 | ```tsx {2,8} 410 | str.split(/(?= )/)} // 'Lorem ipsum dolor' -> ['Lorem', ' ipsum', ' dolor'] 412 | sequence={[ 413 | 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', 414 | 3000, 415 | '', 416 | ]} 417 | speed={{ type: 'keyStrokeDelayInMs', value: 30 }} 418 | omitDeletionAnimation={true} 419 | style={{ fontSize: '1em', display: 'block', minHeight: '200px' }} 420 | repeat={Infinity} 421 | /> 422 | ``` 423 | 424 | ### Typing Complex Characters 425 | 426 | As certain complex Unicode characters, like **emojis**, are internally represented as multiple characters in JavaScript, including them in our animation requires an advanced string splitter, such as [grapheme-splitter](https://www.npmjs.com/package/grapheme-splitter), capable of splitting those characters into *extended grapheme clusters* (single letters). 427 | 428 | 429 | 430 | 431 | ```tsx {1,3,7} 432 | import GraphemeSplitter from 'grapheme-splitter'; // npm i grapheme-splitter 433 | 434 | const splitter = new GraphemeSplitter(); 435 | 436 | return ( 437 | splitter.splitGraphemes(str)} 439 | sequence={[ 440 | 'Hello 🇬🇧', 441 | 2000, 442 | 'Ciao 🇮🇹', 443 | 2000, 444 | '你好 🇨🇳', 445 | 2000, 446 | 'Здравейте 🇧🇬 ', 447 | 2000, 448 | 'Hola 🇪🇸', 449 | 2000, 450 | 'Bonjour 🇫🇷', 451 | 2000, 452 | 'नमस्ते 🇮🇳', 453 | 2000 454 | ]} 455 | style={{ fontSize: '2em' }} 456 | repeat={Infinity} 457 | /> 458 | ); 459 | ``` -------------------------------------------------------------------------------- /example/pages/index.mdx: -------------------------------------------------------------------------------- 1 | import TypeAnimation from '../components/TypeAnimation'; 2 | import { Callout, Tab, Tabs, Steps } from 'nextra-theme-docs'; 3 | 4 | # Get Started 5 | 6 | 7 | 8 | ### Installation 9 | 10 | 11 | 12 | 13 | ```bash copy 14 | npm i react-type-animation 15 | ``` 16 | 17 | 18 | ```bash copy 19 | pnpm i react-type-animation 20 | ``` 21 | 22 | 23 | ```bash copy 24 | yarn add react-type-animation 25 | ``` 26 | 27 | 28 | 29 | ### Basic Usage 30 | 31 | ```js copy 32 | import { TypeAnimation } from 'react-type-animation'; 33 | 34 | const ExampleComponent = () => { 35 | return ( 36 | { 44 | console.log('Sequence completed'); 45 | }, 46 | ]} 47 | wrapper="span" 48 | cursor={true} 49 | repeat={Infinity} 50 | style={{ fontSize: '2em', display: 'inline-block' }} 51 | /> 52 | ); 53 | }; 54 | ``` 55 | 56 | 57 | 58 | ## Custom Props & Options 59 | 60 | See [Options →](/options) 61 | 62 | ## Examples 63 | 64 | See [Examples →](/examples) 65 | 66 | ## Migrating to v3 67 | 68 | From v3.x onwards, the default wrapper is `` instead of `
`. **To migrate**, add a `display: inline-block/block` css rule or `wrapper="div"` to all `` occurances with unspecified wrapper - or leave unchanged if you don't experience any new layout issues. 69 | 70 | ## Important Usage Notes 71 | 72 | ### Immutability 73 | 74 | Due to the nature of the animation, this component is **permanently memoized**, which means that the `` component **never re-renders unless you hard-reload the page**, and hence **props changes will not be reflected**. 75 | 76 | 77 | Note: You can still dynamically manipulate the styles of the animation via 78 | `ref` or `state` as shown in the 79 | [examples](/examples#manipulation-via-css-classes). 80 | 81 | 82 | Here is an example which shows that you cannot render dynamic prop-values: 83 | 84 | ```jsx copy 85 | const [counter, setCounter] = useState(0) 86 | setCounter(++counter), '']} 88 | repeat={Infinity} 89 | /> 90 | ``` 91 | 92 | **Renders**: 95 | 96 | In the example above, `counter` will always render as "0" within the animation and ignore state changes. 97 | 98 | --- 99 | 100 | ### Hot Reload NOT Supported 101 | 102 | Because the TypeAnimation component is memoized and **never** re-rendered (see above), yet Hot Reload attempts to re-render the component, **changes to the TypeAnimation component will not render until you hard-reload the page**. 103 | 104 | Hence, whenever you make changes to the TypeAnimation component, you unfortunately have to reload your page. 105 | 106 | --- 107 | 108 | ### Pure Text Limitation 109 | 110 | The Component is limited to **pure text** and cannot animate nested DOM elements: 111 | 112 | ❌ **Unsupported:** 113 | ` One Two
Three
` 114 | 115 | ✅ **Supported:** 116 | `One Two Three` 117 | 118 | --- 119 | 120 | ### Layout-shift 121 | 122 | As the typing animation progresses, the wrapper may expand and cause layout shift. See [here](/wrapper-css#preventing-layout-shift) for solutions. 123 | 124 | --- 125 | 126 | ### Changing the Wrapper Element 127 | 128 | It's recommended to **not change** the default `wrapper` prop (`span`) without a reason, as it may cause invalid HTML, hydration issues and semantical incorrectness as described [here](/wrapper-css). 129 | -------------------------------------------------------------------------------- /example/pages/options.mdx: -------------------------------------------------------------------------------- 1 | import { OptionTable } from 'components/Table'; 2 | import { Callout, Tab, Tabs, Steps } from 'nextra-theme-docs'; 3 | 4 | # Options 5 | 6 | ## Component Props 7 | 8 | void | Promise)>', 13 | 'Animation sequence consisting of: [TEXT, DELAY-IN-MS, CALLBACK-FUNC]', 14 | "['One', 1000, 'Two', () => console.log('done typing!')]", 15 | '-', 16 | ], 17 | [ 18 | 'wrapper', 19 | 'string', 20 | "HTML element name that wraps the typing animation. See 'Wrapper CSS' section for related info", 21 | "p,h2,div, strong", 22 | 'span', 23 | ], 24 | [ 25 | 'repeat', 26 | 'number', 27 | 'Amount of animation repetitions. e.g. 0 = Animation will only be typed out once', 28 | "1, 3, Infinity", 29 | '0', 30 | ], 31 | [ 32 | 'cursor', 33 | 'boolean', 34 | 'Whether to display default blinking cursor css-animation', 35 | "true, false", 36 | 'true', 37 | ], 38 | [ 39 | 'preRenderFirstString', 40 | 'boolean', 41 | 'If set to true, the first string of your sequence will not be animated and initially (pre-)rendered', 42 | "true, false", 43 | 'false', 44 | ], 45 | [ 46 | 'speed', 47 | '1,2,..,99 | {type: "keyStrokeDelayInMs", value: number}', 48 | 'Basic typing speed from 1-99 or exact keystroke delay in milseconds', 49 | "25, 50, 99, {type: 'keyStrokeDelayInMs', value: 250}", 50 | '40', 51 | ], 52 | [ 53 | 'deletionSpeed', 54 | '1,2,..,99 | {type: "keyStrokeDelayInMs", value: number}', 55 | 'Basic deletion speed from 1-99 or exact keystroke delay in milseconds', 56 | "25, 50, 99, {type: 'keyStrokeDelayInMs', value: 250}", 57 | 'speed', 58 | ], 59 | [ 60 | 'omitDeletionAnimation', 61 | 'boolean', 62 | 'If true, deletions will be instant and without animation', 63 | "true, false", 64 | 'false', 65 | ], 66 | [ 67 | 'className', 68 | 'string', 69 | 'HTML class name applied to the wrapper of the typing animation', 70 | "some-class-name", 71 | '-', 72 | ], 73 | [ 74 | 'style', 75 | 'object', 76 | 'JSX inline style object that will be applied to the wrapper of the typing animation', 77 | "{fontSize: '2em'}", 78 | '-', 79 | ], 80 | [ 81 | 'ref', 82 | 'HTMLElement | null', 83 | 'A React ref that will be passed to the wrapper of the typing animation', 84 | "-", 85 | '-', 86 | ], 87 | [ 88 | 'splitter', 89 | '(text: string) => Array', 90 | 'Custom string splitter, e.g for typing complex characters, such as those handled by the npm package "grapheme-splitter"', 91 | '(str) => new GraphemeSplitter().splitGraphemes(str)', 92 | `(str) => [...str]`, 93 | ] 94 | ]} 95 | 96 | /> 97 | 98 | ### Props Examples 99 | 100 | See [all examples](/examples) to see all props in usage. 101 | 102 | - `ref`, `className` see [here](/examples#manipulation-via-css-classes). 103 | - `speed`, `deletionSpeed` see [here](/examples#custom-speed). 104 | - `preRenderFirstString` see [here](/examples#initially-pre-rendered). 105 | - `splitter` see [here](/examples#custom-splitter) 106 | 107 | ## Custom Cursor Animation 108 | 109 | If you wish to apply a custom cursor animation, set the `cursor` prop to `false` and set a custom `className` prop to the `` component with your own css styles. 110 | 111 | 112 | 113 | 114 | 115 | ```css filename="yourCssModule.module.css" 116 | .type::after { 117 | content: '|'; 118 | animation: cursor 1.1s infinite step-start; 119 | } 120 | 121 | @keyframes cursor { 122 | 50% { 123 | opacity: 0; 124 | } 125 | } 126 | ``` 127 | 128 | ```tsx 129 | import styles from './yourCssModule.module.css'; 130 | 131 | ; 136 | ``` 137 | 138 | 139 | 140 | 141 | 142 | ```css filename="yourGlobalCssFile.css" 143 | .type::after { 144 | content: '|'; 145 | animation: cursor 1.1s infinite step-start; 146 | } 147 | 148 | @keyframes cursor { 149 | 50% { 150 | opacity: 0; 151 | } 152 | } 153 | ``` 154 | 155 | ```tsx 156 | import './yourGlobalCssFile.css'; 157 | 158 | ; 163 | ``` 164 | 165 | 166 | 167 | 168 | ```tsx 169 | <> 170 | 175 | 186 | 187 | ``` 188 | 189 | 190 | 191 | 192 | ### Stop cursor animation 193 | 194 | If you would like the cursor to stop being displayed at a specific sequence step, see [here](/examples#manipulation-via-css-classes). 195 | -------------------------------------------------------------------------------- /example/pages/wrapper-css.mdx: -------------------------------------------------------------------------------- 1 | import { 2 | SpanCollapsingExample, 3 | DisplayBlockCollapsingExample, 4 | WordBreakExample, 5 | } from '../components/examples'; 6 | import { Callout } from 'nextra-theme-docs'; 7 | 8 | # Wrapper & CSS tips 9 | 10 | ## Recommended Wrapper 11 | 12 | As mentioned in the [props](/options#component-props) section, the default wrapper for the `` component is a ``. 13 | 14 | You should mostly stick to the default value of `span` for the `wrapper` prop because: 15 | 16 | 1. The `span` element is the semantically correct element for a typing animation 17 | 2. The `span` element can appear as a child of many different HTML elements which should prevent you from running into hard to debug errors. 18 | 19 | 20 | **Be careful with custom wrappers:** if you set `wrapper='div'` but, for example, accidentally use the `` component as a child of a `

`, this would generate invalid HTML and cause React hydration errors if used with _SSR_ with Next.js or similar. 21 | 22 | 23 | ## Useful css rules for inline wrappers 24 | 25 | Because then `span` is an in-line element, there may be some css styling issues with your typing animations if you don't apply certain styles: 26 | 27 | ### Preventing layout shift 28 | 29 | In most cases, you will have to define fixed dimensions for your wrapper to prevent layout shift. 30 | 31 | With an inline wrapper element, such as `span`, the default css behaviour won't allow you to set a fixed height or width to the typing animation in order to prevent layout shift: 32 | 33 | 34 | 35 | ```tsx /height: '200px'/ /width: '300px'/ 36 | // ❌ height and width not being applied 37 | 46 | ``` 47 | 48 | **Fix**: Apply `display: inline-block` or `display: block`: 49 | 50 |
51 | 52 | 53 | 54 | ```tsx /display: 'block'/ /height: '200px'/ /width: '300px'/ copy 55 | // ✅ height and width being applied 56 | 66 | ``` 67 | 68 | ## Other useful wrapper css rules 69 | 70 | ### Writing Multi-Line 71 | 72 | If you'd like to write in new line after each line break, apply `white-space: pre-line`, as shown in [this example](/examples#multiple-lines). 73 | 74 | ### Word break styles 75 | 76 | By altering the css `word-break` property, you can achieve differen't types of word breaks inside your animation: 77 | 78 |
79 | 80 | 81 | ```tsx /wordBreak: 'break-all'/ copy 82 | 93 | ``` 94 | -------------------------------------------------------------------------------- /example/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /example/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxeth/react-type-animation/512794fab28a5f21bb083efd6427a79502aa2dae/example/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /example/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxeth/react-type-animation/512794fab28a5f21bb083efd6427a79502aa2dae/example/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /example/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxeth/react-type-animation/512794fab28a5f21bb083efd6427a79502aa2dae/example/public/apple-touch-icon.png -------------------------------------------------------------------------------- /example/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxeth/react-type-animation/512794fab28a5f21bb083efd6427a79502aa2dae/example/public/favicon-16x16.png -------------------------------------------------------------------------------- /example/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxeth/react-type-animation/512794fab28a5f21bb083efd6427a79502aa2dae/example/public/favicon-32x32.png -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxeth/react-type-animation/512794fab28a5f21bb083efd6427a79502aa2dae/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /example/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /example/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme'); 2 | 3 | module.exports = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,md,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,md,mdx}', 7 | './theme.config.tsx', 8 | ], 9 | darkMode: 'class', 10 | theme: { 11 | extend: { 12 | colors: { 13 | 'dark-border': '#262626', 14 | }, 15 | fontFamily: { 16 | // imported in _document.tsx 17 | poppins: ['Poppins', ...defaultTheme.fontFamily.sans], 18 | }, 19 | }, 20 | }, 21 | jit: true, 22 | 23 | variants: { 24 | extend: {}, 25 | }, 26 | plugins: [], 27 | }; 28 | -------------------------------------------------------------------------------- /example/theme.config.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DocsThemeConfig } from 'nextra-theme-docs'; 3 | import { TypeAnimation } from 'react-type-animation'; 4 | import { useRouter } from 'next/router'; 5 | 6 | const config: DocsThemeConfig = { 7 | logo: ( 8 | 25 | ), 26 | 27 | project: { 28 | link: 'https://github.com/maxeth/react-type-animation', 29 | }, 30 | docsRepositoryBase: 31 | 'https://github.com/maxeth/react-type-animation/tree/master/example', 32 | feedback: { 33 | useLink: () => 'https://github.com/maxeth/react-type-animation/issues/new', 34 | content: () => Report bugs & provide feedback →, 35 | }, 36 | 37 | useNextSeoProps() { 38 | const { asPath } = useRouter(); 39 | if (asPath !== '/') { 40 | return { 41 | titleTemplate: '%s – React Type Animation', 42 | }; 43 | } else { 44 | return { 45 | titleTemplate: 'Get Started - React Type Animation', 46 | }; 47 | } 48 | }, 49 | head: ( 50 | <> 51 | 52 | 56 | 60 | 64 | 69 | 75 | 81 | 82 | 83 | ), 84 | 85 | footer: { 86 | component: ( 87 |

111 | ) 112 | }, 113 | }; 114 | 115 | export default config; 116 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": false, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "incremental": true 18 | }, 19 | "exclude": ["node_modules", "old_pages"], 20 | "include": [ 21 | "./next-env.d.ts", 22 | "./**/*.ts", 23 | "./**/*.tsx", 24 | "./theme.config.tsx" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-type-animation", 3 | "version": "3.2.0", 4 | "description": "Customizable React typing animation component based on typical.", 5 | "author": "max37", 6 | "license": "MIT", 7 | "repository": "maxeth/react-type-animation", 8 | "main": "dist/cjs/index.js", 9 | "module": "dist/esm/index.es.js", 10 | "jsnext:main": "dist/esm/index.es.js", 11 | "files": [ 12 | "dist" 13 | ], 14 | "types": "dist/index.d.ts", 15 | "scripts": { 16 | "build": "rollup -c", 17 | "start": "rollup -c -w", 18 | "prepare": "npm run build", 19 | "start-linked-example": "npm run build && cd example/ && npm install --save ./../ && npm i && npm run dev && cd ..", 20 | "start-local-example": "npm run build && cd example/ && npm i && npm run dev && cd .." 21 | }, 22 | "keywords": [ 23 | "react", 24 | "reactjs", 25 | "animation", 26 | "typing", 27 | "typewriter", 28 | "react typewriter", 29 | "typing animation", 30 | "type animation", 31 | "react typing animation", 32 | "react type effect", 33 | "type effect", 34 | "text animation" 35 | ], 36 | "peerDependencies": { 37 | "prop-types": "^15.5.4", 38 | "react": ">= 15.0.0", 39 | "react-dom": ">= 15.0.0" 40 | }, 41 | "devDependencies": { 42 | "@rollup/plugin-babel": "^5.3.1", 43 | "@rollup/plugin-commonjs": "^22.0.2", 44 | "@rollup/plugin-node-resolve": "^13.3.0", 45 | "@rollup/plugin-typescript": "^8.3.4", 46 | "@svgr/rollup": "^2.4.1", 47 | "@types/react": "^18.0.16", 48 | "@typescript-eslint/eslint-plugin": "^5.60.0", 49 | "@typescript-eslint/parser": "^5.60.0", 50 | "babel-core": "^6.26.3", 51 | "babel-eslint": "^8.2.5", 52 | "babel-plugin-external-helpers": "^6.22.0", 53 | "babel-preset-env": "^1.7.0", 54 | "babel-preset-react": "^6.24.1", 55 | "babel-preset-stage-0": "^6.24.1", 56 | "cross-env": "^5.1.4", 57 | "eslint": "^8.43.0", 58 | "eslint-config-react-app": "^7.0.1", 59 | "eslint-config-standard": "^17.1.0", 60 | "eslint-config-standard-react": "^13.0.0", 61 | "eslint-plugin-import": "^2.27.5", 62 | "eslint-plugin-node": "^11.1.0", 63 | "eslint-plugin-promise": "^6.1.1", 64 | "eslint-plugin-react": "^7.32.2", 65 | "gh-pages": "^1.2.0", 66 | "react": "^16.4.1", 67 | "react-dom": "^16.4.1", 68 | "react-scripts": "^1.1.4", 69 | "rollup": "^2.77.2", 70 | "rollup-plugin-dts": "^4.2.2", 71 | "rollup-plugin-peer-deps-external": "^2.2.0", 72 | "rollup-plugin-postcss": "^4.0.2", 73 | "rollup-plugin-terser": "^7.0.2", 74 | "rollup-plugin-url": "^1.4.0", 75 | "tslib": "^2.4.0", 76 | "typescript": "^4.9.5", 77 | "typescript-plugin-css-modules": "^3.4.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import external from 'rollup-plugin-peer-deps-external'; 4 | import postcss from 'rollup-plugin-postcss'; 5 | import resolve from '@rollup/plugin-node-resolve'; 6 | import url from 'rollup-plugin-url'; 7 | import svgr from '@svgr/rollup'; 8 | import { terser } from 'rollup-plugin-terser'; 9 | import dts from 'rollup-plugin-dts'; 10 | 11 | import packageJson from './package.json'; 12 | import typescript from '@rollup/plugin-typescript'; 13 | 14 | export default [ 15 | { 16 | input: 'src/index.ts', 17 | output: [ 18 | { 19 | file: packageJson.main, 20 | format: 'cjs', 21 | sourcemap: false 22 | }, 23 | { 24 | file: packageJson.module, 25 | format: 'esm', 26 | sourcemap: false 27 | } 28 | ], 29 | plugins: [ 30 | external(), 31 | postcss({ 32 | modules: true 33 | }), 34 | url(), 35 | svgr(), 36 | babel({ 37 | exclude: 'node_modules/**', 38 | plugins: ['external-helpers'] 39 | }), 40 | typescript({ 41 | tsconfig: './tsconfig.json' 42 | }), 43 | resolve(), 44 | commonjs(), 45 | terser() 46 | ] 47 | }, 48 | { 49 | input: 'dist/esm/index.d.ts', 50 | output: [{ file: 'dist/index.d.ts', format: 'esm' }], 51 | plugins: [dts()] 52 | } 53 | ]; 54 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/TypeAnimation/index.module.css: -------------------------------------------------------------------------------- 1 | .type::after { 2 | content: '|'; 3 | animation: cursor 1.1s infinite step-start; 4 | } 5 | 6 | @keyframes cursor { 7 | 50% { 8 | opacity: 0; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/TypeAnimation/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, HTMLAttributes, memo } from 'react'; 2 | import { useEffectOnce } from '../../hooks/useEffectOnce'; 3 | import useForwardRef from '../../hooks/useForwardRef'; 4 | import { type, type as typeloop } from '../../typical'; 5 | import styles from './index.module.css'; 6 | import { TypeAnimationProps, Wrapper } from './index.types'; 7 | 8 | const DEFAULT_SPEED = 40; 9 | const TypeAnimation = forwardRef< 10 | HTMLElementTagNameMap[Wrapper], 11 | TypeAnimationProps 12 | >( 13 | ( 14 | { 15 | sequence, 16 | repeat, 17 | className, 18 | speed = DEFAULT_SPEED, 19 | deletionSpeed, 20 | omitDeletionAnimation = false, 21 | preRenderFirstString = false, 22 | wrapper = 'span', 23 | splitter = (text: string): ReadonlyArray => [...text], 24 | cursor = true, 25 | style, 26 | ...rest 27 | }, 28 | ref: React.ForwardedRef 29 | ) => { 30 | const { 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, role } = rest; 31 | 32 | if (!deletionSpeed) { 33 | deletionSpeed = speed; 34 | } 35 | 36 | const normalizedSpeeds = new Array(2).fill(DEFAULT_SPEED); 37 | 38 | [speed, deletionSpeed].forEach((s, i) => { 39 | switch (typeof s) { 40 | case 'number': 41 | normalizedSpeeds[i] = Math.abs(s - 100); 42 | break; 43 | case 'object': { 44 | const { type: speedType, value } = s; 45 | 46 | if (typeof value !== 'number') { 47 | break; 48 | // throw new Error("Expected key 'value' of type number."); 49 | } 50 | switch (speedType) { 51 | case 'keyStrokeDelayInMs': { 52 | normalizedSpeeds[i] = value; 53 | break; 54 | } 55 | } 56 | break; 57 | } 58 | } 59 | }); 60 | 61 | const keyStrokeDelayTyping = normalizedSpeeds[0]; 62 | const keyStrokeDelayDeleting = normalizedSpeeds[1]; 63 | 64 | const typeRef = useForwardRef(ref); 65 | 66 | const baseStyle = styles.type; 67 | let finalClassName; 68 | if (className) { 69 | finalClassName = `${cursor ? baseStyle + ' ' : ''}${className}`; 70 | } else { 71 | finalClassName = cursor ? baseStyle : ''; 72 | } 73 | 74 | useEffectOnce(() => { 75 | let seq = sequence; 76 | let tl: typeof typeloop | undefined; 77 | 78 | if (repeat === Infinity) { 79 | tl = typeloop; 80 | } else if (typeof repeat === 'number') { 81 | seq = Array(1 + repeat) // Animation should be performed (1 + repeat) many times 82 | .fill(sequence) 83 | .flat(); 84 | } 85 | 86 | const restArgs = tl ? [...seq, tl] : [...seq]; 87 | 88 | type( 89 | typeRef.current, 90 | splitter, 91 | keyStrokeDelayTyping, 92 | keyStrokeDelayDeleting, 93 | omitDeletionAnimation, 94 | ...restArgs 95 | ); 96 | }); 97 | 98 | const WrapperEl = wrapper; 99 | 100 | const preRenderedChildren = preRenderFirstString 101 | ? ((sequence.find(el => typeof el === 'string') || '') as string) 102 | : null; 103 | 104 | return ( 105 | 118 | ) : ( 119 | preRenderedChildren 120 | ) 121 | } 122 | // @ts-ignore 123 | ref={ariaLabel ? undefined : typeRef} 124 | /> 125 | ); 126 | } 127 | ); 128 | 129 | export default memo(TypeAnimation, (_, __) => { 130 | return true; // IMMUTABLE 131 | }); 132 | -------------------------------------------------------------------------------- /src/components/TypeAnimation/index.types.ts: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes } from 'react'; 2 | 3 | interface Props { 4 | sequence: Sequence; 5 | repeat?: number; 6 | wrapper?: Wrapper; 7 | cursor?: boolean; 8 | splitter?: StringSplitter; 9 | speed?: Speed | GranularSpeed; 10 | deletionSpeed?: Speed | GranularSpeed; 11 | omitDeletionAnimation?: boolean; 12 | preRenderFirstString?: boolean; 13 | } 14 | 15 | export interface TypeAnimationProps 16 | extends Props, 17 | Pick< 18 | HTMLAttributes, 19 | 'style' | 'aria-label' | 'aria-hidden' | 'role' | 'className' 20 | > { 21 | ref?: React.Ref; 22 | } 23 | 24 | export type GranularSpeed = { 25 | type: 'keyStrokeDelayInMs'; 26 | value: number; 27 | }; 28 | 29 | export type StringSplitter = (text: string) => ReadonlyArray; 30 | 31 | export type Wrapper = 32 | | 'p' 33 | | 'div' 34 | | 'span' 35 | | 'strong' 36 | | 'a' 37 | | 'h1' 38 | | 'h2' 39 | | 'h3' 40 | | 'h4' 41 | | 'h5' 42 | | 'h6' 43 | | 'b'; 44 | 45 | export type Sequence = Array; 46 | export type SequenceElement = 47 | | string 48 | | number 49 | | ((element: HTMLElement | null) => void | Promise); 50 | 51 | export type Speed = 52 | | 1 53 | | 2 54 | | 3 55 | | 4 56 | | 5 57 | | 6 58 | | 7 59 | | 8 60 | | 9 61 | | 10 62 | | 11 63 | | 12 64 | | 13 65 | | 14 66 | | 15 67 | | 16 68 | | 17 69 | | 18 70 | | 19 71 | | 20 72 | | 21 73 | | 22 74 | | 23 75 | | 24 76 | | 25 77 | | 26 78 | | 27 79 | | 28 80 | | 29 81 | | 30 82 | | 31 83 | | 32 84 | | 33 85 | | 34 86 | | 35 87 | | 36 88 | | 37 89 | | 38 90 | | 39 91 | | 40 92 | | 41 93 | | 42 94 | | 43 95 | | 44 96 | | 45 97 | | 46 98 | | 47 99 | | 48 100 | | 49 101 | | 50 102 | | 51 103 | | 52 104 | | 53 105 | | 54 106 | | 55 107 | | 56 108 | | 57 109 | | 58 110 | | 59 111 | | 60 112 | | 61 113 | | 62 114 | | 63 115 | | 64 116 | | 65 117 | | 66 118 | | 67 119 | | 68 120 | | 69 121 | | 70 122 | | 71 123 | | 72 124 | | 73 125 | | 74 126 | | 75 127 | | 76 128 | | 77 129 | | 78 130 | | 79 131 | | 80 132 | | 81 133 | | 82 134 | | 83 135 | | 84 136 | | 85 137 | | 86 138 | | 87 139 | | 88 140 | | 89 141 | | 90 142 | | 91 143 | | 92 144 | | 93 145 | | 94 146 | | 95 147 | | 96 148 | | 97 149 | | 98 150 | | 99; 151 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as TypeAnimation } from './TypeAnimation'; 2 | -------------------------------------------------------------------------------- /src/hooks/useEffectOnce.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | export const useEffectOnce = (effect: () => void | (() => void)) => { 4 | const effectFn = useRef<() => void | (() => void)>(effect); 5 | const destroyFn = useRef void)>(); 6 | const effectCalled = useRef(false); 7 | const rendered = useRef(false); 8 | const [, setVal] = useState(0); 9 | 10 | if (effectCalled.current) { 11 | rendered.current = true; 12 | } 13 | 14 | useEffect(() => { 15 | if (!effectCalled.current) { 16 | destroyFn.current = effectFn.current(); 17 | effectCalled.current = true; 18 | } 19 | 20 | setVal(val => val + 1); 21 | 22 | return () => { 23 | if (!rendered.current) { 24 | return; 25 | } 26 | 27 | if (destroyFn.current) { 28 | destroyFn.current(); 29 | } 30 | }; 31 | }, []); 32 | }; 33 | -------------------------------------------------------------------------------- /src/hooks/useForwardRef.tsx: -------------------------------------------------------------------------------- 1 | import { ForwardedRef, useEffect, useRef } from 'react'; 2 | 3 | const useForwardRef = (ref: ForwardedRef, initialValue: any = null) => { 4 | const targetRef = useRef(initialValue); 5 | 6 | useEffect(() => { 7 | if (!ref) return; 8 | 9 | if (typeof ref === 'function') { 10 | ref(targetRef.current); 11 | } else { 12 | ref.current = targetRef.current; 13 | } 14 | }, [ref]); 15 | 16 | return targetRef; 17 | }; 18 | 19 | export default useForwardRef; 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | -------------------------------------------------------------------------------- /src/typical.ts: -------------------------------------------------------------------------------- 1 | import type { SequenceElement, StringSplitter } from './components/TypeAnimation/index.types'; 2 | import { Wrapper } from './components/TypeAnimation/index.types'; 3 | 4 | const OP_CODE_DELETION = 'DELETE'; 5 | const OP_CODE_WRITING = 'WRITING'; 6 | 7 | export async function type( 8 | node: HTMLElementTagNameMap[Wrapper], 9 | splitter: StringSplitter, 10 | speed: number, 11 | deletionSpeed: number, 12 | omitDeletionAnimation: boolean, 13 | ...args: ReadonlyArray 14 | ) { 15 | for (const arg of args) { 16 | switch (typeof arg) { 17 | case 'string': 18 | await edit(node, splitter, arg, speed, deletionSpeed, omitDeletionAnimation); 19 | break; 20 | case 'number': 21 | await wait(arg); 22 | break; 23 | case 'function': 24 | // when typeloop is passed from the TypeAnimation component, this causes an infinite, recursive call-loop here 25 | await arg(node, splitter, speed, deletionSpeed, omitDeletionAnimation, ...args); 26 | break; 27 | default: 28 | await arg; 29 | } 30 | } 31 | } 32 | 33 | async function edit( 34 | node: HTMLElementTagNameMap[Wrapper], 35 | splitter: StringSplitter, 36 | text: string, 37 | speed: number, 38 | deletionSpeed: number, 39 | omitDeletionAnimation: boolean 40 | ) { 41 | const nodeContent = node.textContent || ''; 42 | 43 | const overlap = getOverlap(nodeContent, text); 44 | await perform( 45 | node, 46 | [...deleter(nodeContent, splitter, overlap), ...writer(text, splitter, overlap)], 47 | speed, 48 | deletionSpeed, 49 | omitDeletionAnimation 50 | ); 51 | } 52 | 53 | async function wait(ms: number) { 54 | await new Promise(resolve => setTimeout(resolve, ms)); 55 | } 56 | 57 | async function perform( 58 | node: HTMLElementTagNameMap[Wrapper], 59 | edits: ReadonlyArray, 60 | speed: number, 61 | deletionSpeed: number, 62 | omitDeletionAnimation: boolean 63 | ) { 64 | let filteredEdits = edits; 65 | if (omitDeletionAnimation) { 66 | let slicePoint = 0; 67 | // Find the end-state of the deletion sequence which is either the beginning of a new, longer string, or the empty string 68 | for (let i = 1; i < edits.length; i++) { 69 | const [prev, curr] = [edits[i - 1], edits[i]]; 70 | if (curr.length > prev.length || curr === '') { 71 | slicePoint = i; 72 | break; 73 | } 74 | } 75 | filteredEdits = edits.slice(slicePoint, edits.length); // slice the array from the end-state string onwards, so that the deletion-animation gets omitted as a result 76 | } 77 | for (const op of editor(filteredEdits)) { 78 | const waitingTime = 79 | op.opCode(node) === OP_CODE_WRITING 80 | ? speed + speed * (Math.random() - 0.5) 81 | : deletionSpeed + deletionSpeed * (Math.random() - 0.5); 82 | op.op(node); 83 | await wait(waitingTime); 84 | } 85 | } 86 | 87 | function* editor(edits: ReadonlyArray) { 88 | for (const snippet of edits) { 89 | yield { 90 | op: (node: HTMLElementTagNameMap[Wrapper]) => requestAnimationFrame(() => (node.textContent = snippet)), 91 | 92 | opCode: (node: HTMLElementTagNameMap[Wrapper]) => { 93 | const nodeContent = node.textContent || ''; 94 | 95 | return snippet === '' || nodeContent.length > snippet.length 96 | ? OP_CODE_DELETION 97 | : OP_CODE_WRITING; 98 | } 99 | }; 100 | } 101 | } 102 | 103 | function* writer(text: string, splitter: StringSplitter, startIndex = 0) { 104 | const splitText = splitter(text); 105 | const endIndex = splitText.length; 106 | 107 | while (startIndex < endIndex) { 108 | yield splitText.slice(0, ++startIndex).join(''); 109 | } 110 | } 111 | 112 | function* deleter(text: string, splitter: StringSplitter, startIndex = 0) { 113 | const splitText = splitter(text); 114 | let endIndex = splitText.length; 115 | 116 | while (endIndex > startIndex) { 117 | yield splitText.slice(0, --endIndex).join(''); 118 | } 119 | } 120 | 121 | function getOverlap(start: string, [...end]: string) { 122 | return [...start, NaN].findIndex((char, i) => end[i] !== char); 123 | } 124 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "skipLibCheck": true, 8 | "jsx": "react", 9 | "module": "ESNext", 10 | "declaration": true, 11 | "declarationDir": "types", 12 | "sourceMap": true, 13 | "outDir": "dist", 14 | "moduleResolution": "node", 15 | "allowSyntheticDefaultImports": true, 16 | "emitDeclarationOnly": true, 17 | "downlevelIteration": true, 18 | "plugins": [{ "name": "typescript-plugin-css-modules" }] 19 | }, 20 | "exclude": ["node_modules/", "/example"], 21 | "include": ["src/**/*", "global.d.ts"] 22 | } 23 | --------------------------------------------------------------------------------