# Tag Input

> Tag input — add with Enter or comma, with suggestions and a tag limit.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/tag-input
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/tag-input.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/tag-input-demo.tsx"
import { TagInput } from "@/components/tag-input"

const suggestions = ["React", "Next.js", "Tailwind CSS", "TypeScript", "Radix UI", "Framer Motion", "Vite", "Remix", "Astro"]

export default function TagInputDemo() {
  return (
    <div className="flex w-full max-w-md flex-col gap-2 pb-40">
      <span className="text-sm font-medium">Stack</span>
      <TagInput defaultValue={["React", "Tailwind CSS"]} suggestions={suggestions} placeholder="Type a technology…" />
    </div>
  )
}
```

## Source

```tsx title="components/tag-input.tsx"
"use client"

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

import { cn } from "@/lib/utils"

export interface TagInputProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  value?: string[]
  defaultValue?: string[]
  onChange?: (tags: string[]) => void
  placeholder?: string
  /** Offered as you type; any text is accepted unless `restrictToSuggestions` */
  suggestions?: string[]
  restrictToSuggestions?: boolean
  maxTags?: number
  /** Chip style */
  variant?: "soft" | "outline"
}

export function TagInput({
  value,
  defaultValue = [],
  onChange,
  placeholder = "Add a tag…",
  suggestions = [],
  restrictToSuggestions = false,
  maxTags = 10,
  variant = "soft",
  className,
  ...props
}: TagInputProps) {
  const [internal, setInternal] = React.useState(defaultValue)
  const tags = value ?? internal
  const [text, setText] = React.useState("")
  const [highlight, setHighlight] = React.useState(0)
  const input = React.useRef<HTMLInputElement>(null)
  const listId = React.useId()
  const full = tags.length >= maxTags

  const matches = text
    ? suggestions.filter((s) => s.toLowerCase().includes(text.toLowerCase()) && !tags.includes(s)).slice(0, 6)
    : []

  const update = (next: string[]) => {
    setInternal(next)
    onChange?.(next)
  }

  const add = (raw: string) => {
    const tag = raw.trim()
    setText("")
    setHighlight(0)
    if (!tag || full || tags.some((t) => t.toLowerCase() === tag.toLowerCase())) return
    if (restrictToSuggestions && !suggestions.includes(tag)) return
    update([...tags, tag])
  }

  const remove = (tag: string) => {
    update(tags.filter((t) => t !== tag))
    input.current?.focus()
  }

  const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Enter" || event.key === "," || event.key === "Tab") {
      if (!text && event.key === "Tab") return
      event.preventDefault()
      add(matches[highlight] ?? text)
    } else if (event.key === "Backspace" && !text && tags.length) {
      update(tags.slice(0, -1))
    } else if (event.key === "ArrowDown" && matches.length) {
      event.preventDefault()
      setHighlight((h) => (h + 1) % matches.length)
    } else if (event.key === "ArrowUp" && matches.length) {
      event.preventDefault()
      setHighlight((h) => (h - 1 + matches.length) % matches.length)
    } else if (event.key === "Escape") {
      setText("")
    }
  }

  return (
    <div className={cn("relative w-full max-w-md", className)} {...props}>
      <div
        onClick={() => input.current?.focus()}
        className="flex min-h-10 flex-wrap items-center gap-1.5 rounded-lg border bg-background px-2 py-1.5 shadow-xs transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20"
      >
        {tags.map((tag) => (
          <span
            key={tag}
            className={cn(
              "inline-flex h-6 items-center gap-1 rounded-md pr-1 pl-2 text-xs font-medium animate-in fade-in zoom-in-90",
              variant === "soft" ? "bg-secondary text-secondary-foreground" : "border bg-background"
            )}
          >
            {tag}
            <button
              type="button"
              onClick={(event) => {
                event.stopPropagation()
                remove(tag)
              }}
              aria-label={`Remove ${tag}`}
              className="flex size-4 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground"
            >
              <X className="size-3" />
            </button>
          </span>
        ))}
        <input
          ref={input}
          value={text}
          onChange={(event) => {
            setText(event.target.value)
            setHighlight(0)
          }}
          onKeyDown={onKeyDown}
          onBlur={() => !restrictToSuggestions && text && add(text)}
          placeholder={full ? `Up to ${maxTags} tags` : tags.length ? "" : placeholder}
          disabled={full}
          role="combobox"
          aria-expanded={matches.length > 0}
          aria-controls={listId}
          aria-activedescendant={matches.length ? `${listId}-${highlight}` : undefined}
          aria-label={placeholder}
          className="h-6 min-w-24 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
        />
      </div>
      <ul
        id={listId}
        role="listbox"
        className={cn(
          "absolute inset-x-0 top-full z-10 mt-1 overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-md",
          !matches.length && "hidden"
        )}
      >
        {matches.map((match, index) => (
          <li
            key={match}
            id={`${listId}-${index}`}
            role="option"
            aria-selected={index === highlight}
            onMouseDown={(event) => {
              event.preventDefault()
              add(match)
            }}
            onMouseEnter={() => setHighlight(index)}
            className={cn("cursor-default rounded-md px-2 py-1.5 text-sm", index === highlight && "bg-accent text-accent-foreground")}
          >
            {match}
          </li>
        ))}
      </ul>
      <p className="mt-1.5 px-0.5 text-xs text-muted-foreground">
        {tags.length}/{maxTags} · Press Enter or comma to add
      </p>
    </div>
  )
}
```
