DEV Community

Nataly
Nataly

Posted on • Edited on

How to create an advanced React tags input component

There are enough good tutorials on how to build a tag input component in React JS. However, typically, it is a very simple component without functionality to search for existing tags (for example, from a database) and present them as options to choose from. Also, there was no tutorial on how to add functionality for colorizing tags.

In this tutorial, I will try to cover both of these topics: autocomplete tags and add colors to newly added tags.

Project Setup

We are going to create a tags input component using React JS. To start, we will create a new React application using the Create React App (CRA) tool.

npx create-react-app tags-input
cd tags-input
Enter fullscreen mode Exit fullscreen mode

Then, install the necessary dependencies. For this project we will use react-select, react-popper, and nanoid.

npm install react-select react-popper nanoid
Enter fullscreen mode Exit fullscreen mode

Let's create two additional folders inside the src folder called "data" and "tags".

Inside the data folder, create two files: COLORS.js and TAGS.js.

// COLORS.js
export const COLORS = [
    "#e93a55", "#f94e45", "#ff8549", "#3e993c", "#1e8a78",
    "#238cd7", "#6d65a8", "#414a53", "#e36dab", "#4abeb7",
    "#ff8657", "#ffb855", "#84c15f", "#00bd9d", "#00b2d7",
    "#967cd7", "#a8b2bc", '#fb5779', '#ff7511', '#ffa800',
    '#ffd100', '#ace60f', '#19db7e', '#00d4c8', '#48dafd',
    '#008ce3', '#6457f9', '#9f46e4', '#ff78ff', '#ff4ba6'
]
Enter fullscreen mode Exit fullscreen mode
// TAGS.js
export const TAGS = [
    { id: 1, value: "React",  label: "React", color: COLORS[1] },
    { id: 2, value: "Angular", label: "Angular", color: COLORS[2] },
    { id: 3, value: "Vue", label: "Vue", color: COLORS[3] },
    { id: 4, value: "Javascript", label: "Javascript", color: COLORS[4] },
    { id: 5, value: "Typescript", label: "Typescript", color: COLORS[5] }
]
Enter fullscreen mode Exit fullscreen mode

Let's start to work on React tags input component

Inside a tags folder create files called Tags.js. This component will render list of tags and tags input field.

import { useCallback, useEffect, useRef, useState } from "react"
import { TAGS } from "../data/TAGS"
import { TagsList } from "./TagsList"
import { TagsInputField } from "./TagsInputField"

export const Tags = () => {

    const [tags, setTags] = useState(TAGS.slice(0, 3))

    return (
        <div className="TaskTags">
            <TagsList
                tags={tags}
                setTags={setTags}
            />

            <TagsInputField
                tags={tags}
                setTags={setTags}
            />
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Than in the same folder create files TagsList.js, Tag.js and TagsInputField.js files.

//TagsList.js
import { Tag } from "./Tag"

export const TagsList = ({
    tags,
    setTags
}) => {

    return (
        <>
            {tags.map((tag, index) => <Tag
                key={tag.id}
                tag={tag}
                setTags={setTags}
            />)}
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode
// Tag.js
import { forwardRef } from "react"

export const Tag = forwardRef(({ tag, setTags }, ref) => {

    const removeTag = () => setTags(prevState => prevState.filter(i => i.id !== tag.id))

    return (
        <div
            ref={ref}
            className="TaskTag"
            style={{
                background: tag.color
            }}
        >
            <span className="TaskTag-label">{tag.label}</span>
            <svg onClick={removeTag} className="TaskTag-deleteButton" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
                <path fill="none" d="M0 0h24v24H0z"/>
                <path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636z"/>
            </svg>
        </div>
    )
})
Enter fullscreen mode Exit fullscreen mode

TagsInputField.js

In this component we are going to use async select component, that provide possibility to fetch from server existing tags and create new tags.

import { forwardRef, useCallback, useEffect } from "react"
import { nanoid } from "nanoid"
import AsyncCreatableSelect from "react-select/async-creatable"
import { components } from "react-select"
import { TAGS } from "../data/TAGS"
import { COLORS } from "../data/COLORS"

export const TagsInputField = forwardRef(({
    tags,
    setTags
}, ref) => {

    const addTag = (value) => setTags(prevState => [...prevState, value])

    const handleCreateOption = async (value) => {
        const additionalOption = createOption(value)

        addTag(additionalOption)
    }

    // function to remove tags on Backspace key press
    const listener = useCallback((e) => {
        if (e.key === 'Backspace') {
            setTags(prev => prev.filter((_, i) => i !== tags.length - 1))
            if (ref.current) ref.current.focus()
        }

    }, [ref, tags, setTags])

    // add "keydown" event listener
    useEffect(() => {
        if (ref.current) ref.current.focus()

        document.addEventListener("keydown", listener)

        return () => {
            document.removeEventListener("keydown", listener)
        }
    }, [ref, listener])

    return (
        <AsyncCreatableSelect
            ref={ref}
            name="tags"
            value={{}}
            loadOptions={(value) => promiseOptions(value, tags)}
            menuPlacement={'auto'}
            components={{ LoadingIndicator: null, Option, SinleValue }}
            classNamePrefix="select"
            placeholder=""
            styles={tagsListStyles}
            cacheOptions
            onCreateOption={handleCreateOption}
            onChange={addTag}
        />
    )
})

// "fetch" options from "backend"
const promiseOptions = (inputValue, tags) =>
    new Promise((resolve) => {
        setTimeout(() => {
            resolve(filterTags(inputValue, tags))
        }, 1000)
    }
)

// filter already selected tags
// and filter tags from "backend" by input value
const filterTags = (inputValue, tags) => {
    return TAGS.filter(x => !tags.includes(x)).filter((i) =>
        i.label.toLowerCase().includes(inputValue.toLowerCase())
    )
}

const createOption = (value) => ({
    id: nanoid(),
    value,
    label: value,
    color: COLORS[16]
})

// And let's add some custom styling to react-select component
const SinleValue = (props) => {

    const { data } = props

    return (
        <components.SingleValue {...props}>
            <div className="Select-option">
                <span className="SelectColorSingleValue" style={{
                    backgroundColor: data.color
                }}>
                    {data.label}
                </span>
            </div>
        </components.SingleValue>
    )
}

const Option = (props) => {

    const {data} = props

    return (
        <components.Option {...props}>
            <div className="Select-option">
                <span>
                    {data.label}
                </span>
            </div>
        </components.Option>
    )
}

const tagsListStyles = ({
    container: (provided) => ({
        ...provided,
        width: "100%"
    }),

    control: (provided, state) => ({
        ...provided,
        height: 28,
        minHeight: 28,
        width: "100%",
        fontSize: 14,
        borderRadius: 6,
        padding: '0 0 0 8px',
        ':hover': {
            cursor: 'pointer'
        },
        borderColor: 'transparent',
        border: state.isFocused ? '1px solid transparent' : '1px solid #transparent',
        boxShadow: state.isFocused ? 0 : 0,
        boxSizing: 'border-box',
        '&:hover': {
            borderColor: state.isFocused ? 'transparent' : 'transparent',
            boxShadow: state.isFocused ? 0 : 0,
            boxSizing: 'border-box',
        }
    }),

    valueContainer: (provided) => ({
        ...provided,
        padding: 0
    }),

    indicatorSeparator: (provided) => ({
        ...provided,
        display: 'none'
    }),

    indicatorContainer: (provided) => ({
        ...provided,
        padding: 3
    }),

    dropdownIndicator: (provided) => ({
        ...provided,
        display: 'none'
    }),

    menu: (provided) => ({
        ...provided,
        boxShadow: '0 0 0 1px rgb(111 119 130 / 15%), 0 5px 20px 0 rgb(21 27 38 / 8%)',
        background: '#fff',
        width: "100%",
        boxSizing: 'border-box',
        margin: 0
    }),

    option: (provided, {isSelected}) => ({
        ...provided,
        color: "#151b26",
        fontSize: 14,
        minHeight: "36px",
        backgroundColor: isSelected && "#fff" ,
        ':hover': {
            cursor: 'pointer',
            backgroundColor: " #f2f6f8"
        }
    })
})

Enter fullscreen mode Exit fullscreen mode

Now we can import and use in our App.js

import './App.css'
import { Tags } from './tags/Tags'

function App() {

  return (
    <div className="App">
      <div className="Tags">
        <Tags />
      </div>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

And also add these CSS styles in our src/App.css

*{
    margin: 0;
    padding: 0;
}
html, body{
    height: 100%;
}
body{
    display: flex;
    justify-content: center;
    align-items: center;
    font-family: 'Courier New', Courier, monospace;
    font-weight: bold;
}

.App {
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    position: absolute;
}

.Tags {
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    display: flex;
    flex-direction: column;
    position: absolute;
    justify-content: center;
    align-items: center;
}

.TaskTags {
    align-items: center;
    border: 1px solid #14aaf5;
    border-radius: 8px;
    display: flex;
    min-height: 38px;
    padding: 5px;
    display: flex;
    min-width: 1px;
    width: 700px;
    flex-wrap: wrap;
    max-width: 100%;
}

.css-fyq6mk-container {
    display: flex;
    flex: 1 1 auto;
    min-width: 180px;
    width: auto !important;
}

.Select-option {
    align-items: center;
    display: flex;
}

.PopoverReferenceElement {
    align-items: center;
    display: flex;
    flex: 1 1 auto;
}

.ActiveTaskData-content .PopoverReferenceElement {
    display: flex;
    flex: 0 0 auto;
}

.TaskTagWithColorSelect {
    align-items: center;
    display: flex;
}

.TaskTag {
    margin-bottom: 2px;
    margin-right: 4px;
    margin-top: 2px;
    display: flex;
    align-items: center;
    color: #fff;
    fill: #fff;
    border-radius: 10px;
    font-size: 12px;
    height: 20px;
    line-height: 20px;
    padding: 0 8px;
}

.TaskTag-label {
    text-overflow: ellipsis;
    box-sizing: border-box;
    display: block;
    font-weight: 400;
    max-width: 180px;
    overflow: hidden;
    text-align: left;
    white-space: nowrap;
    font-size: 12px;
    height: 20px;
    line-height: 20px;
    padding: 0 8px;
}

.TaskTag-deleteButton {
    border-radius: 10px;
    cursor: pointer;
    font-size: 12px;
    height: 12px;
    line-height: 20px;
    min-height: 12px;
    min-width: 12px;
    width: 12px;
}

.ColorSelectPopUp {
    align-items: center;
    background: #fff;
    border-radius: 4px;
    box-shadow: 0 0 0 1px #e8ecee, 0 5px 20px 0 rgba(21, 7, 38, 0.08);
    box-sizing: border-box;
    color: #151b26;
    display: flex;
    justify-content: center;
    flex-wrap: wrap;
    padding: 10px;
    position: relative;
    width: 241px;
    z-index: 5000;
}

.ColorItem {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 22px;
    height: 22px;
    border-width: 1px;
    border-color: #fff;
    border-style: solid;
    border-radius: 50%;
    background-color: #fff;
    cursor: pointer;
    padding: 2px;
    transition-property: border-color;
    transition-duration: 200ms;
}

.ColorItem:hover {
    border-color: #26d1b9;
    background-color: #fff;
}

.SelectedColorItem {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 22px;
    height: 22px;
    background-color: #fff;
    border-width: 1px;
    border-color: #26d1b9;
    border-style: solid;
    border-radius: 50%;
    background-color: #fff;
    cursor: pointer;
    padding: 2px;
    transition-property: border-color;
    transition-duration: 200ms;
}

.ColorIndicator {
    border-radius: 50%;
    height: 18px;
    width: 18px;
}

Enter fullscreen mode Exit fullscreen mode

We have finished with first part
Now we can add existing tags from list, remove selected tags and create new tags. However all new tags will be grey color.

Second topic: Function for tag color changing

And now we are goin to add possibility to select colors for all created tags.

Tags colorizing

Let me briefly describe how it will work.
When we create new tag, we add it to tags list. Next, using react-popper library, we will show popup with list of available colors, where user may select color for tag.
If user start typing new tag we simply hide popup.

Popup element should be displayed near created tag, it means we have to set last tag in the list as "referenceElement".
However tags list is an array of elements and if we want to refer to one of it's elements we need to save to the ref array of elements: ref={el => tagsRef.current[index] = el}

First of all let's modify Tags.js component: add reference element, popper element, state to show popper and tagsRef (which is an empty array).

Additionally we need to add the state to store id of active tag for which we will change color.

And finally add outsideClickHandler to hide popup.

import { useCallback, useEffect, useRef, useState } from "react"
import { TAGS } from "../data/TAGS"
import { TagsList } from "./TagsList"
import { TagsInputField } from "./TagsInputField"

export const Tags = () => {

    const [tags, setTags] = useState(TAGS.slice(0, 3))

    const [referenceElement, setReferenceElement] = useState(null)
    const [popperElement, setPopperElement] = useState(null)
    const [showPopper, setShowPopper] = useState(false)


    const [activeTagId, setActiveTagId] = useState(null)

    const tagsRef = useRef([])

    useEffect(() => {
        if (activeTagId === null) return

        if (tags.indexOf(tags.find(tag => tag.id === activeTagId)) >= 0) {
            setReferenceElement(tagsRef.current[tags.indexOf(tags.find(tag => tag.id === activeTagId))])
            setShowPopper(true)
        }
    }, [setShowPopper, tags, tagsRef, activeTagId])

    const selectRef = useRef(null)

    const outsideClickHandler = useCallback((event) => {
        if (!popperElement || popperElement.contains(event.target)) {
            return
        }
        if (popperElement && !popperElement.contains(event.target)) {
            setShowPopper(false)
            setActiveTagId(null)
        }
    }, [popperElement, setShowPopper, setActiveTagId])

    useEffect(() => {
        if (popperElement) {
            document.addEventListener("mousedown", outsideClickHandler)
            return () => {
                document.removeEventListener("mousedown", outsideClickHandler)
            }
        }
    }, [popperElement, outsideClickHandler])

    return (
        <div className="TaskTags">
            <TagsList
                referenceElement={referenceElement}
                popperElement={popperElement}
                selectRef={selectRef}
                activeTagId={activeTagId}
                tags={tags}
                tagsRef={tagsRef}
                showPopper={showPopper}
                setPopperElement={setPopperElement}
                setTags={setTags}
            />

            <TagsInputField
                ref={selectRef}
                tags={tags}
                setTags={setTags}
                setActiveTagId={setActiveTagId}
                setShowPopper={setShowPopper}
            />
        </div>
    )
}

Enter fullscreen mode Exit fullscreen mode

Next we are goin to modify TagsInput.js: set active tag id when adding new tag and hide popup when user starts typing new tag title in input field.

import { forwardRef, useCallback, useEffect } from "react"
import { nanoid } from "nanoid"
import AsyncCreatableSelect from "react-select/async-creatable"
import { components } from "react-select"
import { TAGS } from "../data/TAGS"
import { COLORS } from "../data/COLORS"

export const TagsInputField = forwardRef(({
    tags,
    setTags,
    setActiveTagId,
    setShowPopper
}, ref) => {

    const addTag = (value) => setTags(prevState => [...prevState, value])

    const handleCreateOption = async (value) => {
        const additionalOption = createOption(value)

        addTag(additionalOption)

        setActiveTagId(additionalOption.id)
    }

    const listener = useCallback((e) => {
        if (e.key === 'Backspace') {
            setTags(prev => prev.filter((_, i) => i !== tags.length - 1))
            if (ref.current) ref.current.focus()
        }

        setShowPopper(false)
        setActiveTagId(null)
    }, [ref, tags, setTags, setShowPopper, setActiveTagId])

    useEffect(() => {
        if (ref.current) ref.current.focus()

        document.addEventListener("keydown", listener)

        return () => {
            document.removeEventListener("keydown", listener)
        }
    }, [ref, listener])

// other code remains without changes
Enter fullscreen mode Exit fullscreen mode

React tags input component COMPLETED! 🎉

Implementation of this code can be seen in our real project - a self-hosted project management system Hubio.

Top comments (2)

Collapse
 
3bdelrahman profile image
Abdelrahman hassan hamdy

awesome

Collapse
 
mio_im profile image
Nataly

Thanks ❤️