Docs
Go back
Component by
OV
OblivionTSX
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>
)
}"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-inputDependencies
npm install lucide-reactUsage Example
tsx
import TagInput from "@/components/oblivion/tag-input"
export default function Demo() {
return (
<div className="p-10 flex justify-center">
<TagInput />
</div>
)
}import TagInput from "@/components/oblivion/tag-input"
export default function Demo() {
return (
<div className="p-10 flex justify-center">
<TagInput />
</div>
)
}