Chip & TagInput

Compact removable tokens, and a tag input that adds chips on Enter/comma.

import { Chip, TagInput }from "@/components/ui"

Chips + tag input

Read-onlyRemovable
designfrontend

Enter or comma to add.

Full source

The complete component, read from src/components/ui/chip.tsx.

chip.tsx
"use client";

import { useState, type ReactNode, type KeyboardEvent } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Field, controlBase, controlClasses } from "./field";

interface ChipProps {
  children: ReactNode;
  onRemove?: () => void;
  className?: string;
}

export function Chip({ children, onRemove, className }: ChipProps) {
  return (
    <span className={cn("inline-flex items-center gap-1 rounded-md bg-accent-light px-2 py-0.5 text-xs font-medium text-accent", className)}>
      {children}
      {onRemove && (
        <button
          type="button"
          onClick={onRemove}
          aria-label="Remove"
          className="rounded-full hover:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
        >
          <X size={11} aria-hidden />
        </button>
      )}
    </span>
  );
}

interface TagInputProps {
  id?: string;
  label?: string;
  hint?: string;
  error?: string;
  value: string[];
  onChange: (tags: string[]) => void;
  placeholder?: string;
  disabled?: boolean;
}

export function TagInput({ id, label, hint, error, value, onChange, placeholder = "Add tag…", disabled }: TagInputProps) {
  const [draft, setDraft] = useState("");

  const add = (raw: string) => {
    const t = raw.trim().replace(/,$/, "").trim();
    if (t && !value.includes(t)) onChange([...value, t]);
    setDraft("");
  };

  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
    if ((e.key === "Enter" || e.key === ",") && draft.trim()) {
      e.preventDefault();
      add(draft);
    } else if (e.key === "Backspace" && !draft && value.length) {
      onChange(value.slice(0, -1));
    }
  };

  return (
    <Field label={label} hint={hint} error={error} htmlFor={id}>
      <div
        className={cn(
          controlBase,
          controlClasses(error, disabled),
          "flex min-h-10 flex-wrap items-center gap-1 px-2 py-1",
          !disabled && "cursor-text"
        )}
      >
        {value.map((t) => (
          <Chip key={t} onRemove={disabled ? undefined : () => onChange(value.filter((x) => x !== t))}>
            {t}
          </Chip>
        ))}
        <input
          id={id}
          value={draft}
          disabled={disabled}
          placeholder={value.length === 0 ? placeholder : ""}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={onKeyDown}
          onBlur={() => draft.trim() && add(draft)}
          className="min-w-24 flex-1 bg-transparent px-1 py-0.5 text-sm text-text outline-none"
        />
      </div>
    </Field>
  );
}

Dependencies

To use Chip & TagInput you also need the following. Copy these into your project alongside it.

Recommended folder structure

project structure
src/components/ui/
  ├─ chip.tsx
  └─ field.tsx
src/lib/
  └─ utils.ts