Skip to Content

tag-input

ui

Token-based input for multiple values as dismissible badges.

Source Code

View the full component source code below.

"use client"

import * as React from "react"
import { XIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"

function TagInput({
  className,
  tags,
  onTagsChange,
  placeholder = "Add a tag...",
  maxTags,
  ...props
}: Omit<React.ComponentProps<"div">, "onChange"> & {
  tags: string[]
  onTagsChange: (tags: string[]) => void
  placeholder?: string
  maxTags?: number
}) {
  const inputRef = React.useRef<HTMLInputElement>(null)
  const [inputValue, setInputValue] = React.useState("")

  const addTag = (value: string) => {
    const trimmed = value.trim()
    if (!trimmed) return
    if (tags.includes(trimmed)) return
    if (maxTags && tags.length >= maxTags) return
    onTagsChange([...tags, trimmed])
    setInputValue("")
  }

  const removeTag = (index: number) => {
    onTagsChange(tags.filter((_, i) => i !== index))
  }

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter" || e.key === ",") {
      e.preventDefault()
      addTag(inputValue)
    }
    if (e.key === "Backspace" && !inputValue && tags.length > 0) {
      removeTag(tags.length - 1)
    }
  }

  const handleContainerClick = () => {
    inputRef.current?.focus()
  }

  return (
    <div
      data-slot="tag-input"
      onClick={handleContainerClick}
      className={cn(
        "bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50",
        "flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-4xl border px-3 py-1.5 transition-colors",
        "focus-within:ring-[3px]",
        className
      )}
      {...props}
    >
      {tags.map((tag, index) => (
        <Badge
          key={`${tag}-${index}`}
          variant="secondary"
          className="gap-1 pr-1"
        >
          {tag}
          <button
            type="button"
            onClick={(e) => {
              e.stopPropagation()
              removeTag(index)
            }}
            className="hover:bg-foreground/10 inline-flex size-4 items-center justify-center rounded-full transition-colors"
            aria-label={`Remove ${tag}`}
          >
            <XIcon className="size-3" />
          </button>
        </Badge>
      ))}
      <input
        ref={inputRef}
        data-slot="tag-input-field"
        type="text"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        onKeyDown={handleKeyDown}
        onBlur={() => addTag(inputValue)}
        placeholder={tags.length === 0 ? placeholder : ""}
        disabled={maxTags ? tags.length >= maxTags : false}
        className={cn(
          "placeholder:text-muted-foreground min-w-20 flex-1 bg-transparent text-sm outline-none",
          "disabled:cursor-not-allowed disabled:opacity-50"
        )}
      />
    </div>
  )
}

export { TagInput }

Installation

npx shadcn@latest add https://registry.mukoko.com/api/v1/ui/tag-input

API

Fetch this component's metadata and source code from the registry API.

GET/api/v1/ui/tag-input

Source

components/ui/tag-input.tsx