Skip to content
All Taste components

Inputs

Tag input

Type and press Enter to add tags; each chip can be removed, and invalid ones are flagged.

tags · focused · invalid chip · count

egglesssame-day deliverybakery
Press Enter or comma to add.3 / 10
ravi@kaverifoods.inmeera@kaveri
meera@kaveri isn’t a full email address.
"use client";
import { useId, useRef, useState } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";

export function TagInput({ label, value, onChange, max = 10, placeholder = "Add a tag…", isValid = () => true }:
  { label: string; value: string[]; onChange: (tags: string[]) => void; max?: number; placeholder?: string; isValid?: (tag: string) => boolean }) {
  const id = useId();
  const [draft, setDraft] = useState("");
  const inputRef = useRef<HTMLInputElement>(null);

  function commit() {
    const tag = draft.trim().replace(/,+$/, "");
    if (tag && !value.includes(tag) && value.length < max) onChange([...value, tag]);
    setDraft("");
  }

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

  return (
    <div className="grid gap-1.5">
      <label htmlFor={id} className="text-[13px] font-medium text-fg">{label}</label>
      <div
        onClick={() => inputRef.current?.focus()}
        className="flex min-h-9 w-full cursor-text flex-wrap items-center gap-1.5 rounded border border-border-strong bg-bg px-1.5 py-[5px] transition focus-within:border-primary focus-within:ring-[3px] focus-within:ring-primary/20"
      >
        {value.map((tag) => {
          const ok = isValid(tag);
          return (
            <span key={tag} className={cn("inline-flex h-6 max-w-full items-center gap-0.5 rounded-sm pl-2 pr-0.5 text-[13px]", ok ? "bg-surface-2 text-fg" : "bg-danger-soft text-danger-fg")}>
              <span className="truncate">{tag}</span>
              <button
                type="button"
                aria-label={"Remove " + tag + (ok ? "" : ", not valid")}
                onClick={(e) => { e.stopPropagation(); onChange(value.filter((t) => t !== tag)); }}
                className={cn("grid size-[18px] place-items-center rounded-sm transition hover:bg-fg/10", ok ? "text-fg-subtle hover:text-fg" : "text-current")}
              >
                <X className="size-3" aria-hidden />
              </button>
            </span>
          );
        })}
        <input
          ref={inputRef}
          id={id}
          value={draft}
          placeholder={value.length < max ? placeholder : ""}
          disabled={value.length >= max}
          aria-describedby={id + "-help"}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={onKeyDown}
          onBlur={commit}
          className="h-6 min-w-24 flex-1 bg-transparent px-1 text-sm text-fg outline-none placeholder:text-fg-subtle"
        />
      </div>
      <div className="flex items-baseline justify-between gap-3 text-xs text-fg-subtle">
        <p id={id + "-help"}>Press Enter or comma to add.</p>
        <p className="tabular-nums">{value.length} / {max}</p>
      </div>
    </div>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.