Chip & TagInput

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

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

Specification

Displays a compact, optionally-removable token; TagInput lets users build a list of chips by typing and pressing Enter or comma.

Props (11)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
children*ReactNode—The chip's content/label (Chip).
onRemove() => void—When provided, shows a remove (×) button and is called on click (Chip).
classNamestring—Extra classes merged onto the chip (Chip).
idstring—Explicit element id, wired to the label (TagInput).
labelstring—Visible field label shown above the input (TagInput).
hintstring—Helper text shown below the field (TagInput).
errorstring—Error message; renders the field in the error state (TagInput).
value*string[]—The current list of tags (TagInput).
onChange*(tags: string[]) => void—Called with the updated tag list when tags are added or removed (TagInput).
placeholderstring"Add tag…"Placeholder shown when there are no tags (TagInput).
disabledboolean—Disables adding and removing tags (TagInput).

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

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/components/ui/field.tsx
field.tsx
"use client";

import { type ReactNode } from "react";
import { cn } from "@/lib/utils";

interface FieldCounter {
  text: string;
  warn?: boolean;
}

interface FieldProps {
  label?: string;
  hint?: string;
  error?: string;
  required?: boolean;
  htmlFor?: string;
  counter?: FieldCounter;

  messageId?: string;
  children: ReactNode;
}

export function Field({
  label,
  hint,
  error,
  required,
  htmlFor,
  counter,
  messageId,
  children,
}: FieldProps) {
  const message = error || hint;
  return (
    <div className="flex w-full flex-col gap-1.5">
      {label && (
        <label
          htmlFor={htmlFor}
          className="flex items-center gap-1 text-[13px] font-medium text-text"
        >
          {label}
          {required && (
            <span className="text-error" aria-hidden>
              *
            </span>
          )}
        </label>
      )}
      {children}
      <div className="flex items-start justify-between gap-3">
        <p
          id={messageId}
          className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
        >
          {message || "\u00A0"}
        </p>
        {counter && (
          <span
            className={cn(
              "shrink-0 text-xs tabular-nums",
              counter.warn ? "text-error" : "text-text-tertiary"
            )}
          >
            {counter.text}
          </span>
        )}
      </div>
    </div>
  );
}

export const controlBase = cn(
  "w-full rounded-lg border text-sm bg-surface text-text transition-colors",
  "placeholder:text-text-tertiary",
  "focus:outline-none focus-visible:outline-none focus-visible:border-accent",
  "disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
  "read-only:bg-bg-secondary"
);

export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
  return cn(
    error ? "border-error focus-visible:border-error" : "border-border",
    readOnly && "read-only:bg-bg-secondary",
    disabled && "text-text-tertiary"
  );
}
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}