Menu

Building a Tag Input Field with React MUI Chip and Autocomplete

As a front-end developer, creating intuitive and functional input fields is a common requirement. One particularly useful pattern is the tag input field - allowing users to add multiple selections as tags. Material UI (MUI) provides powerful components like Chip and Autocomplete that can be combined to create a robust tag input solution.

In this guide, I'll walk you through building a complete tag input field with React MUI that includes autocomplete functionality, custom styling, validation, and advanced features. We'll explore both basic implementations and more sophisticated solutions that you can adapt for your projects.

What You'll Learn

By the end of this tutorial, you'll be able to:

  • Understand MUI Chip component fundamentals and its key props
  • Implement a basic tag input field using MUI Chip and Autocomplete
  • Create controlled and uncontrolled tag input components
  • Add validation, custom styling, and accessibility features
  • Handle complex use cases like async data loading and form integration
  • Implement performance optimizations for large datasets

Understanding MUI Chip Component

The MUI Chip component is a versatile UI element that represents compact entities like tags, filters, or options. Before we combine it with Autocomplete, let's understand what makes Chip so useful for tag inputs.

Core Chip Properties and Variants

Chips in MUI are compact elements that represent attributes, inputs, or actions. They're perfect for tag inputs because they provide built-in functionality for displaying selected values with options to delete them.

The Chip component offers several variants that change its appearance and behavior:

VariantDescriptionUse Case
filled (default)Solid background with contrasting textStandard tags, selected filters
outlinedTransparent with outlined borderMore subtle tag representations

You can also control the Chip's size using the size prop, which accepts small or medium (default).

Essential Chip Props

Chips come with several key props that make them ideal for tag inputs:

PropTypeDescription
labelnodeThe content displayed on the chip
onDeletefunctionCallback fired when the delete icon is clicked
deleteIconnodeCustom delete icon element
colorstringColor of the chip (default, primary, secondary, etc.)
disabledbooleanDisables interaction with the chip
iconnodeIcon element displayed before the label
clickablebooleanMakes the chip clickable
onClickfunctionCallback fired when the chip is clicked

Let's look at a basic Chip example to understand its usage:


import { Chip, Stack } from '@mui/material';
import FaceIcon from '@mui/icons-material/Face';

function BasicChipDemo() {
  const handleDelete = () => {
    console.log('Chip deleted');
  };

  return (
    <Stack direction="row" spacing={1}>
      <Chip label="Basic Chip" />
      <Chip 
        label="Deletable Chip" 
        onDelete={handleDelete} 
      />
      <Chip 
        icon={<FaceIcon />} 
        label="With Icon" 
        variant="outlined" 
        color="primary" 
      />
      <Chip 
        label="Clickable Chip" 
        clickable 
        onClick={() => console.log('Chip clicked')} 
      />
    </Stack>
  );
}

Customizing Chip Appearance

You can customize Chips using MUI's sx prop or theme overrides. The sx prop provides a shorthand way to apply styles directly to components:


<Chip
  label="Custom Chip"
  sx={{
    backgroundColor: '#f5f5f5',
    borderRadius: '4px',
    '& .MuiChip-label': {
      fontWeight: 'bold',
    },
    '&:hover': {
      backgroundColor: '#e0e0e0',
    },
  }}
/>

For more consistent styling across your application, you can customize Chips through theme overrides:


import { createTheme, ThemeProvider } from '@mui/material/styles';

const theme = createTheme({
  components: {
    MuiChip: {
      styleOverrides: {
        root: {
          borderRadius: '4px',
          backgroundColor: '#f8f9fa',
        },
        label: {
          fontWeight: 500,
        },
        deleteIcon: {
          color: '#666',
          '&:hover': {
            color: '#333',
          },
        },
      },
      variants: [
        {
          props: { variant: 'tag' },
          style: {
            backgroundColor: '#e3f2fd',
            color: '#1976d2',
            borderRadius: '16px',
          },
        },
      ],
    },
  },
});

function ThemedChips() {
  return (
    <ThemeProvider theme={theme}>
      <Chip label="Themed Chip" onDelete={() => {}} />
      <Chip label="Custom Variant" variant="tag" />
    </ThemeProvider>
  );
}

Understanding MUI Autocomplete

The Autocomplete component is MUI's solution for combobox inputs that provide suggestions as you type. When paired with Chip, it becomes a powerful tag input field.

Key Autocomplete Props

PropTypeDescription
multiplebooleanAllows multiple selections, rendering them as chips
optionsarrayArray of available options
valueanyCurrent value (controlled component)
defaultValueanyDefault value (uncontrolled component)
onChangefunctionCallback fired when the value changes
getOptionLabelfunctionFunction that defines how to display options
renderTagsfunctionFunction to customize how tags are rendered
freeSolobooleanAllows values not in the options list
filterOptionsfunctionCustom filter function for options
loadingbooleanIndicates if options are being loaded
disableClearablebooleanRemoves the clear button

Building a Basic Tag Input Field

Now let's combine Chip and Autocomplete to create a basic tag input field. We'll start with a simple implementation and gradually add more features.

Setting Up the Project

First, let's set up a new React project and install the necessary dependencies:


// Using npm
npm install @mui/material @mui/icons-material @emotion/react @emotion/styled

// Using yarn
yarn add @mui/material @mui/icons-material @emotion/react @emotion/styled

Creating a Simple Tag Input Component

Let's build a basic tag input field that allows users to select multiple options from a predefined list:


import React, { useState } from 'react';
import { Autocomplete, TextField, Chip } from '@mui/material';

const SimpleTagInput = () => {
  // Sample options for our tag input
  const tagOptions = [
    'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
    'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js'
  ];

  // State to track selected tags
  const [selectedTags, setSelectedTags] = useState([]);

  const handleChange = (event, newValue) => {
    setSelectedTags(newValue);
  };

  return (
    <Autocomplete
      multiple
      options={tagOptions}
      value={selectedTags}
      onChange={handleChange}
      renderInput={(params) => (
        <TextField
          {...params}
          variant="outlined"
          label="Select Tags"
          placeholder="Tags"
        />
      )}
      renderTags={(value, getTagProps) =>
        value.map((option, index) => (
          <Chip
            label={option}
            {...getTagProps({ index })}
            color="primary"
            variant="outlined"
          />
        ))
      }
    />
  );
};

export default SimpleTagInput;

In this example:

  • We use the multiple prop on Autocomplete to allow selecting multiple options
  • We maintain selected tags in state with useState
  • The renderTags prop customizes how selected options appear as Chips
  • The getTagProps function provides necessary props for each Chip, including delete functionality

Handling Tag Selection and Removal

The Autocomplete component handles most of the tag selection and removal logic for us:

  1. When a user selects an option from the dropdown, it's added to the value array
  2. When a user clicks the delete icon on a Chip, the option is removed from the array
  3. The onChange handler is called with the updated array, which we use to update our state

Enhancing the Tag Input with Custom Styling

Let's improve our tag input with custom styling to make it more visually appealing and match your application's design:


import React, { useState } from 'react';
import { Autocomplete, TextField, Chip, Box } from '@mui/material';
import { styled } from '@mui/material/styles';

// Custom styled Chip component
const CustomChip = styled(Chip)(({ theme }) => ({
  borderRadius: '6px',
  backgroundColor: theme.palette.primary.light,
  color: theme.palette.primary.contrastText,
  fontWeight: 500,
  '&:hover': {
    backgroundColor: theme.palette.primary.main,
  },
  '& .MuiChip-deleteIcon': {
    color: theme.palette.primary.contrastText,
    '&:hover': {
      color: '#fff',
    },
  },
}));

// Custom styled Autocomplete
const StyledAutocomplete = styled(Autocomplete)(({ theme }) => ({
  '& .MuiAutocomplete-tag': {
    margin: '2px',
  },
  '& .MuiInputBase-root': {
    padding: '4px 8px',
  },
}));

const StyledTagInput = () => {
  const tagOptions = [
    'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
    'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js'
  ];

  const [selectedTags, setSelectedTags] = useState([]);

  return (
    <Box sx={{ width: '100%', maxWidth: 500 }}>
      <StyledAutocomplete
        multiple
        options={tagOptions}
        value={selectedTags}
        onChange={(event, newValue) => setSelectedTags(newValue)}
        renderInput={(params) => (
          <TextField
            {...params}
            variant="outlined"
            label="Select Tags"
            placeholder={selectedTags.length > 0 ? '' : 'Add tags...'}
            InputProps={{
              ...params.InputProps,
              sx: { paddingY: '8px' }
            }}
          />
        )}
        renderTags={(value, getTagProps) =>
          value.map((option, index) => (
            <CustomChip
              label={option}
              {...getTagProps({ index })}
            />
          ))
        }
      />
    </Box>
  );
};

export default StyledTagInput;

In this enhanced version:

  • We've created a custom styled Chip component with a different shape and colors
  • We've adjusted the Autocomplete's styling to improve the spacing of tags
  • We've added conditional placeholder text that only shows when no tags are selected
  • We've wrapped the component in a Box with width constraints

Building a Tag Input with Free Text Entry

Often, you'll want to allow users to enter custom tags that aren't in the predefined list. The freeSolo prop enables this functionality:


import React, { useState } from 'react';
import { Autocomplete, TextField, Chip, Box } from '@mui/material';

const FreeTextTagInput = () => {
  const suggestedTags = [
    'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
    'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js'
  ];

  const [selectedTags, setSelectedTags] = useState([]);

  const handleChange = (event, newValue) => {
    // Filter out any empty strings that might be entered
    const filteredValues = newValue.filter(tag => tag.trim() !== '');
    setSelectedTags(filteredValues);
  };

  const handleKeyDown = (event) => {
    // Add a new tag when the user presses Enter or comma
    if (['Enter', ','].includes(event.key) && event.target.value.trim() !== '') {
      event.preventDefault();
      const newTag = event.target.value.trim();
      if (!selectedTags.includes(newTag)) {
        setSelectedTags([...selectedTags, newTag]);
        // Clear the input field
        event.target.value = '';
      }
    }
  };

  return (
    <Box sx={{ width: '100%', maxWidth: 500 }}>
      <Autocomplete
        multiple
        freeSolo
        options={suggestedTags}
        value={selectedTags}
        onChange={handleChange}
        renderInput={(params) => (
          <TextField
            {...params}
            variant="outlined"
            label="Tags"
            placeholder="Add tags..."
            onKeyDown={handleKeyDown}
          />
        )}
        renderTags={(value, getTagProps) =>
          value.map((option, index) => (
            <Chip
              label={option}
              {...getTagProps({ index })}
              color="primary"
            />
          ))
        }
      />
    </Box>
  );
};

export default FreeTextTagInput;

Key features of this implementation:

  • The freeSolo prop allows entering values not in the options list
  • We added a handleKeyDown function to support creating tags by pressing Enter or comma
  • We filter out empty strings to prevent invalid tags
  • We check for duplicates before adding new tags

Advanced: Tag Input with Validation

Let's build a more sophisticated tag input that includes validation for the entered tags:


import React, { useState } from 'react';
import { 
  Autocomplete, 
  TextField, 
  Chip, 
  Box,
  Typography 
} from '@mui/material';
import { styled } from '@mui/material/styles';

// Custom styled error message
const ErrorMessage = styled(Typography)(({ theme }) => ({
  color: theme.palette.error.main,
  fontSize: '0.75rem',
  marginTop: theme.spacing(0.5),
  marginLeft: theme.spacing(1.5),
}));

const ValidatedTagInput = () => {
  const suggestedTags = [
    'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
    'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js'
  ];

  const [selectedTags, setSelectedTags] = useState([]);
  const [inputValue, setInputValue] = useState('');
  const [error, setError] = useState('');

  // Validation rules
  const validateTag = (tag) => {
    if (tag.length < 2) {
      return 'Tag must be at least 2 characters long';
    }
    if (tag.length > 20) {
      return 'Tag cannot exceed 20 characters';
    }
    if (!/^[a-zA-Z0-9.#+-_ ]+$/.test(tag)) {
      return 'Tag can only contain letters, numbers, and basic punctuation';
    }
    if (selectedTags.includes(tag)) {
      return 'This tag has already been added';
    }
    return '';
  };

  const handleInputChange = (event, newInputValue) => {
    setInputValue(newInputValue);
    // Clear error when input changes
    if (error) setError('');
  };

  const handleChange = (event, newValue) => {
    // Handle deletion (which is always valid)
    if (newValue.length < selectedTags.length) {
      setSelectedTags(newValue);
      return;
    }

    // Handle addition - get the new tag
    const newTag = newValue[newValue.length - 1];
    
    // Validate the new tag
    const validationError = validateTag(newTag);
    if (validationError) {
      setError(validationError);
      return;
    }

    setSelectedTags(newValue);
  };

  const handleKeyDown = (event) => {
    if (event.key === 'Enter' && inputValue) {
      event.preventDefault();
      
      // Validate the tag
      const validationError = validateTag(inputValue);
      if (validationError) {
        setError(validationError);
        return;
      }

      // Add the tag if it's valid
      if (!selectedTags.includes(inputValue)) {
        setSelectedTags([...selectedTags, inputValue]);
        setInputValue('');
      }
    }
  };

  return (
    <Box sx={{ width: '100%', maxWidth: 500 }}>
      <Autocomplete
        multiple
        freeSolo
        options={suggestedTags.filter(option => !selectedTags.includes(option))}
        value={selectedTags}
        inputValue={inputValue}
        onInputChange={handleInputChange}
        onChange={handleChange}
        renderInput={(params) => (
          <TextField
            {...params}
            variant="outlined"
            label="Tags"
            placeholder="Add tags..."
            onKeyDown={handleKeyDown}
            error={!!error}
          />
        )}
        renderTags={(value, getTagProps) =>
          value.map((option, index) => (
            <Chip
              label={option}
              {...getTagProps({ index })}
              color="primary"
            />
          ))
        }
      />
      {error && <ErrorMessage>{error}</ErrorMessage>}
    </Box>
  );
};

export default ValidatedTagInput;

This implementation includes:

  • Tag validation with specific rules (length, characters, duplicates)
  • Error messages that display below the input
  • Controlled input value for better validation handling
  • Filtering of already selected tags from the suggestions

Integrating with Form Libraries

In real-world applications, tag inputs are often part of larger forms. Let's see how to integrate our tag input with a popular form library like Formik:


import React from 'react';
import { Formik, Form, Field } from 'formik';
import * as Yup from 'yup';
import { 
  Autocomplete, 
  TextField, 
  Chip, 
  Box, 
  Button,
  Typography
} from '@mui/material';

// Define validation schema
const FormSchema = Yup.object().shape({
  title: Yup.string()
    .required('Title is required')
    .min(5, 'Title must be at least 5 characters'),
  tags: Yup.array()
    .min(1, 'At least one tag is required')
    .max(5, 'Maximum 5 tags allowed')
});

const FormIntegratedTagInput = () => {
  const suggestedTags = [
    'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
    'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js'
  ];

  return (
    <Box sx={{ maxWidth: 600, mx: 'auto', p: 2 }}>
      <Typography variant="h5" gutterBottom>
        Create New Article
      </Typography>
      
      <Formik
        initialValues={{
          title: '',
          tags: []
        }}
        validationSchema={FormSchema}
        onSubmit={(values, { setSubmitting }) => {
          // In a real app, you would submit to an API
          console.log('Form values:', values);
          alert(JSON.stringify(values, null, 2));
          setSubmitting(false);
        }}
      >
        {({
          values,
          errors,
          touched,
          handleChange,
          handleBlur,
          setFieldValue,
          isSubmitting
        }) => (
          <Form>
            <Box sx={{ mb: 3 }}>
              <TextField
                fullWidth
                name="title"
                label="Article Title"
                value={values.title}
                onChange={handleChange}
                onBlur={handleBlur}
                error={touched.title && Boolean(errors.title)}
                helperText={touched.title && errors.title}
                margin="normal"
              />
            </Box>
            
            <Box sx={{ mb: 3 }}>
              <Autocomplete
                multiple
                freeSolo
                options={suggestedTags}
                value={values.tags}
                onChange={(event, newValue) => {
                  setFieldValue('tags', newValue);
                }}
                renderInput={(params) => (
                  <TextField
                    {...params}
                    label="Tags"
                    placeholder="Add tags..."
                    error={touched.tags && Boolean(errors.tags)}
                    helperText={touched.tags && errors.tags}
                  />
                )}
                renderTags={(value, getTagProps) =>
                  value.map((option, index) => (
                    <Chip
                      label={option}
                      {...getTagProps({ index })}
                      color="primary"
                    />
                  ))
                }
              />
            </Box>
            
            <Button
              type="submit"
              variant="contained"
              color="primary"
              disabled={isSubmitting}
            >
              Submit
            </Button>
          </Form>
        )}
      </Formik>
    </Box>
  );
};

export default FormIntegratedTagInput;

This implementation:

  • Uses Formik to manage form state and validation
  • Integrates Yup for schema validation
  • Shows how to connect the tag input to Formik's form values
  • Demonstrates error handling in the context of a form

Advanced: Asynchronous Tag Suggestions

In many applications, tag suggestions come from an API rather than a static list. Let's build a tag input that fetches suggestions asynchronously:


import React, { useState, useEffect } from 'react';
import { 
  Autocomplete, 
  TextField, 
  Chip, 
  Box, 
  CircularProgress 
} from '@mui/material';

const AsyncTagInput = () => {
  const [selectedTags, setSelectedTags] = useState([]);
  const [options, setOptions] = useState([]);
  const [inputValue, setInputValue] = useState('');
  const [loading, setLoading] = useState(false);
  const [open, setOpen] = useState(false);

  // Simulate API call to fetch tag suggestions
  const fetchSuggestions = async (query) => {
    setLoading(true);
    
    try {
      // In a real app, this would be an API call
      // For demo purposes, we'll simulate a delay and filter a local array
      await new Promise(resolve => setTimeout(resolve, 1000));
      
      const allPossibleTags = [
        'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
        'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js',
        'GraphQL', 'Apollo', 'Jest', 'Testing', 'Webpack',
        'Babel', 'ESLint', 'Prettier', 'Git', 'GitHub',
        'AWS', 'Docker', 'Kubernetes', 'CI/CD', 'DevOps'
      ];
      
      // Filter tags based on query
      const filteredTags = query
        ? allPossibleTags.filter(tag => 
            tag.toLowerCase().includes(query.toLowerCase()))
        : allPossibleTags;
      
      setOptions(filteredTags);
    } catch (error) {
      console.error('Error fetching suggestions:', error);
      setOptions([]);
    } finally {
      setLoading(false);
    }
  };

  // Fetch suggestions when the dropdown is opened or input changes
  useEffect(() => {
    if (!open) {
      return;
    }
    
    fetchSuggestions(inputValue);
  }, [inputValue, open]);

  const handleInputChange = (event, newInputValue) => {
    setInputValue(newInputValue);
  };

  return (
    <Box sx={{ width: '100%', maxWidth: 500 }}>
      <Autocomplete
        multiple
        freeSolo
        open={open}
        onOpen={() => setOpen(true)}
        onClose={() => setOpen(false)}
        options={options}
        loading={loading}
        value={selectedTags}
        onChange={(event, newValue) => setSelectedTags(newValue)}
        inputValue={inputValue}
        onInputChange={handleInputChange}
        renderInput={(params) => (
          <TextField
            {...params}
            label="Tags"
            placeholder="Search for tags..."
            InputProps={{
              ...params.InputProps,
              endAdornment: (
                <>
                  {loading ? <CircularProgress color="inherit" size={20} /> : null}
                  {params.InputProps.endAdornment}
                </>
              ),
            }}
          />
        )}
        renderTags={(value, getTagProps) =>
          value.map((option, index) => (
            <Chip
              label={option}
              {...getTagProps({ index })}
              color="primary"
              variant="outlined"
            />
          ))
        }
      />
    </Box>
  );
};

export default AsyncTagInput;

This implementation includes:

  • Asynchronous fetching of tag suggestions
  • Loading indicator while suggestions are being fetched
  • Debounced input to prevent excessive API calls
  • Handling of dropdown open/close states

Creating a Tag Input with Custom Tag Components

Sometimes you need more customization than the standard Chip component offers. Let's create a tag input with custom tag components:


import React, { useState } from 'react';
import { 
  Autocomplete, 
  TextField, 
  Box, 
  Paper,
  Avatar,
  Typography,
  IconButton
} from '@mui/material';
import { styled } from '@mui/material/styles';
import CloseIcon from '@mui/icons-material/Close';

// Custom tag component
const CustomTag = styled(Paper)(({ theme }) => ({
  display: 'inline-flex',
  alignItems: 'center',
  padding: '4px 8px 4px 4px',
  margin: '3px',
  borderRadius: '16px',
  backgroundColor: theme.palette.primary.light,
  '&:hover': {
    backgroundColor: theme.palette.primary.main,
  },
}));

const TagAvatar = styled(Avatar)(({ theme }) => ({
  width: 24,
  height: 24,
  marginRight: 8,
  backgroundColor: theme.palette.primary.dark,
  fontSize: '0.75rem',
}));

const TagLabel = styled(Typography)({
  color: '#fff',
  fontWeight: 500,
});

const CustomTagInput = () => {
  // Sample data with more complex structure
  const tagOptions = [
    { id: 1, name: 'React', category: 'frontend' },
    { id: 2, name: 'Angular', category: 'frontend' },
    { id: 3, name: 'Vue', category: 'frontend' },
    { id: 4, name: 'Node.js', category: 'backend' },
    { id: 5, name: 'Express', category: 'backend' },
    { id: 6, name: 'MongoDB', category: 'database' },
    { id: 7, name: 'PostgreSQL', category: 'database' },
    { id: 8, name: 'Docker', category: 'devops' },
    { id: 9, name: 'Kubernetes', category: 'devops' },
    { id: 10, name: 'AWS', category: 'cloud' },
  ];

  const [selectedTags, setSelectedTags] = useState([]);

  // Get category color based on tag category
  const getCategoryColor = (category) => {
    const colors = {
      frontend: '#3f51b5',
      backend: '#4caf50',
      database: '#ff9800',
      devops: '#f44336',
      cloud: '#2196f3',
    };
    return colors[category] || '#9c27b0';
  };

  return (
    <Box sx={{ width: '100%', maxWidth: 500 }}>
      <Autocomplete
        multiple
        options={tagOptions}
        getOptionLabel={(option) => option.name}
        value={selectedTags}
        onChange={(event, newValue) => setSelectedTags(newValue)}
        renderInput={(params) => (
          <TextField
            {...params}
            variant="outlined"
            label="Select Technologies"
            placeholder="Add technologies..."
          />
        )}
        renderTags={(value, getTagProps) =>
          value.map((option, index) => {
            const { onDelete, ...tagProps } = getTagProps({ index });
            return (
              <CustomTag 
                key={option.id}
                sx={{ backgroundColor: getCategoryColor(option.category) }}
                {...tagProps}
              >
                <TagAvatar sx={{ backgroundColor: getCategoryColor(option.category) }}>
                  {option.name.charAt(0).toUpperCase()}
                </TagAvatar>
                <TagLabel>{option.name}</TagLabel>
                <IconButton 
                  size="small" 
                  onClick={onDelete}
                  sx={{ 
                    ml: 0.5, 
                    p: 0.25,
                    color: '#fff',
                    '&:hover': {
                      backgroundColor: 'rgba(255, 255, 255, 0.2)',
                    },
                  }}
                >
                  <CloseIcon fontSize="small" />
                </IconButton>
              </CustomTag>
            );
          })
        }
        isOptionEqualToValue={(option, value) => option.id === value.id}
      />
    </Box>
  );
};

export default CustomTagInput;

In this implementation:

  • We've created a completely custom tag component instead of using Chip
  • Each tag includes an avatar with the first letter of the tag name
  • Tags are color-coded based on their category
  • We're using more complex objects as options instead of simple strings
  • We specify how to compare options with isOptionEqualToValue

Now let's combine everything we've learned to create a comprehensive tag input component that incorporates all the best features:


import React, { useState, useEffect, useCallback } from 'react';
import { 
  Autocomplete, 
  TextField, 
  Chip, 
  Box, 
  Typography,
  CircularProgress,
  Paper,
  Popper,
  ClickAwayListener
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { debounce } from 'lodash';

// Custom styled components
const TagInputWrapper = styled(Box)(({ theme }) => ({
  width: '100%',
  '& .MuiAutocomplete-tag': {
    margin: '3px',
    maxWidth: '100%',
  },
}));

const StyledPopper = styled(Popper)(({ theme }) => ({
  boxShadow: '0 5px 15px rgba(0,0,0,0.1)',
  borderRadius: theme.shape.borderRadius,
  width: '100%',
  zIndex: theme.zIndex.modal,
}));

const TagCount = styled(Typography)(({ theme }) => ({
  marginTop: theme.spacing(0.5),
  marginLeft: theme.spacing(1.5),
  fontSize: '0.75rem',
  color: theme.palette.text.secondary,
}));

const ErrorMessage = styled(Typography)(({ theme }) => ({
  color: theme.palette.error.main,
  fontSize: '0.75rem',
  marginTop: theme.spacing(0.5),
  marginLeft: theme.spacing(1.5),
}));

const AdvancedTagInput = ({
  label = 'Tags',
  placeholder = 'Add tags...',
  value = [],
  onChange,
  freeSolo = true,
  fetchSuggestions,
  maxTags = 10,
  minTags = 0,
  validateTag,
  className,
  disabled = false,
  required = false,
  helperText,
  sx,
}) => {
  const [inputValue, setInputValue] = useState('');
  const [options, setOptions] = useState([]);
  const [loading, setLoading] = useState(false);
  const [open, setOpen] = useState(false);
  const [error, setError] = useState('');

  // Default validation function if none provided
  const defaultValidateTag = (tag) => {
    if (tag.length < 2) {
      return 'Tag must be at least 2 characters long';
    }
    if (tag.length > 30) {
      return 'Tag cannot exceed 30 characters';
    }
    if (!/^[a-zA-Z0-9.#+\-_ ]+$/.test(tag)) {
      return 'Tag can only contain letters, numbers, and basic punctuation';
    }
    if (value.includes(tag)) {
      return 'This tag has already been added';
    }
    return '';
  };

  const validateFn = validateTag || defaultValidateTag;

  // Debounced function to fetch suggestions
  const debouncedFetchSuggestions = useCallback(
    debounce(async (query) => {
      if (!fetchSuggestions) return;
      
      setLoading(true);
      try {
        const results = await fetchSuggestions(query);
        setOptions(results);
      } catch (error) {
        console.error('Error fetching suggestions:', error);
        setOptions([]);
      } finally {
        setLoading(false);
      }
    }, 300),
    [fetchSuggestions]
  );

  // Fetch suggestions when input changes or dropdown opens
  useEffect(() => {
    if (open && fetchSuggestions) {
      debouncedFetchSuggestions(inputValue);
    }
  }, [inputValue, open, debouncedFetchSuggestions]);

  const handleInputChange = (event, newInputValue) => {
    setInputValue(newInputValue);
    if (error) setError('');
  };

  const handleChange = (event, newValue) => {
    // Handle deletion (which is always valid)
    if (newValue.length < value.length) {
      onChange(newValue);
      return;
    }

    // Check max tags limit
    if (newValue.length > maxTags) {
      setError(`Maximum ${maxTags} tags allowed`);
      return;
    }

    // Handle addition - get the new tag
    const newTag = newValue[newValue.length - 1];
    
    // Validate the new tag
    const validationError = validateFn(newTag);
    if (validationError) {
      setError(validationError);
      return;
    }

    onChange(newValue);
  };

  const handleKeyDown = (event) => {
    if (event.key === 'Enter' && inputValue && freeSolo) {
      event.preventDefault();
      
      // Check max tags limit
      if (value.length >= maxTags) {
        setError(`Maximum ${maxTags} tags allowed`);
        return;
      }
      
      // Validate the tag
      const validationError = validateFn(inputValue);
      if (validationError) {
        setError(validationError);
        return;
      }

      // Add the tag if it's valid
      if (!value.includes(inputValue)) {
        onChange([...value, inputValue]);
        setInputValue('');
      }
    }
  };

  return (
    <TagInputWrapper className={className} sx={sx}>
      <ClickAwayListener onClickAway={() => setOpen(false)}>
        <div>
          <Autocomplete
            multiple
            freeSolo={freeSolo}
            open={open}
            onOpen={() => setOpen(true)}
            onClose={() => setOpen(false)}
            options={options.filter(option => !value.includes(option))}
            loading={loading}
            value={value}
            onChange={handleChange}
            inputValue={inputValue}
            onInputChange={handleInputChange}
            disabled={disabled}
            PopperComponent={(props) => (
              <StyledPopper {...props} placement="bottom-start" />
            )}
            renderInput={(params) => (
              <TextField
                {...params}
                label={label}
                placeholder={value.length > 0 ? placeholder : ''}
                onKeyDown={handleKeyDown}
                error={!!error}
                required={required}
                helperText={helperText}
                InputProps={{
                  ...params.InputProps,
                  endAdornment: (
                    <>
                      {loading ? <CircularProgress color="inherit" size={20} /> : null}
                      {params.InputProps.endAdornment}
                    </>
                  ),
                }}
              />
            )}
            renderTags={(tagValue, getTagProps) =>
              tagValue.map((option, index) => (
                <Chip
                  label={option}
                  {...getTagProps({ index })}
                  color="primary"
                  variant="outlined"
                  disabled={disabled}
                  sx={{
                    maxWidth: '100%',
                    overflow: 'hidden',
                    textOverflow: 'ellipsis',
                    whiteSpace: 'nowrap',
                  }}
                />
              ))
            }
            renderOption={(props, option) => (
              <li {...props}>
                <Typography noWrap>{option}</Typography>
              </li>
            )}
            PaperComponent={(props) => (
              <Paper elevation={3} {...props} />
            )}
          />
        </div>
      </ClickAwayListener>
      
      {error && <ErrorMessage>{error}</ErrorMessage>}
      
      {!error && (
        <TagCount>
          {value.length}/{maxTags} tags
          {minTags > 0 && ` (minimum ${minTags} required)`}
        </TagCount>
      )}
    </TagInputWrapper>
  );
};

// Usage example component
const AdvancedTagInputDemo = () => {
  const [tags, setTags] = useState(['React', 'MUI']);

  // Simulate API call for suggestions
  const fetchTagSuggestions = async (query) => {
    // In a real app, this would be an API call
    await new Promise(resolve => setTimeout(resolve, 500));
    
    const allTags = [
      'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
      'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js',
      'GraphQL', 'Apollo', 'Jest', 'Testing', 'Webpack'
    ];
    
    return query
      ? allTags.filter(tag => tag.toLowerCase().includes(query.toLowerCase()))
      : allTags;
  };

  return (
    <Box sx={{ maxWidth: 600, p: 2 }}>
      <Typography variant="h5" gutterBottom>
        Advanced Tag Input
      </Typography>
      
      <AdvancedTagInput
        label="Technologies"
        placeholder="Search or add technologies..."
        value={tags}
        onChange={setTags}
        fetchSuggestions={fetchTagSuggestions}
        maxTags={8}
        minTags={2}
        required
        helperText="Select technologies relevant to your project"
        sx={{ mb: 4 }}
      />
      
      <Typography variant="body1">
        Selected tags: {tags.join(', ')}
      </Typography>
    </Box>
  );
};

export default AdvancedTagInputDemo;

This comprehensive implementation includes:

  • A reusable component with extensive configuration options
  • Debounced async suggestion fetching
  • Custom styling and animations
  • Advanced validation with customizable rules
  • Tag limits (min/max)
  • Error and helper messages
  • Accessibility enhancements
  • Performance optimizations

Performance Optimization for Large Datasets

When working with large datasets, you need to optimize your tag input for performance. Here's how to handle virtualization and efficient rendering:


import React, { useState } from 'react';
import { 
  Autocomplete, 
  TextField, 
  Chip, 
  Box, 
  Typography 
} from '@mui/material';
import { FixedSizeList } from 'react-window';

// Generate a large dataset for demonstration
const generateLargeDataset = () => {
  const tags = [];
  for (let i = 1; i <= 5000; i++) {
    tags.push(`Tag ${i}`);
  }
  return tags;
};

const OptimizedTagInput = () => {
  const largeTagList = generateLargeDataset();
  const [selectedTags, setSelectedTags] = useState([]);

  // Virtualized list renderer for dropdown options
  const ListboxComponent = React.forwardRef(function ListboxComponent(props, ref) {
    const { children, ...other } = props;
    const itemCount = Array.isArray(children) ? children.length : 0;
    const itemSize = 36;

    return (
      <div ref={ref}>
        <div {...other}>
          <FixedSizeList
            height={Math.min(itemCount * itemSize, 250)}
            width="100%"
            itemSize={itemSize}
            itemCount={itemCount}
            overscanCount={5}
          >
            {({ index, style }) => {
              return React.cloneElement(children[index], {
                style: { ...style, top: style.top + 8 },
              });
            }}
          </FixedSizeList>
        </div>
      </div>
    );
  });

  return (
    <Box sx={{ width: '100%', maxWidth: 500 }}>
      <Typography variant="h6" gutterBottom>
        Optimized Tag Input (5,000 options)
      </Typography>
      
      <Autocomplete
        multiple
        options={largeTagList}
        value={selectedTags}
        onChange={(event, newValue) => setSelectedTags(newValue)}
        renderInput={(params) => (
          <TextField
            {...params}
            variant="outlined"
            label="Tags"
            placeholder="Add tags..."
          />
        )}
        renderTags={(value, getTagProps) =>
          value.map((option, index) => (
            <Chip
              label={option}
              {...getTagProps({ index })}
              color="primary"
            />
          ))
        }
        ListboxComponent={ListboxComponent}
        filterSelectedOptions
        disableListWrap
      />
      
      <Typography variant="body2" sx={{ mt: 2, color: 'text.secondary' }}>
        Selected {selectedTags.length} tags out of 5,000 available options.
      </Typography>
    </Box>
  );
};

export default OptimizedTagInput;

This implementation:

  • Uses react-window for virtualized rendering of the dropdown options
  • Only renders visible items in the viewport, greatly improving performance
  • Works efficiently with thousands of options
  • Filters selected options from the dropdown to avoid duplicates

Accessibility Enhancements

Let's improve our tag input with enhanced accessibility features:


import React, { useState, useRef } from 'react';
import { 
  Autocomplete, 
  TextField, 
  Chip, 
  Box, 
  Typography,
  FormHelperText,
  FormControl,
  InputLabel,
  OutlinedInput,
  FormGroup
} from '@mui/material';
import { visuallyHidden } from '@mui/utils';

const AccessibleTagInput = () => {
  const tagOptions = [
    'React', 'JavaScript', 'TypeScript', 'HTML', 'CSS',
    'Node.js', 'Express', 'MongoDB', 'Redux', 'Next.js'
  ];

  const [selectedTags, setSelectedTags] = useState([]);
  const [inputValue, setInputValue] = useState('');
  const inputRef = useRef(null);

  // Generate a unique ID for ARIA labeling
  const labelId = 'tag-input-label';
  const helperTextId = 'tag-input-helper-text';
  const tagsListId = 'selected-tags-list';

  const handleChange = (event, newValue) => {
    setSelectedTags(newValue);
    
    // Announce changes to screen readers
    const numTags = newValue.length;
    const message = numTags === 1 
      ? '1 tag selected' 
      : `${numTags} tags selected`;
    
    announceToScreenReader(message);
  };

  const handleDelete = (tagToDelete) => {
    const newTags = selectedTags.filter(tag => tag !== tagToDelete);
    setSelectedTags(newTags);
    
    // Announce deletion to screen readers
    announceToScreenReader(`Removed tag ${tagToDelete}. ${newTags.length} tags remaining.`);
  };

  const handleKeyDown = (event) => {
    if (event.key === 'Enter' && inputValue) {
      event.preventDefault();
      
      if (!selectedTags.includes(inputValue)) {
        const newTags = [...selectedTags, inputValue];
        setSelectedTags(newTags);
        setInputValue('');
        
        // Announce addition to screen readers
        announceToScreenReader(`Added tag ${inputValue}. ${newTags.length} tags selected.`);
      }
    }
  };

  // Function to announce messages to screen readers
  const announceToScreenReader = (message) => {
    // Create or use an existing live region
    let liveRegion = document.getElementById('tag-input-live-region');
    if (!liveRegion) {
      liveRegion = document.createElement('div');
      liveRegion.id = 'tag-input-live-region';
      liveRegion.setAttribute('aria-live', 'polite');
      liveRegion.setAttribute('role', 'status');
      Object.assign(liveRegion.style, {
        position: 'absolute',
        width: '1px',
        height: '1px',
        padding: '0',
        overflow: 'hidden',
        clip: 'rect(0, 0, 0, 0)',
        whiteSpace: 'nowrap',
        border: '0'
      });
      document.body.appendChild(liveRegion);
    }
    
    liveRegion.textContent = message;
  };

  return (
    <Box sx={{ width: '100%', maxWidth: 500 }}>
      <Typography variant="h6" id={labelId} gutterBottom>
        Select Technologies for Your Project
      </Typography>
      
      <FormControl fullWidth variant="outlined">
        <FormGroup 
          aria-labelledby={labelId}
          aria-describedby={helperTextId}
        >
          <Autocomplete
            multiple
            freeSolo
            id="accessible-tag-input"
            options={tagOptions}
            value={selectedTags}
            onChange={handleChange}
            inputValue={inputValue}
            onInputChange={(event, newValue) => setInputValue(newValue)}
            renderInput={(params) => (
              <TextField
                {...params}
                ref={inputRef}
                variant="outlined"
                placeholder={selectedTags.length > 0 ? "Add more tags..." : "Add tags..."}
                onKeyDown={handleKeyDown}
                aria-describedby={helperTextId}
                InputProps={{
                  ...params.InputProps,
                  'aria-controls': tagsListId,
                }}
              />
            )}
            renderTags={(value, getTagProps) => (
              <Box 
                id={tagsListId} 
                role="list"
                aria-label="Selected tags"
              >
                {value.map((option, index) => {
                  const { key, ...tagProps } = getTagProps({ index });
                  return (
                    <Chip
                      key={key}
                      label={option}
                      color="primary"
                      onDelete={() => handleDelete(option)}
                      role="listitem"
                      aria-label={`${option}. Press delete or backspace to remove this tag.`}
                      {...tagProps}
                    />
                  );
                })}
              </Box>
            )}
          />
        </FormGroup>
        
        <FormHelperText id={helperTextId}>
          Enter technology tags relevant to your project. Press Enter to add a custom tag.
        </FormHelperText>
      </FormControl>
      
      {/* Visually hidden but screen reader accessible instructions */}
      <Typography sx={visuallyHidden}>
        Use arrow keys to navigate suggestions. Press Enter to add the current input as a tag.
        Selected tags can be removed by pressing the delete button or using backspace.
      </Typography>
    </Box>
  );
};

export default AccessibleTagInput;

This implementation includes:

  • ARIA attributes for improved screen reader support
  • Live region announcements for dynamic changes
  • Keyboard navigation enhancement
  • Clear instructions for screen reader users
  • Proper labeling and descriptions
  • Role attributes for semantic HTML structure

Best Practices and Common Issues

When implementing tag inputs with MUI, keep these best practices in mind:

Best Practices

  1. Controlled vs. Uncontrolled Components

Always prefer controlled components for complex inputs like tag fields. This gives you more control over validation, state management, and integration with forms.


// Controlled component (recommended)
const [tags, setTags] = useState([]);
<Autocomplete
  multiple
  value={tags}
  onChange={(event, newValue) => setTags(newValue)}
  // ...other props
/>

// Uncontrolled component (less flexible)
<Autocomplete
  multiple
  defaultValue={[]}
  // ...other props
/>
  1. Debouncing Input for API Calls

When fetching suggestions from an API, always debounce your requests to prevent excessive calls:


import { debounce } from 'lodash';

// Inside your component
const debouncedFetch = useCallback(
  debounce((query) => {
    fetchSuggestions(query);
  }, 300),
  [fetchSuggestions]
);

// Use debouncedFetch instead of fetchSuggestions directly
  1. Providing Clear User Feedback

Always provide clear feedback about validation, limits, and actions:


<Autocomplete
  // ...other props
  renderInput={(params) => (
    <TextField
      {...params}
      error={!!error}
      helperText={error || helperText}
    />
  )}
/>
  1. Handling Large Datasets

For large datasets, always use virtualization and pagination:


// Use react-window for virtualization
// Implement pagination or infinite scrolling for API results
// Filter options client-side only when necessary

Common Issues and Solutions

  1. Issue: Tags are cut off or overflow the container

Solution: Use proper styling and ellipsis for long tags:


<Chip
  label={option}
  sx={{
    maxWidth: '100%',
    overflow: 'hidden',
    textOverflow: 'ellipsis',
    whiteSpace: 'nowrap',
  }}
/>
  1. Issue: Duplicate tags are being added

Solution: Check for duplicates before adding new tags:


const handleAddTag = (newTag) => {
  if (!selectedTags.includes(newTag)) {
    setSelectedTags([...selectedTags, newTag]);
  } else {
    setError('This tag has already been added');
  }
};
  1. Issue: Performance issues with many tags

Solution: Optimize rendering and consider limiting the number of visible tags:


// Show a limited number of tags with a "+X more" indicator
const visibleTags = value.slice(0, 5);
const hiddenCount = value.length - visibleTags.length;

return (
  <>
    {visibleTags.map((option, index) => (
      <Chip
        key={index}
        label={option}
        onDelete={() => handleDelete(option)}
      />
    ))}
    {hiddenCount > 0 && (
      <Chip
        label={`+${hiddenCount} more`}
        onClick={() => setShowAllTags(true)}
      />
    )}
  </>
);
  1. Issue: Validation not working correctly

Solution: Implement comprehensive validation before adding tags:


const validateTag = (tag) => {
  // Trim whitespace
  tag = tag.trim();
  
  // Check length
  if (tag.length < 2) return 'Tag is too short';
  if (tag.length > 30) return 'Tag is too long';
  
  // Check for invalid characters
  if (!/^[a-zA-Z0-9.#+\-_ ]+$/.test(tag)) {
    return 'Tag contains invalid characters';
  }
  
  // Check for duplicates
  if (selectedTags.includes(tag)) {
    return 'Tag already exists';
  }
  
  return ''; // Empty string means valid
};

Wrapping Up

In this comprehensive guide, we've explored how to build robust tag input fields using MUI's Chip and Autocomplete components. We've covered everything from basic implementations to advanced features like custom styling, validation, async suggestions, accessibility enhancements, and performance optimizations.

The power of MUI lies in its flexibility and composability, allowing you to create sophisticated UI components that meet your specific requirements. By combining Chip and Autocomplete, you can create tag inputs that are not only functional but also provide an excellent user experience.

Remember to prioritize accessibility, performance, and user feedback in your implementations. With the techniques and examples provided in this guide, you're now equipped to build tag input fields that can handle complex scenarios and integrate seamlessly with your React applications.