Oblivion
Oblivion
Docs
Go back
Component by
OV
Oblivion
TSX
tag-input
"use client"

import { KeyboardEvent, useRef, useState } from "react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"

export const componentMeta = {
  name: "Tag Input",
  description: "A chip-style tag input. Type and press Enter or comma to add tags. Click × to remove.",
  props: {
    placeholder: { type: "string",   default: '"Add tag..."',   description: "Input placeholder" },
    maxTags:     { type: "number",   default: "10",              description: "Maximum number of tags" },
    defaultTags: { type: "string[]", default: "[]",              description: "Pre-filled tags" },
    onChange:    { type: "(tags: string[]) => void",             description: "Called when tags change" },
  }
}

export default function TagInput({
  placeholder  = "Add a tag…",
  maxTags      = 10,
  defaultTags  = [],
  onChange,
  className,
}: {
  placeholder?: string
  maxTags?:     number
  defaultTags?: string[]
  onChange?:    (tags: string[]) => void
  className?:   string
}) {
  const [tags,  setTags]  = useState<string[]>(defaultTags)
  const [input, setInput] = useState("")
  const [focused, setFocused] = useState(false)
  const inputRef = useRef<HTMLInputElement>(null)

  const addTag = (raw: string) => {
    const val = raw.trim().replace(/,+$/, "").trim()
    if (!val || tags.includes(val) || tags.length >= maxTags) return
    const next = [...tags, val]
    setTags(next)
    onChange?.(next)
    setInput("")
  }

  const removeTag = (idx: number) => {
    const next = tags.filter((_, i) => i !== idx)
    setTags(next)
    onChange?.(next)
  }

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

  return (
    <div
      onClick={() => inputRef.current?.focus()}
      className={cn(
        "flex flex-wrap gap-2 min-h-[44px] p-2 rounded-xl border cursor-text transition-all duration-200",
        focused
          ? "border-zinc-400 dark:border-zinc-500 ring-2 ring-zinc-200 dark:ring-zinc-700/60"
          : "border-zinc-200 dark:border-zinc-700/60 bg-transparent dark:bg-zinc-900/40",
        className
      )}
    >
      {/* Chips */}
      {tags.map((tag, i) => (
        <span
          key={i}
          className="inline-flex items-center gap-1 h-7 pl-2.5 pr-1.5 rounded-lg text-xs font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 border border-zinc-200 dark:border-zinc-700"
        >
          {tag}
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); removeTag(i) }}
            className="flex items-center justify-center w-4 h-4 rounded-md hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
          >
            <X className="w-2.5 h-2.5" />
          </button>
        </span>
      ))}

      {/* Input */}
      {tags.length < maxTags && (
        <input
          ref={inputRef}
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={handleKey}
          onBlur={() => { setFocused(false); if (input) addTag(input) }}
          onFocus={() => setFocused(true)}
          placeholder={tags.length === 0 ? placeholder : ""}
          className="flex-1 min-w-[80px] bg-transparent text-sm text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400 dark:placeholder:text-zinc-600 outline-none h-7 px-1"
        />
      )}
    </div>
  )
}

export function TagInputPreview() {
  const [tags, setTags] = useState<string[]>([])
  return (
    <div className="flex w-full flex-col items-center justify-center gap-6 bg-transparent px-8 py-12">
      <div className="w-full max-w-sm space-y-6">
        <div className="space-y-2">
          <p className="text-xs font-semibold uppercase tracking-widest text-zinc-500 dark:text-zinc-400">
            Skills
          </p>
          <TagInput
            defaultTags={["React", "TypeScript", "Next.js"]}
            placeholder="Add skill…"
            onChange={setTags}
          />
          <p className="text-[11px] text-zinc-500 dark:text-zinc-400">
            Press Enter or comma to add · Backspace to remove
          </p>
        </div>
        {tags.length > 0 && (
          <p className="text-xs text-zinc-500 dark:text-zinc-400">
            Tags: {tags.join(", ")}
          </p>
        )}
      </div>
    </div>
  )
}
Save to favoritesCopy to Figma
React

Tag Input

A chip-style tag input. Type and press Enter or comma to add tags. Click × to remove.

Installation

$ npx @cosmoo/oblivion add tag-input
Dependenciesnpm install lucide-react

Usage Example

tsx
import TagInput from "@/components/oblivion/tag-input"

export default function Demo() {
  return (
    <div className="p-10 flex justify-center">
      <TagInput />
    </div>
  )
}