├── .gitignore ├── .prettierignore ├── .prettierrc ├── .storybook ├── main.ts └── preview.ts ├── LICENSE ├── README.md ├── babel.config.cjs ├── jest.config.cjs ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── components │ ├── index.ts │ ├── rcinputtag │ │ ├── InputTag.css │ │ ├── InputTag.stories.tsx │ │ ├── InputTag.test.tsx │ │ ├── InputTag.tsx │ │ ├── InputTag.types.ts │ │ └── index.ts │ └── tag │ │ ├── Tag.css │ │ ├── Tag.stories.tsx │ │ ├── Tag.test.tsx │ │ ├── Tag.tsx │ │ ├── Tag.types.ts │ │ └── index.ts ├── images │ ├── demo.gif │ ├── theme-1-demo.jpg │ ├── theme-2-demo.jpg │ └── theme-3-demo.jpg ├── index.ts └── stories │ └── Guide.mdx └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Node modules 2 | node_modules/ 3 | 4 | # Build output 5 | build/ 6 | dist/ 7 | 8 | # Temporary files 9 | *.log 10 | *.tmp 11 | 12 | # Dependency directories 13 | jspm_packages/ 14 | 15 | # IDE files 16 | .vscode/ 17 | .idea/ 18 | 19 | # OS files 20 | .DS_Store 21 | Thumbs.db 22 | 23 | # Environment variables 24 | .env 25 | .env.local 26 | .env.development.local 27 | .env.test.local 28 | .env.production.local 29 | 30 | # Coverage directory used by testing tools 31 | coverage/ 32 | 33 | # Miscellaneous 34 | *.swp 35 | *.swo 36 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ 4 | coverage/ 5 | public/ 6 | storybook-static/ 7 | *.min.js 8 | *.bundle.js 9 | *.log 10 | *.lock 11 | .DS_Store 12 | .env 13 | .vscode/ 14 | .idea/ 15 | *.iml -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "tabWidth": 2, 5 | "semi": true, 6 | "endOfLine": "auto", 7 | "printWidth": 80, 8 | "bracketSpacing": true, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | import remarkGfm from 'remark-gfm'; 3 | 4 | const config: StorybookConfig = { 5 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 6 | addons: [ 7 | '@storybook/addon-essentials', 8 | '@storybook/addon-onboarding', 9 | '@chromatic-com/storybook', 10 | '@storybook/experimental-addon-test', 11 | { 12 | name: '@storybook/addon-docs', 13 | options: { 14 | mdxPluginOptions: { 15 | mdxCompileOptions: { 16 | remarkPlugins: [remarkGfm], 17 | }, 18 | }, 19 | }, 20 | }, 21 | ], 22 | framework: { 23 | name: '@storybook/react-vite', 24 | options: {}, 25 | }, 26 | }; 27 | export default config; 28 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react' 2 | 3 | const preview: Preview = { 4 | parameters: { 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/i, 9 | }, 10 | }, 11 | }, 12 | }; 13 | 14 | export default preview; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Shaian 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-tagit 2 | 3 | ![npm version](https://img.shields.io/npm/v/react-tagit) 4 | ![license](https://img.shields.io/npm/l/react-tagit) 5 | ![bundle size](https://img.shields.io/bundlephobia/minzip/react-tagit) 6 | ![types](https://img.shields.io/npm/types/react-tagit) 7 | 8 | A simple and customizable Input Tag Component for React. 9 | 10 | ## Demo 11 | 12 | ![Demo](https://raw.githubusercontent.com/zshaian/react-tagit/refs/heads/main/src/images/demo.gif) 13 | 14 | Check out the live playground on CodeSandbox [Playground](https://codesandbox.io/p/sandbox/react-tagit-9n6nvz) 15 | 16 | ## Table of Contents 17 | 18 | - [Installation](#installation) 19 | - [Usage](#usage) 20 | - [Props](#props) 21 | - [Styling & Theming](#styling--theming) 22 | - [Development](#development) 23 | - [Contributing](#contributing) 24 | - [License](#license) 25 | 26 | ## Installation 27 | 28 | Install the package using your preferred package manager: 29 | 30 | **NPM** 31 | 32 | ```bash 33 | npm install react-tagit 34 | ``` 35 | 36 | **Yarn** 37 | 38 | ```bash 39 | yarn add react-tagit 40 | ``` 41 | 42 | **PNPM** 43 | 44 | ```bash 45 | pnpm add react-tagit 46 | ``` 47 | 48 | ### Peer Dependencies 49 | 50 | Ensure you have the following peer dependencies installed: 51 | 52 | ```json 53 | "peerDependencies": { 54 | "react": "^19.0.0", 55 | "react-dom": "^19.0.0" 56 | } 57 | ``` 58 | 59 | ## Usage 60 | 61 | Here’s an example of how to use the `` component: 62 | 63 | ```tsx 64 | import { InputTag } from 'react-tagit'; 65 | import { useState } from 'react'; 66 | 67 | export default function App() { 68 | const [tags, setTags] = useState>([]); 69 | 70 | return ( 71 |
72 | 80 | 81 | 82 | ); 83 | } 84 | ``` 85 | 86 | And that's it! You now have a fully functional input tag component. 87 | 88 | ## Props 89 | 90 | Here’s a list of props you can pass to the `` component: 91 | 92 | | Prop | Type | Default | Description | 93 | | ----------------------------- | ------------------------------------- | ----------- | --------------------------------------------------------------------- | 94 | | `autoFocus` | `boolean` | `false` | Autofocus the tag input element when the component mounts. | 95 | | `customClass` | `object` | `{}` | Custom classes for the elements of the InputTag component. | 96 | | `disabled` | `boolean` | `false` | Disable the InputTag component. Hides the remove button for each tag. | 97 | | `inputTagContainerStyleProps` | `object` | `{}` | Style props for the container element. | 98 | | `labelStyleProps` | `object` | `{}` | Style props for the label element. | 99 | | `inputStyleProps` | `object` | `{}` | Style props for the input element. | 100 | | `tagsContainerStyleProps` | `object` | `{}` | Style props for the tags list container. | 101 | | `tagsStyleProps` | `object` | `{}` | Style props for individual tag elements. | 102 | | `removeTagBtnStyleProps` | `object` | `{}` | Style props for the remove button on each tag. | 103 | | `hideLabel` | `boolean` | `false` | Whether to hide the label. | 104 | | `label` | `string` | `'Tags'` | Label for the input. | 105 | | `maxTags` | `number` | `infinite` | Maximum number of tags allowed. | 106 | | `maxTagsValue` | `number` | `infinite` | Maximum number of characters per tag. | 107 | | `name` | `string` | `'tags'` | Name attribute for the input element. | 108 | | `separator` | `'Enter' \| 'Space'` | `'Enter'` | The key that triggers tag creation. | 109 | | `theme` | `'theme-1' \| 'theme-2' \| 'theme-3'` | `undefined` | Available themes for styling the component. | 110 | | `value` | `Array` | `[]` | Current tag values. | 111 | | `onChange` | `(tags: Array) => void` | `undefined` | Function to update the tag values. | 112 | | `onFocus` | `(event: FocusEvent) => void` | `() => {}` | Callback when the input gains focus. | 113 | | `onBlur` | `(event: FocusEvent) => void` | `() => {}` | Callback when the input loses focus. | 114 | 115 | ## Styling & Theming 116 | 117 | `react-tagit` comes with built-in themes and allows you to customize styles using class names or inline styles. 118 | 119 | ### Built-in Themes 120 | 121 | You can use one of the predefined themes: 122 | 123 | - **Theme 1** 124 | 125 | ```tsx 126 | 127 | ``` 128 | 129 | ![Theme 1 Demo](https://raw.githubusercontent.com/zshaian/react-tagit/refs/heads/main/src/images/theme-1-demo.jpg) 130 | 131 | - **Theme 2** 132 | 133 | ```tsx 134 | 135 | ``` 136 | 137 | ![Theme 2 Demo](https://raw.githubusercontent.com/zshaian/react-tagit/refs/heads/main/src/images/theme-2-demo.jpg) 138 | 139 | - **Theme 3** 140 | ```tsx 141 | 142 | ``` 143 | ![Theme 3 Demo](https://raw.githubusercontent.com/zshaian/react-tagit/refs/heads/main/src/images/theme-3-demo.jpg) 144 | 145 | ### Custom Classes 146 | 147 | You can override the default classes by passing a `customClass` object: 148 | 149 | ```tsx 150 | 161 | ``` 162 | 163 | ### Default Classes 164 | 165 | Here are the default class names you can target for styling: 166 | 167 | - `input-tag-container-element` 168 | - `input-tag-label-element` 169 | - `input-tag-list-container-element` 170 | - `input-tag-tag-item-element` 171 | - `input-tag-tag-remove-btn-element` 172 | - `input-tag-tag-content-element` 173 | - `input-tag-input-element` 174 | 175 | ## Development 176 | 177 | ### Testing 178 | 179 | This project uses [Jest](https://jestjs.io/) and [Testing Library](https://testing-library.com/) for testing. To run the tests: 180 | 181 | ```bash 182 | npm run test 183 | ``` 184 | 185 | ### Storybook 186 | 187 | The project uses [Storybook](https://storybook.js.org/) for component documentation and previews. To start Storybook: 188 | 189 | ```bash 190 | npm run storybook 191 | ``` 192 | 193 | This will start a local server and provide a preview URL. 194 | 195 | ### Building 196 | 197 | The project uses [Rollup](https://rollupjs.org/) as the bundler. To build the project: 198 | 199 | ```bash 200 | npm run build 201 | ``` 202 | 203 | The compiled code will be available in the `dist` folder. 204 | 205 | ## Contributing 206 | 207 | Contributions are welcome! Please open an issue or submit a pull request for any improvements or bug fixes. 208 | 209 | ## License 210 | 211 | This project is licensed under the [MIT License](LICENSE). 212 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript", 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "jsdom", 3 | moduleNameMapper: { 4 | ".(css|less|scss)$": "identity-obj-proxy", 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-tagit", 3 | "version": "1.0.4", 4 | "main": "dist/cjs/index.js", 5 | "module": "dist/esm/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "rollup -c --bundleConfigAsCjs", 9 | "test": "jest", 10 | "prettier": "prettier --ignore-path .gitignore --write \"./src/**/*.+(js|jsx|ts|tsx|json)\"", 11 | "prettier:fix": "prettier --write", 12 | "storybook": "storybook dev -p 6006", 13 | "build-storybook": "storybook build" 14 | }, 15 | "files": [ 16 | "dist" 17 | ], 18 | "keywords": [ 19 | "input", 20 | "react-component", 21 | "react-tags", 22 | "custom-component", 23 | "input-tag" 24 | ], 25 | "author": "shaian", 26 | "license": "MIT", 27 | "type": "module", 28 | "description": "Simple input tag component for react", 29 | "homepage": "https://github.com/zshaian/react-tagit", 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/zshaian/react-tagit.git" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/zshaian/react-tagit/issues" 36 | }, 37 | "peerDependencies": { 38 | "react": "^19.0.0", 39 | "react-dom": "^19.0.0" 40 | }, 41 | "devDependencies": { 42 | "@babel/core": "^7.26.10", 43 | "@babel/preset-env": "^7.26.9", 44 | "@babel/preset-react": "^7.26.3", 45 | "@babel/preset-typescript": "^7.26.0", 46 | "@chromatic-com/storybook": "^3.2.4", 47 | "@eslint/js": "^9.23.0", 48 | "@rollup/plugin-commonjs": "^28.0.3", 49 | "@rollup/plugin-node-resolve": "^16.0.1", 50 | "@rollup/plugin-terser": "^0.4.4", 51 | "@rollup/plugin-typescript": "^12.1.2", 52 | "@storybook/addon-essentials": "^8.5.8", 53 | "@storybook/addon-onboarding": "^8.5.8", 54 | "@storybook/blocks": "^8.5.8", 55 | "@storybook/experimental-addon-test": "^8.5.8", 56 | "@storybook/react": "^8.5.8", 57 | "@storybook/react-vite": "^8.5.8", 58 | "@storybook/test": "^8.5.8", 59 | "@testing-library/react": "^16.2.0", 60 | "@types/jest": "^29.5.14", 61 | "@types/react": "^19.0.10", 62 | "babel-jest": "^29.7.0", 63 | "identity-obj-proxy": "^3.0.0", 64 | "jest": "^29.7.0", 65 | "jest-environment-jsdom": "^29.7.0", 66 | "prettier": "^3.5.3", 67 | "remark-gfm": "^4.0.1", 68 | "rollup": "^4.35.0", 69 | "rollup-plugin-dts": "^6.1.1", 70 | "rollup-plugin-peer-deps-external": "^2.2.4", 71 | "rollup-plugin-postcss": "^4.0.2", 72 | "storybook": "^8.5.8", 73 | "tslib": "^2.8.1", 74 | "typescript": "^5.8.2" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import typescript from "@rollup/plugin-typescript"; 4 | import dts from "rollup-plugin-dts"; 5 | import terser from "@rollup/plugin-terser"; 6 | import peerDepsExternal from "rollup-plugin-peer-deps-external"; 7 | import postcss from "rollup-plugin-postcss"; 8 | 9 | const packageJson = require("./package.json"); 10 | 11 | export default [ 12 | { 13 | input: "src/index.ts", 14 | output: [ 15 | { 16 | file: packageJson.main, 17 | format: "cjs", 18 | sourcemap: true, 19 | }, 20 | { 21 | file: packageJson.module, 22 | format: "esm", 23 | sourcemap: true, 24 | }, 25 | ], 26 | plugins: [ 27 | peerDepsExternal(), 28 | resolve(), 29 | commonjs(), 30 | typescript({ tsconfig: "./tsconfig.json" }), 31 | terser(), 32 | postcss(), 33 | ], 34 | external: ["react", "react-dom"], 35 | }, 36 | { 37 | input: "src/index.ts", 38 | output: [{ file: "dist/index.d.ts", format: "es" }], 39 | plugins: [dts.default()], 40 | external: [/\.css$/], 41 | }, 42 | ]; 43 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { InputTag } from './rcinputtag'; 2 | -------------------------------------------------------------------------------- /src/components/rcinputtag/InputTag.css: -------------------------------------------------------------------------------- 1 | .input-tag-container { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 0.5rem 0rem; 5 | } 6 | 7 | .input-tag-list-container { 8 | cursor: text; 9 | padding: 1rem; 10 | margin: 0; 11 | display: flex; 12 | align-items: baseline; 13 | flex-wrap: wrap; 14 | list-style-type: none; 15 | border-radius: 5px; 16 | border: 1px solid rgb(70, 70, 70); 17 | } 18 | 19 | .input-tag-list-container:has(input:disabled) { 20 | border: 1px solid rgba(70, 70, 70, 0.349); 21 | } 22 | 23 | .input-tag-input { 24 | border: none; 25 | background-color: transparent; 26 | } 27 | 28 | .input-tag-label, 29 | .theme-1-input-tag-label, 30 | .theme-2-input-tag-label, 31 | .theme-3-input-tag-label { 32 | font-size: 1rem; 33 | font-weight: 500; 34 | font-family: 35 | ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 36 | 'Segoe UI Symbol', 'Noto Color Emoji'; 37 | } 38 | 39 | .theme-1-input-tag-list-container, 40 | .theme-2-input-tag-list-container, 41 | .theme-3-input-tag-list-container { 42 | min-height: 4rem; 43 | padding: 0.3rem 1rem; 44 | gap: 0.2rem; 45 | } 46 | 47 | .theme-2-input-tag-list-container, 48 | .theme-3-input-tag-list-container { 49 | padding: 1rem; 50 | } 51 | 52 | .theme-1-input-tag-input, 53 | .theme-2-input-tag-input, 54 | .theme-3-input-tag-input { 55 | padding: 0.3rem; 56 | } 57 | -------------------------------------------------------------------------------- /src/components/rcinputtag/InputTag.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import InputTag from './InputTag'; 3 | import { useState } from 'react'; 4 | 5 | const meta: Meta = { 6 | component: InputTag, 7 | }; 8 | 9 | export default meta; 10 | 11 | export const RenderInputTag: StoryObj = { 12 | render: function Render() { 13 | const [tags, setTags] = useState>([]); 14 | return ( 15 | 21 | ); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/rcinputtag/InputTag.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { render, screen, fireEvent } from '@testing-library/react'; 4 | import type { InputTagProps } from './InputTag.types'; 5 | import InputTag from './InputTag'; 6 | 7 | describe('InputTag Component', () => { 8 | const setup = (propsOverride: Partial = {}) => { 9 | const onChangeMock = jest.fn(); 10 | 11 | render(); 12 | const input = screen.getByLabelText(/tags/i) as HTMLInputElement; 13 | 14 | return { 15 | input, 16 | onChangeMock, 17 | }; 18 | }; 19 | 20 | test('renders without crashing', () => { 21 | setup(); 22 | expect(screen.getByLabelText(/tags/i)).toBeInTheDocument(); 23 | }); 24 | 25 | test('adds a tag on pressing Enter', () => { 26 | const { input, onChangeMock } = setup(); 27 | 28 | fireEvent.change(input, { target: { value: 'React' } }); 29 | fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); 30 | 31 | expect(onChangeMock).toHaveBeenCalledWith(expect.any(Function)); 32 | 33 | // simulate internal call (optional, to show what's inside the callback) 34 | const updatedTags: Array = []; 35 | const result = onChangeMock.mock.calls[0][0](updatedTags); 36 | expect(result).toContain('React'); 37 | }); 38 | 39 | test('does not add duplicate tags', () => { 40 | const { input, onChangeMock } = setup({ value: ['React'] }); 41 | 42 | fireEvent.change(input, { target: { value: 'React' } }); 43 | fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); 44 | 45 | expect(onChangeMock).not.toHaveBeenCalled(); 46 | }); 47 | 48 | test('removes a tag when backspace is pressed and input is empty', () => { 49 | const { input, onChangeMock } = setup({ value: ['React', 'Vue'] }); 50 | 51 | fireEvent.change(input, { target: { value: '' } }); 52 | fireEvent.keyDown(input, { key: 'Backspace', code: 'Backspace' }); 53 | 54 | expect(onChangeMock).toHaveBeenCalledWith(expect.any(Function)); 55 | 56 | const prevTags = ['React', 'Vue']; 57 | const newTags = onChangeMock.mock.calls[0][0](prevTags); 58 | expect(newTags).toEqual(['React']); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/components/rcinputtag/InputTag.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Tag } from '../tag'; 3 | import { useRef, useState } from 'react'; 4 | import './InputTag.css'; 5 | import type { InputTagProps } from './InputTag.types'; 6 | 7 | export default function InputTag({ 8 | autoFocus = false, 9 | customClass, 10 | disabled = false, 11 | inputTagContainerStyleProps, 12 | labelStyleProps, 13 | inputStyleProps, 14 | tagsContainerStyleProps, 15 | tagsStyleProps, 16 | removeTagBtnStyleProps, 17 | hideLabel = false, 18 | label = 'Tags', 19 | maxTags, 20 | maxTagsValue, 21 | name = 'tags', 22 | separator = 'Enter', 23 | theme, 24 | value, 25 | onChange, 26 | onFocus = () => {}, 27 | onBlur = () => {}, 28 | }: InputTagProps) { 29 | const inputTagRef = useRef(null); 30 | const [tagInputValue, setTagInputValue] = useState(''); 31 | 32 | const valueIsNotAlreadyInTags = 33 | value.filter( 34 | tag => tag.toLowerCase().trim() === tagInputValue.toLowerCase().trim(), 35 | ).length === 0; 36 | 37 | const valueIsNotEmpty = 38 | tagInputValue.trim() !== '' && tagInputValue.length > 0; 39 | 40 | // Check if the current number of tags is below the maxTags limit. 41 | // Defaults to true when no max is set (unlimited tags). 42 | const isLessThanMaxTags = 43 | maxTags && maxTags > 0 ? value.length < maxTags : true; 44 | 45 | // Return the key that triggers tag separation. Space key is represented as " ". 46 | const separatorTriggerKey = separator === 'Enter' ? 'Enter' : ' '; 47 | 48 | const handleSetTags = (event: React.KeyboardEvent) => { 49 | if (event.key === 'Enter') { 50 | // If the InputTag component is inside the form this will prevent the form, 51 | // from submitting when the enter key is pressed. 52 | event.preventDefault(); 53 | } 54 | // Remove the tag before the input if Backspace is pressed, 55 | // the input is empty, and there are existing tags. 56 | if (event.key === 'Backspace' && !valueIsNotEmpty && value.length > 0) { 57 | handleRemoveTag(value[value.length - 1]); 58 | } 59 | if ( 60 | event.key === separatorTriggerKey && 61 | valueIsNotEmpty && 62 | valueIsNotAlreadyInTags && 63 | isLessThanMaxTags 64 | ) { 65 | onChange(previousTags => [...previousTags, tagInputValue]); 66 | setTagInputValue(''); 67 | } 68 | }; 69 | 70 | const handleRemoveTag = (tagName: string) => { 71 | onChange(previousTags => previousTags.filter(tag => tag !== tagName)); 72 | }; 73 | 74 | return ( 75 |
inputTagRef.current!.focus()} 81 | > 82 | 91 |
    101 | {value.map(tag => ( 102 |
  • 110 | 120 |
  • 121 | ))} 122 |
  • 123 | setTagInputValue(event.target.value)} 134 | autoFocus={autoFocus} 135 | maxLength={maxTagsValue} 136 | name={name} 137 | style={inputStyleProps} 138 | onBlur={onBlur} 139 | onFocus={onFocus} 140 | disabled={disabled} 141 | /> 142 |
  • 143 |
144 |
145 | ); 146 | } 147 | -------------------------------------------------------------------------------- /src/components/rcinputtag/InputTag.types.ts: -------------------------------------------------------------------------------- 1 | export type InputTagProps = { 2 | /** 3 | * Autofocus the tag input element when the component mounts. 4 | */ 5 | autoFocus?: boolean; 6 | 7 | /** 8 | * Custom classes for the elements of the InputTag component. 9 | * These will override the default classes. 10 | */ 11 | customClass?: { 12 | inputTagContainerElement?: string; 13 | inputTagLabelElement?: string; 14 | inputTagInputElement?: string; 15 | inputTagListContainerElement?: string; 16 | inputTagTagItemElement?: string; 17 | inputTagTagRemoveBtnElement?: string; 18 | inputTagTagContentElement?: string; 19 | }; 20 | 21 | /** 22 | * Disable the InputTag component. 23 | * When disabled, the remove button for each tag is hidden. 24 | */ 25 | disabled?: boolean; 26 | 27 | /** 28 | * Style props for the container element. 29 | */ 30 | inputTagContainerStyleProps?: React.CSSProperties; 31 | 32 | /** 33 | * Style props for the label element. 34 | */ 35 | labelStyleProps?: React.CSSProperties; 36 | 37 | /** 38 | * Style props for the input element. 39 | */ 40 | inputStyleProps?: React.CSSProperties; 41 | 42 | /** 43 | * Style props for the tags list container. 44 | */ 45 | tagsContainerStyleProps?: React.CSSProperties; 46 | 47 | /** 48 | * Style props for individual tag elements. 49 | */ 50 | tagsStyleProps?: React.CSSProperties; 51 | 52 | /** 53 | * Style props for the remove button on each tag. 54 | */ 55 | removeTagBtnStyleProps?: React.CSSProperties; 56 | 57 | /** 58 | * Whether to hide the label. 59 | */ 60 | hideLabel?: boolean; 61 | 62 | /** 63 | * Label for the input. 64 | */ 65 | label?: string; 66 | 67 | /** 68 | * Maximum number of tags allowed. 69 | */ 70 | maxTags?: number; 71 | 72 | /** 73 | * Maximum number of characters per tag. 74 | */ 75 | maxTagsValue?: number; 76 | 77 | /** 78 | * Name attribute for the input element. 79 | */ 80 | name?: string; 81 | 82 | /** 83 | * The key that triggers tag creation. 84 | */ 85 | separator?: 'Enter' | 'Space'; 86 | 87 | /** 88 | * Available themes for styling the component. 89 | */ 90 | theme?: 'theme-1' | 'theme-2' | 'theme-3'; 91 | 92 | /** 93 | * Current tag values. 94 | */ 95 | value: Array; 96 | 97 | /** 98 | * Function to update the tag values. 99 | */ 100 | onChange: React.Dispatch>>; 101 | 102 | /** 103 | * Callback when the input gains focus. 104 | */ 105 | onFocus?: (event: React.FocusEvent) => void; 106 | 107 | /** 108 | * Callback when the input loses focus. 109 | */ 110 | onBlur?: (event: React.FocusEvent) => void; 111 | }; 112 | -------------------------------------------------------------------------------- /src/components/rcinputtag/index.ts: -------------------------------------------------------------------------------- 1 | export { default as InputTag } from './InputTag'; 2 | -------------------------------------------------------------------------------- /src/components/tag/Tag.css: -------------------------------------------------------------------------------- 1 | .input-tag-tag-item { 2 | margin: 0; 3 | padding: 0 0.5rem 0 0; 4 | display: flex; 5 | gap: 0.3rem; 6 | list-style-type: none; 7 | } 8 | 9 | .input-tag-tag-remove-btn { 10 | cursor: pointer; 11 | } 12 | 13 | .theme-1-input-tag-tag-item { 14 | padding: 0.5rem; 15 | color: white; 16 | background-color: rgb(39, 39, 39); 17 | border-radius: 5px; 18 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", 19 | "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 20 | } 21 | 22 | .theme-1-input-tag-tag-remove-btn { 23 | color: white; 24 | border: none; 25 | font-weight: bold; 26 | background-color: transparent; 27 | } 28 | 29 | .theme-1-input-tag-tag-remove-btn:hover { 30 | color: rgb(207, 31, 31); 31 | } 32 | 33 | .theme-2-input-tag-tag-item, 34 | .theme-3-input-tag-tag-item { 35 | padding: 0.3rem; 36 | background-color: rgb(199, 199, 199); 37 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", 38 | "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 39 | } 40 | 41 | .theme-2-input-tag-tag-remove-btn { 42 | color: white; 43 | padding: 0.1rem 0.3rem 0.15rem 0.3rem; 44 | font-weight: bold; 45 | border: none; 46 | border-radius: 50%; 47 | background-color: rgb(207, 31, 31); 48 | } 49 | 50 | .theme-3-input-tag-tag-item { 51 | padding: 0; 52 | gap: 0rem; 53 | } 54 | 55 | .theme-3-input-tag-tag-remove-btn { 56 | color: white; 57 | border: none; 58 | background-color: rgb(126, 126, 126); 59 | } 60 | 61 | .theme-3-input-tag-tag-content { 62 | padding: 0 0.3rem; 63 | } 64 | -------------------------------------------------------------------------------- /src/components/tag/Tag.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react'; 2 | import Tag from './Tag'; 3 | 4 | const meta: Meta = { 5 | component: Tag, 6 | }; 7 | 8 | export default meta; 9 | 10 | export const tag: StoryObj = { 11 | render: () => ( 12 | console.log(tagName)} /> 13 | ), 14 | }; 15 | -------------------------------------------------------------------------------- /src/components/tag/Tag.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { render, screen, fireEvent } from '@testing-library/react'; 4 | import Tag from './Tag'; 5 | import type { TagProps } from './Tag.types'; 6 | 7 | describe('Tag Component', () => { 8 | const setup = (propsOverride: Partial = {}) => { 9 | const onRemoveTagMock = jest.fn(); 10 | const defaultProps: TagProps = { 11 | tagName: 'React', 12 | onRemoveTag: onRemoveTagMock, 13 | }; 14 | 15 | render(); 16 | return { 17 | onRemoveTagMock, 18 | }; 19 | }; 20 | 21 | test('renders the tag name', () => { 22 | setup(); 23 | expect(screen.getByText('React')).toBeInTheDocument(); 24 | }); 25 | 26 | test('calls onRemoveTag when remove button is clicked', () => { 27 | const { onRemoveTagMock } = setup(); 28 | const removeBtn = screen.getByRole('button'); 29 | 30 | fireEvent.click(removeBtn); 31 | 32 | expect(onRemoveTagMock).toHaveBeenCalledWith('React'); 33 | expect(onRemoveTagMock).toHaveBeenCalledTimes(1); 34 | }); 35 | 36 | test('hides remove button when disabled is true', () => { 37 | setup({ disabled: true }); 38 | 39 | // Expect the remove button to not be visible 40 | const removeBtn = screen.queryByRole('button'); 41 | expect(removeBtn).toBeNull(); 42 | }); 43 | 44 | test('applies custom class names', () => { 45 | setup({ 46 | customRemoveButtonClass: 'custom-remove-btn-class', 47 | customTagContentClass: 'custom-content-class', 48 | }); 49 | 50 | expect(screen.getByText('React').className).toMatch(/custom-content-class/); 51 | const removeBtn = screen.getByRole('button'); 52 | expect(removeBtn.className).toMatch(/custom-remove-btn-class/); 53 | }); 54 | 55 | test('applies inline styles', () => { 56 | setup({ 57 | removeTagBtnStyleProps: { color: 'blue' }, 58 | }); 59 | 60 | const removeBtn = screen.getByRole('button'); 61 | 62 | expect(removeBtn).toHaveStyle('color: blue'); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/tag/Tag.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Tag.css'; 3 | import type { TagProps } from './Tag.types'; 4 | 5 | export default function Tag({ 6 | customRemoveButtonClass, 7 | customTagContentClass, 8 | disabled, 9 | tagName, 10 | removeTagBtnStyleProps, 11 | theme, 12 | onRemoveTag, 13 | }: TagProps) { 14 | return ( 15 | <> 16 | 33 | 38 | {tagName} 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/tag/Tag.types.ts: -------------------------------------------------------------------------------- 1 | export type TagProps = { 2 | /** 3 | * Custom class for the tag's remove button. 4 | */ 5 | customRemoveButtonClass?: string; 6 | 7 | /** 8 | * Custom class for the tag's text/content. 9 | */ 10 | customTagContentClass?: string; 11 | 12 | /** 13 | * Whether the tag (and its remove button) is disabled. 14 | */ 15 | disabled?: boolean; 16 | 17 | /** 18 | * The text or value of the tag. 19 | */ 20 | tagName: string; 21 | 22 | /** 23 | * Optional theme to style the tag. 24 | */ 25 | theme?: 'theme-1' | 'theme-2' | 'theme-3'; 26 | 27 | /** 28 | * Inline style props for the remove button. 29 | */ 30 | removeTagBtnStyleProps?: React.CSSProperties; 31 | 32 | /** 33 | * Callback fired when the remove button is clicked. 34 | * @param tagName - The tag being removed. 35 | */ 36 | onRemoveTag: (tagName: string) => void; 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/tag/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Tag } from './Tag'; 2 | -------------------------------------------------------------------------------- /src/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zshaian/react-tagit/d003f2c5b61e8255e245b05ef51ef78b7536ec72/src/images/demo.gif -------------------------------------------------------------------------------- /src/images/theme-1-demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zshaian/react-tagit/d003f2c5b61e8255e245b05ef51ef78b7536ec72/src/images/theme-1-demo.jpg -------------------------------------------------------------------------------- /src/images/theme-2-demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zshaian/react-tagit/d003f2c5b61e8255e245b05ef51ef78b7536ec72/src/images/theme-2-demo.jpg -------------------------------------------------------------------------------- /src/images/theme-3-demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zshaian/react-tagit/d003f2c5b61e8255e245b05ef51ef78b7536ec72/src/images/theme-3-demo.jpg -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | -------------------------------------------------------------------------------- /src/stories/Guide.mdx: -------------------------------------------------------------------------------- 1 | # react-tagit 2 | 3 | ![npm version](https://img.shields.io/npm/v/react-tagit) 4 | ![license](https://img.shields.io/npm/l/react-tagit) 5 | ![bundle size](https://img.shields.io/bundlephobia/minzip/react-tagit) 6 | ![types](https://img.shields.io/npm/types/react-tagit) 7 | 8 | A simple and customizable Input Tag Component for React. 9 | 10 | ## Demo 11 | 12 | ![Demo](https://raw.githubusercontent.com/zshaian/react-tagit/refs/heads/main/src/images/demo.gif) 13 | 14 | Check out the live playground on CodeSandbox [Playground](https://codesandbox.io/p/sandbox/react-tagit-9n6nvz) 15 | 16 | ## Table of Contents 17 | 18 | - [Installation](#installation) 19 | - [Usage](#usage) 20 | - [Props](#props) 21 | - [Styling & Theming](#styling--theming) 22 | - [Development](#development) 23 | - [Contributing](#contributing) 24 | - [License](#license) 25 | 26 | ## Installation 27 | 28 | Install the package using your preferred package manager: 29 | 30 | **NPM** 31 | 32 | ```bash 33 | npm install react-tagit 34 | ``` 35 | 36 | **Yarn** 37 | 38 | ```bash 39 | yarn add react-tagit 40 | ``` 41 | 42 | **PNPM** 43 | 44 | ```bash 45 | pnpm add react-tagit 46 | ``` 47 | 48 | ### Peer Dependencies 49 | 50 | Ensure you have the following peer dependencies installed: 51 | 52 | ```json 53 | "peerDependencies": { 54 | "react": "^19.0.0", 55 | "react-dom": "^19.0.0" 56 | } 57 | ``` 58 | 59 | ## Usage 60 | 61 | Here’s an example of how to use the `` component: 62 | 63 | ```tsx 64 | import { InputTag } from 'react-tagit'; 65 | import { useState } from 'react'; 66 | 67 | export default function App() { 68 | const [tags, setTags] = useState>([]); 69 | 70 | return ( 71 |
72 | 80 | 81 | 82 | ); 83 | } 84 | ``` 85 | 86 | And that's it! You now have a fully functional input tag component. 87 | 88 | ## Props 89 | 90 | Here’s a list of props you can pass to the `` component: 91 | 92 | | Prop | Type | Default | Description | 93 | | ----------------------------- | ------------------------------------- | ----------- | --------------------------------------------------------------------- | 94 | | `autoFocus` | `boolean` | `false` | Autofocus the tag input element when the component mounts. | 95 | | `customClass` | `object` | `{}` | Custom classes for the elements of the InputTag component. | 96 | | `disabled` | `boolean` | `false` | Disable the InputTag component. Hides the remove button for each tag. | 97 | | `inputTagContainerStyleProps` | `object` | `{}` | Style props for the container element. | 98 | | `labelStyleProps` | `object` | `{}` | Style props for the label element. | 99 | | `inputStyleProps` | `object` | `{}` | Style props for the input element. | 100 | | `tagsContainerStyleProps` | `object` | `{}` | Style props for the tags list container. | 101 | | `tagsStyleProps` | `object` | `{}` | Style props for individual tag elements. | 102 | | `removeTagBtnStyleProps` | `object` | `{}` | Style props for the remove button on each tag. | 103 | | `hideLabel` | `boolean` | `false` | Whether to hide the label. | 104 | | `label` | `string` | `'Tags'` | Label for the input. | 105 | | `maxTags` | `number` | `infinite` | Maximum number of tags allowed. | 106 | | `maxTagsValue` | `number` | `infinite` | Maximum number of characters per tag. | 107 | | `name` | `string` | `'tags'` | Name attribute for the input element. | 108 | | `separator` | `'Enter' \| 'Space'` | `'Enter'` | The key that triggers tag creation. | 109 | | `theme` | `'theme-1' \| 'theme-2' \| 'theme-3'` | `undefined` | Available themes for styling the component. | 110 | | `value` | `Array` | `[]` | Current tag values. | 111 | | `onChange` | `(tags: Array) => void` | `undefined` | Function to update the tag values. | 112 | | `onFocus` | `(event: FocusEvent) => void` | `() => {}` | Callback when the input gains focus. | 113 | | `onBlur` | `(event: FocusEvent) => void` | `() => {}` | Callback when the input loses focus. | 114 | 115 | ## Styling & Theming 116 | 117 | `react-tagit` comes with built-in themes and allows you to customize styles using class names or inline styles. 118 | 119 | ### Built-in Themes 120 | 121 | You can use one of the predefined themes: 122 | 123 | - **Theme 1** 124 | 125 | ```tsx 126 | 127 | ``` 128 | 129 | ![Theme 1 Demo](https://raw.githubusercontent.com/zshaian/react-tagit/refs/heads/main/src/images/theme-1-demo.jpg) 130 | 131 | - **Theme 2** 132 | 133 | ```tsx 134 | 135 | ``` 136 | 137 | ![Theme 2 Demo](https://raw.githubusercontent.com/zshaian/react-tagit/refs/heads/main/src/images/theme-2-demo.jpg) 138 | 139 | - **Theme 3** 140 | ```tsx 141 | 142 | ``` 143 | ![Theme 3 Demo](https://raw.githubusercontent.com/zshaian/react-tagit/refs/heads/main/src/images/theme-3-demo.jpg) 144 | 145 | ### Custom Classes 146 | 147 | You can override the default classes by passing a `customClass` object: 148 | 149 | ```tsx 150 | 161 | ``` 162 | 163 | ### Default Classes 164 | 165 | Here are the default class names you can target for styling: 166 | 167 | - `input-tag-container-element` 168 | - `input-tag-label-element` 169 | - `input-tag-list-container-element` 170 | - `input-tag-tag-item-element` 171 | - `input-tag-tag-remove-btn-element` 172 | - `input-tag-tag-content-element` 173 | - `input-tag-input-element` 174 | 175 | ## Development 176 | 177 | ### Testing 178 | 179 | This project uses [Jest](https://jestjs.io/) and [Testing Library](https://testing-library.com/) for testing. To run the tests: 180 | 181 | ```bash 182 | npm run test 183 | ``` 184 | 185 | ### Storybook 186 | 187 | The project uses [Storybook](https://storybook.js.org/) for component documentation and previews. To start Storybook: 188 | 189 | ```bash 190 | npm run storybook 191 | ``` 192 | 193 | This will start a local server and provide a preview URL. 194 | 195 | ### Building 196 | 197 | The project uses [Rollup](https://rollupjs.org/) as the bundler. To build the project: 198 | 199 | ```bash 200 | npm run build 201 | ``` 202 | 203 | The compiled code will be available in the `dist` folder. 204 | 205 | ## Contributing 206 | 207 | Contributions are welcome! Please open an issue or submit a pull request for any improvements or bug fixes. 208 | 209 | ## License 210 | 211 | This project is licensed under the [MIT License](LICENSE). 212 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "lib": ["dom", "DOM.Iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noFallthroughCasesInSwitch": false, 11 | "module": "ESNext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx" 17 | }, 18 | "include": ["src"], 19 | "exclude": ["node_modules"] 20 | } 21 | --------------------------------------------------------------------------------