formsclient
tag input
type a word, hit enter or a comma, and it turns into a tag. backspace takes it away.
designreact
press enter or comma to add
installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add tag-inputor by hand: drop this file into components/ui and import from there.
components/ui/TagInput.tsxtsx
1"use client";2
3import React, { useState, useRef, useEffect, useId } from "react";4
5interface TagInputProps {6 value?: string[];7 defaultValue?: string[];8 onChange?: (tags: string[]) => void;9 placeholder?: string;10 label?: string;11 hint?: string;12 maxTags?: number;13 fullWidth?: boolean;14 className?: string;15}16
17interface AnimatedTag {18 text: string;19 state: "entering" | "visible" | "exiting";20}21
22export function TagInput({23 value,24 defaultValue = [],25 onChange,26 placeholder = "add a tag…",27 label,28 hint,29 maxTags,30 fullWidth = false,31 className = "",32}: TagInputProps) {33 const [internal, setInternal] = useState<string[]>(defaultValue);34 const [input, setInput] = useState("");35 const [animTags, setAnimTags] = useState<AnimatedTag[]>(() =>36 (value ?? defaultValue).map((t) => ({ text: t, state: "visible" as const }))37 );38 const inputRef = useRef<HTMLInputElement>(null);39 const uid = useId();40 const prevValueRef = useRef<string[]>(value ?? defaultValue);41
42 const tags = value !== undefined ? value : internal;43
44 useEffect(() => {45 const prev = prevValueRef.current;46 const next = tags;47
48 const added = next.filter((t) => !prev.includes(t));49 const removed = prev.filter((t) => !next.includes(t));50
51 if (added.length === 0 && removed.length === 0) return;52
53 setAnimTags((current) => {54 const updated = current.map((at) =>55 removed.includes(at.text) ? { ...at, state: "exiting" as const } : at56 );57 added.forEach((t) => {58 if (!updated.find((at) => at.text === t)) {59 updated.push({ text: t, state: "entering" });60 }61 });62 return updated;63 });64
65 if (added.length > 0) {66 requestAnimationFrame(() => {67 setAnimTags((current) =>68 current.map((at) =>69 at.state === "entering" ? { ...at, state: "visible" } : at70 )71 );72 });73 }74
75 if (removed.length > 0) {76 setTimeout(() => {77 setAnimTags((current) => current.filter((at) => at.state !== "exiting"));78 }, 180);79 }80
81 prevValueRef.current = next;82 }, [tags]);83
84 const update = (next: string[]) => {85 setInternal(next);86 onChange?.(next);87 };88
89 const addTag = (raw: string) => {90 const tag = raw.trim();91 if (!tag || tags.includes(tag)) return;92 if (maxTags && tags.length >= maxTags) return;93 update([...tags, tag]);94 setInput("");95 };96
97 const removeTag = (index: number) => {98 const tagText = tags[index];99 if (!tagText) return;100 setAnimTags((current) =>101 current.map((at) =>102 at.text === tagText ? { ...at, state: "exiting" as const } : at103 )104 );105 setTimeout(() => {106 update(tags.filter((_, i) => i !== index));107 }, 180);108 };109
110 const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {111 if (e.key === "Enter" || e.key === ",") {112 e.preventDefault();113 addTag(input);114 }115 if (e.key === "Backspace" && !input && tags.length > 0) {116 removeTag(tags.length - 1);117 }118 };119
120 return (121 <div className={["flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>122 {label && (123 <label htmlFor={`${uid}-input`} className="text-[12.5px] font-medium text-neutral-700 tracking-[-0.005em]">124 {label}125 </label>126 )}127 <div128 onClick={() => inputRef.current?.focus()}129 className={[130 "flex flex-wrap items-center gap-1.5 rounded-xl px-3 py-2.5",131 "transition-[box-shadow,background] duration-200 ease-out cursor-text",132 "[background:var(--field)] [box-shadow:var(--field-shadow)]",133 "hover:[background:var(--field-hover)] hover:[box-shadow:var(--field-shadow-hover)]",134 "focus-within:[background:#ffffff] focus-within:[box-shadow:var(--field-shadow-focus)]",135 fullWidth ? "w-full" : "",136 className,137 ]138 .filter(Boolean)139 .join(" ")}140 >141 {animTags.map((at, i) => (142 <span143 key={at.text}144 className={[145 "inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-[7px] text-[11.5px] font-medium text-neutral-700 select-none",146 "transition-all duration-180 ease-[cubic-bezier(0.34,1.4,0.64,1)]",147 at.state === "entering" ? "opacity-0 scale-75" : "",148 at.state === "visible" ? "opacity-100 scale-100" : "",149 at.state === "exiting" ? "opacity-0 scale-75" : "",150 ].join(" ")}151 style={{152 /* Tags are small raised faces sitting in the sunken field */153 background: "var(--face-light)",154 boxShadow: "var(--face-light-shadow)",155 }}156 >157 {at.text}158 <button159 type="button"160 onClick={(e) => {161 e.stopPropagation();162 const idx = tags.indexOf(at.text);163 if (idx !== -1) removeTag(idx);164 }}165 aria-label={`Remove ${at.text}`}166 className="w-4 h-4 inline-flex items-center justify-center rounded-[5px] text-neutral-400 hover:text-neutral-900 hover:bg-neutral-900/[0.06] cursor-pointer transition-colors"167 >168 <svg width="8" height="8" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">169 <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />170 </svg>171 </button>172 </span>173 ))}174 <input175 ref={inputRef}176 id={`${uid}-input`}177 type="text"178 value={input}179 onChange={(e) => setInput(e.target.value)}180 onKeyDown={handleKeyDown}181 onBlur={() => { if (input) addTag(input); }}182 placeholder={tags.length === 0 ? placeholder : ""}183 aria-label={label ? undefined : placeholder}184 aria-describedby={hint ? `${uid}-hint` : undefined}185 className="flex-1 min-w-[80px] bg-transparent text-[13.5px] text-neutral-900 placeholder:text-neutral-400 outline-none py-0.5"186 />187 </div>188 {hint && <p id={`${uid}-hint`} className="text-[11.5px] text-neutral-500 mt-0.5">{hint}</p>}189 </div>190 );191}usage
tsx
import { TagInput } from "@blurrow/ui";tsx
<TagInput value={tags} onChange={setTags} label="tags" />examples
limited
maxtags caps how many tags can be added.
essay
up to 3
props
straight from the source, so they're always up to date.
propsts
interface TagInputProps { value?: string[]; defaultValue?: string[]; onChange?: (tags: string[]) => void; placeholder?: string; label?: string; hint?: string; maxTags?: number; fullWidth?: boolean; className?: string;}