VibeUI
ComponentsDocs
VibeUI

Components with taste, that you own. Copy the source, retint the tokens, keep every line.

Library

  • All components
  • Signature pieces
  • Text effects
  • Footers

Docs

  • Introduction
  • Installation
  • Theming
  • CLI

Project

  • GitHub
  • Agents and MCP
  • Issues
  • MIT license
© 2026 VibeUI · MIT licensed
  1. Components
  2. Forms
  3. Tag Input

Tag Input

Free-form tag entry. Enter or comma adds, Backspace removes the last.

FormsClient component
minimaltokens

Enter or comma to add · Backspace to remove.

Install

bash
npx @agents-npm-packages/vibeui add tag-input --dir ./src/components/ui

Import

tsx
import { TagInput } from "@/components/ui/tag-input"

API

Generated from the component's TypeScript types, so it always matches the source. * marks a required prop.

<TagInput>

PropTypeDefaultDescription
tags*string[]—Current tags.
onChange*(tags: string[]) => void—Called when the value changes.
placeholderstring"Add tag…"Hint text shown while empty.
classNamestring—Extra classes, merged with the component's own.

Source

src/components/ui/tag-input.tsx
"use client";

import * as React from "react";
import { X } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";

/** Free-form tag entry. Enter or comma adds, Backspace removes the last. */
export function TagInput({
  tags,
  onChange,
  placeholder = "Add tag…",
  className,
}: {
  tags: string[];
  onChange: (tags: string[]) => void;
  placeholder?: string;
  className?: string;
}) {
  const [draft, setDraft] = React.useState("");

  const add = () => {
    const t = draft.trim();
    if (!t || tags.includes(t)) return;
    onChange([...tags, t]);
    setDraft("");
  };

  return (
    <div
      className={cn(
        "flex min-h-10 flex-wrap items-center gap-1.5 rounded-sm border border-line-strong bg-surface px-1.5 py-1.5 shadow-quiet transition-[border-color,box-shadow] duration-150 hover:border-faint focus-within:border-accent focus-within:ring-[3px] focus-within:ring-ring",
        className,
      )}
    >
      {tags.map((tag) => (
        <span
          key={tag}
          className="inline-flex h-6 animate-pop-in items-center gap-1 rounded-[6px] bg-surface-muted pl-2 pr-1 text-[12px] font-medium text-ink-soft"
        >
          {tag}
          <button
            type="button"
            aria-label={`Remove ${tag}`}
            onClick={() => onChange(tags.filter((t) => t !== tag))}
            className="flex size-4 items-center justify-center rounded-full text-faint transition-colors hover:bg-surface-sunken hover:text-ink"
          >
            <X size={10} weight="bold" />
          </button>
        </span>
      ))}
      <input
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onBlur={add}
        onKeyDown={(e) => {
          if (e.key === "Enter" || e.key === ",") {
            e.preventDefault();
            add();
          }
          if (e.key === "Backspace" && !draft && tags.length) {
            onChange(tags.slice(0, -1));
          }
        }}
        placeholder={tags.length ? "" : placeholder}
        aria-label="Add tag"
        className="min-w-20 flex-1 bg-transparent px-1.5 text-sm text-ink outline-none placeholder:text-faint"
      />
    </div>
  );
}

Exports TagInput. Peer dependencies: clsx, tailwind-merge, @phosphor-icons/react.

PreviousSwitchNextAlert