Skip to content

Instantly share code, notes, and snippets.

@enesien
Last active July 27, 2025 21:43
Show Gist options
  • Save enesien/03ba5340f628c6c812b306da5fedd1a4 to your computer and use it in GitHub Desktop.
Save enesien/03ba5340f628c6c812b306da5fedd1a4 to your computer and use it in GitHub Desktop.
shadcn multiple tag input

shadcn/ui multi tag input component

A react tag input field component using shadcn, like one you see when adding keywords to a video on YouTube. Usable with Form or standalone.

Preview

image

Standalone Usage

const [values, setValues] = useState<string[]>([])
...
<InputTags value={values} onChange={setValues} />

Form Usage (React Hook Form)

<FormField
  control={form.control}
  name="data_points"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Add Data Point(s)</FormLabel>
      <FormControl>
        <InputTags {...field} />
      </FormControl>
      <FormDescription>
       ...
      </FormDescription>
      <FormMessage />
    </FormItem>
  )}
/>

Component

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input, InputProps } from "@/components/ui/input";
import { XIcon } from "lucide-react";
import { Dispatch, SetStateAction, forwardRef, useState } from "react";

type InputTagsProps = InputProps & {
  value: string[];
  onChange: Dispatch<SetStateAction<string[]>>;
};

export const InputTags = forwardRef<HTMLInputElement, InputTagsProps>(
  ({ value, onChange, ...props }, ref) => {
    const [pendingDataPoint, setPendingDataPoint] = useState("");

    const addPendingDataPoint = () => {
      if (pendingDataPoint) {
        const newDataPoints = new Set([...value, pendingDataPoint]);
        onChange(Array.from(newDataPoints));
        setPendingDataPoint("");
      }
    };

    return (
      <>
        <div className="flex">
          <Input
            value={pendingDataPoint}
            onChange={(e) => setPendingDataPoint(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") {
                e.preventDefault();
                addPendingDataPoint();
              } else if (e.key === "," || e.key === " ") {
                e.preventDefault();
                addPendingDataPoint();
              }
            }}
            className="rounded-r-none"
            {...props}
            ref={ref}
          />
          <Button
            type="button"
            variant="secondary"
            className="rounded-l-none border border-l-0"
            onClick={addPendingDataPoint}
          >
            Add
          </Button>
        </div>
        <div className="border rounded-md min-h-[2.5rem] overflow-y-auto p-2 flex gap-2 flex-wrap items-center">
          {value.map((item, idx) => (
            <Badge key={idx} variant="secondary">
              {item}
              <button
                type="button"
                className="w-3 ml-2"
                onClick={() => {
                  onChange(value.filter((i) => i !== item));
                }}
              >
                <XIcon className="w-3" />
              </button>
            </Badge>
          ))}
        </div>
      </>
    );
  }
);

Godspeed!

Created by Enesien

@aryanbhat
Copy link

aryanbhat commented Aug 10, 2024

I made some changes to the component so that the box displaying the selected values only appears when there are values present. Previously, the box was always visible, even when empty. Now, the box will only show up when there are items to display, making the UI cleaner and more adaptable to your specific use case

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input, InputProps } from "@/components/ui/input";
import { Cross2Icon } from "@radix-ui/react-icons";
import { Dispatch, SetStateAction, forwardRef, useState } from "react";

type InputTagsProps = InputProps & {
  value: string[];
  onChange: Dispatch<SetStateAction<string[]>>;
};

export const InputTags = forwardRef<HTMLInputElement, InputTagsProps>(
  ({ value, onChange, ...props }, ref) => {
    const [pendingDataPoint, setPendingDataPoint] = useState("");

    const addPendingDataPoint = () => {
      if (pendingDataPoint) {
        const newDataPoints = new Set([...value, pendingDataPoint]);
        onChange(Array.from(newDataPoints));
        setPendingDataPoint("");
      }
    };

    return (
      <>
        <div className="flex">
          <Input
            value={pendingDataPoint}
            onChange={(e) => setPendingDataPoint(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") {
                e.preventDefault();
                addPendingDataPoint();
              } else if (e.key === "," || e.key === " ") {
                e.preventDefault();
                addPendingDataPoint();
              }
            }}
            className="rounded-r-none"
            {...props}
            ref={ref}
          />
          <Button
            type="button"
            variant="secondary"
            className="rounded-l-none border border-l-0"
            onClick={addPendingDataPoint}
          >
            Add
          </Button>
        </div>
        {value.length > 0 && (
          <div className=" rounded-md min-h-[2.5rem] overflow-y-auto py-2 flex gap-2 flex-wrap items-center">
            {value.map((item, idx) => (
              <Badge key={idx} variant="secondary">
                {item}
                <button
                  type="button"
                  className="w-3 ml-2"
                  onClick={() => {
                    onChange(value.filter((i) => i !== item));
                  }}
                >
                  <Cross2Icon className="w-3" />
                </button>
              </Badge>
            ))}
          </div>
        )}
      </>
    );
  }
);

Thank you @enesien for the component.

@algsupport
Copy link

@aryanbhat One note regarding your change. This will have the effect of changing the form height when the field appears and in many cases when you have other things under the form, this may not be deisrable.
I would make the function optional with a default value in order to preserve backward compatibility.
Just a suggestion.

@destpat
Copy link

destpat commented Aug 29, 2024

Work great thank you 👍 💯

@kimuradev
Copy link

Good job mate!

@aryanbhat
Copy link

@algsupport Yeah got it mate took some time to understand 🤣

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment