Web InnoventixFreeCode

Tags Input

Original · free

tag input, add and remove chips

byWeb InnoventixReact + Tailwind
inptagsinputs
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/inp-tags.json
inp-tags.tsx
"use client";

import {
  useId,
  useRef,
  useState,
  type KeyboardEvent,
  type ClipboardEvent,
  type MouseEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Accent = "indigo" | "violet" | "emerald" | "sky" | "amber";

type Tag = {
  id: string;
  value: string;
  invalid: boolean;
};

type TagFieldProps = {
  label: string;
  hint: string;
  placeholder: string;
  accent: Accent;
  initial: string[];
  suggestions?: string[];
  maxTags?: number;
  validate?: (value: string) => boolean;
};

const ringMap: Record<Accent, string> = {
  indigo: "focus-within:border-indigo-400 focus-within:ring-indigo-500/25",
  violet: "focus-within:border-violet-400 focus-within:ring-violet-500/25",
  emerald: "focus-within:border-emerald-400 focus-within:ring-emerald-500/25",
  sky: "focus-within:border-sky-400 focus-within:ring-sky-500/25",
  amber: "focus-within:border-amber-400 focus-within:ring-amber-500/25",
};

const chipMap: Record<Accent, string> = {
  indigo:
    "bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/25",
  violet:
    "bg-violet-50 text-violet-700 ring-1 ring-violet-200 dark:bg-violet-500/15 dark:text-violet-200 dark:ring-violet-400/25",
  emerald:
    "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/25",
  sky: "bg-sky-50 text-sky-700 ring-1 ring-sky-200 dark:bg-sky-500/15 dark:text-sky-200 dark:ring-sky-400/25",
  amber:
    "bg-amber-50 text-amber-800 ring-1 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-400/25",
};

const removeHoverMap: Record<Accent, string> = {
  indigo: "hover:bg-indigo-200/70 dark:hover:bg-indigo-400/25",
  violet: "hover:bg-violet-200/70 dark:hover:bg-violet-400/25",
  emerald: "hover:bg-emerald-200/70 dark:hover:bg-emerald-400/25",
  sky: "hover:bg-sky-200/70 dark:hover:bg-sky-400/25",
  amber: "hover:bg-amber-200/70 dark:hover:bg-amber-400/25",
};

const suggestMap: Record<Accent, string> = {
  indigo:
    "border-indigo-200 text-indigo-700 hover:bg-indigo-50 focus-visible:ring-indigo-500/40 dark:border-indigo-400/25 dark:text-indigo-200 dark:hover:bg-indigo-500/10",
  violet:
    "border-violet-200 text-violet-700 hover:bg-violet-50 focus-visible:ring-violet-500/40 dark:border-violet-400/25 dark:text-violet-200 dark:hover:bg-violet-500/10",
  emerald:
    "border-emerald-200 text-emerald-700 hover:bg-emerald-50 focus-visible:ring-emerald-500/40 dark:border-emerald-400/25 dark:text-emerald-200 dark:hover:bg-emerald-500/10",
  sky: "border-sky-200 text-sky-700 hover:bg-sky-50 focus-visible:ring-sky-500/40 dark:border-sky-400/25 dark:text-sky-200 dark:hover:bg-sky-500/10",
  amber:
    "border-amber-200 text-amber-800 hover:bg-amber-50 focus-visible:ring-amber-500/40 dark:border-amber-400/25 dark:text-amber-100 dark:hover:bg-amber-500/10",
};

const invalidChip =
  "bg-rose-50 text-rose-700 ring-1 ring-rose-300 dark:bg-rose-500/15 dark:text-rose-200 dark:ring-rose-400/35";

let tagSeq = 0;
function nextId(): string {
  tagSeq += 1;
  return `tag-${tagSeq}`;
}

function TagField({
  label,
  hint,
  placeholder,
  accent,
  initial,
  suggestions = [],
  maxTags,
  validate,
}: TagFieldProps) {
  const reduced = useReducedMotion();
  const inputId = useId();
  const hintId = useId();
  const statusId = useId();
  const inputRef = useRef<HTMLInputElement | null>(null);
  const shakeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const [tags, setTags] = useState<Tag[]>(() =>
    initial.map((value) => ({
      id: nextId(),
      value,
      invalid: validate ? !validate(value) : false,
    })),
  );
  const [draft, setDraft] = useState("");
  const [shaking, setShaking] = useState(false);
  const [status, setStatus] = useState("");

  const atLimit = typeof maxTags === "number" && tags.length >= maxTags;

  function announce(message: string) {
    setStatus(message);
  }

  function reject(message: string) {
    announce(message);
    if (reduced) return;
    setShaking(true);
    if (shakeTimer.current) clearTimeout(shakeTimer.current);
    shakeTimer.current = setTimeout(() => setShaking(false), 320);
  }

  function commit(raw: string) {
    const pieces = raw
      .split(/[,\n]/)
      .map((piece) => piece.trim())
      .filter(Boolean);
    if (pieces.length === 0) return;

    setTags((current) => {
      const next = [...current];
      const seen = new Set(next.map((tag) => tag.value.toLowerCase()));
      let added = 0;
      let blocked = false;

      for (const piece of pieces) {
        if (seen.has(piece.toLowerCase())) {
          blocked = true;
          continue;
        }
        if (typeof maxTags === "number" && next.length >= maxTags) {
          blocked = true;
          break;
        }
        next.push({
          id: nextId(),
          value: piece,
          invalid: validate ? !validate(piece) : false,
        });
        seen.add(piece.toLowerCase());
        added += 1;
      }

      if (added > 0) {
        announce(
          `Added ${pieces[pieces.length - 1]}. ${next.length} ${label.toLowerCase()} total.`,
        );
      } else if (blocked) {
        reject(
          typeof maxTags === "number" && current.length >= maxTags
            ? `Limit of ${maxTags} reached.`
            : "That tag is already added.",
        );
      }
      return next;
    });
    setDraft("");
  }

  function removeAt(id: string, value: string) {
    setTags((current) => current.filter((tag) => tag.id !== id));
    announce(`Removed ${value}.`);
    inputRef.current?.focus();
  }

  function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
    if (event.key === "Enter" || event.key === ",") {
      event.preventDefault();
      commit(draft);
      return;
    }
    if (event.key === "Backspace" && draft.length === 0 && tags.length > 0) {
      event.preventDefault();
      const last = tags[tags.length - 1];
      removeAt(last.id, last.value);
    }
  }

  function onPaste(event: ClipboardEvent<HTMLInputElement>) {
    const text = event.clipboardData.getData("text");
    if (/[,\n]/.test(text)) {
      event.preventDefault();
      commit(text);
    }
  }

  function onContainerMouseDown(event: MouseEvent<HTMLDivElement>) {
    if (event.target === inputRef.current) return;
    if ((event.target as HTMLElement).closest("button")) return;
    event.preventDefault();
    inputRef.current?.focus();
  }

  const openSuggestions = suggestions.filter(
    (item) => !tags.some((tag) => tag.value.toLowerCase() === item.toLowerCase()),
  );

  return (
    <div className="flex flex-col gap-2.5">
      <div className="flex items-baseline justify-between gap-3">
        <label
          htmlFor={inputId}
          className="text-sm font-medium text-slate-800 dark:text-slate-100"
        >
          {label}
        </label>
        {typeof maxTags === "number" ? (
          <span
            className={`text-xs tabular-nums ${
              atLimit
                ? "font-semibold text-rose-500 dark:text-rose-300"
                : "text-slate-400 dark:text-slate-500"
            }`}
          >
            {tags.length} / {maxTags}
          </span>
        ) : null}
      </div>

      <div
        onMouseDown={onContainerMouseDown}
        className={`inptags-shake-target flex min-h-[3rem] flex-wrap items-center gap-1.5 rounded-xl border border-slate-300 bg-white/80 p-2 shadow-sm backdrop-blur transition-shadow ring-0 focus-within:ring-4 dark:border-slate-700 dark:bg-slate-900/60 ${ringMap[accent]} ${
          shaking ? "inptags-shake-anim" : ""
        }`}
      >
        <ul role="list" aria-label={label} className="contents">
          <AnimatePresence initial={false}>
            {tags.map((tag) => (
              <motion.li
                key={tag.id}
                layout={!reduced}
                initial={reduced ? false : { opacity: 0, scale: 0.7 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.7 }}
                transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
                className={`group inline-flex items-center gap-1 rounded-lg py-1 pl-2.5 pr-1 text-sm font-medium ${
                  tag.invalid ? invalidChip : chipMap[accent]
                }`}
              >
                <span className="max-w-[16rem] truncate">{tag.value}</span>
                {tag.invalid ? (
                  <span className="sr-only">(invalid)</span>
                ) : null}
                <button
                  type="button"
                  onClick={() => removeAt(tag.id, tag.value)}
                  aria-label={`Remove ${tag.value}`}
                  className={`grid h-5 w-5 place-items-center rounded-md text-current opacity-70 outline-none transition-colors hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-current ${removeHoverMap[accent]}`}
                >
                  <svg
                    viewBox="0 0 20 20"
                    fill="none"
                    aria-hidden="true"
                    className="h-3 w-3"
                  >
                    <path
                      d="M5 5l10 10M15 5L5 15"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                    />
                  </svg>
                </button>
              </motion.li>
            ))}
          </AnimatePresence>
        </ul>

        <input
          id={inputId}
          ref={inputRef}
          type="text"
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
          onKeyDown={onKeyDown}
          onPaste={onPaste}
          onBlur={() => commit(draft)}
          disabled={atLimit && draft.length === 0}
          placeholder={tags.length === 0 ? placeholder : atLimit ? "" : "Add another…"}
          aria-describedby={`${hintId} ${statusId}`}
          className="min-w-[10ch] flex-1 bg-transparent px-1.5 py-1 text-sm text-slate-900 outline-none placeholder:text-slate-400 disabled:cursor-not-allowed dark:text-slate-50 dark:placeholder:text-slate-500"
        />
      </div>

      <p id={hintId} className="text-xs text-slate-500 dark:text-slate-400">
        {hint}
      </p>

      {openSuggestions.length > 0 && !atLimit ? (
        <div className="flex flex-wrap items-center gap-1.5">
          <span className="text-xs font-medium text-slate-400 dark:text-slate-500">
            Suggested:
          </span>
          {openSuggestions.slice(0, 6).map((item) => (
            <button
              key={item}
              type="button"
              onClick={() => {
                commit(item);
                inputRef.current?.focus();
              }}
              aria-label={`Add ${item}`}
              className={`inline-flex items-center gap-1 rounded-full border bg-transparent px-2.5 py-1 text-xs font-medium outline-none transition-colors focus-visible:ring-2 ${suggestMap[accent]}`}
            >
              <svg
                viewBox="0 0 20 20"
                fill="none"
                aria-hidden="true"
                className="h-3 w-3"
              >
                <path
                  d="M10 4v12M4 10h12"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                />
              </svg>
              {item}
            </button>
          ))}
        </div>
      ) : null}

      <span id={statusId} role="status" aria-live="polite" className="sr-only">
        {status}
      </span>
    </div>
  );
}

const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export default function InpTags() {
  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes inptags-shake {
          10%, 90% { transform: translateX(-1px); }
          20%, 80% { transform: translateX(2px); }
          30%, 50%, 70% { transform: translateX(-4px); }
          40%, 60% { transform: translateX(4px); }
        }
        .inptags-shake-anim { animation: inptags-shake 0.32s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
        @keyframes inptags-orb {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(0, -22px, 0) scale(1.06); }
        }
        .inptags-orb { animation: inptags-orb 11s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .inptags-shake-anim { animation: none; }
          .inptags-orb { animation: none; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="inptags-orb pointer-events-none absolute -left-24 -top-24 h-80 w-80 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -bottom-32 -right-20 h-96 w-96 rounded-full bg-violet-400/15 blur-3xl dark:bg-violet-500/10"
      />

      <div className="relative mx-auto max-w-2xl">
        <header className="mb-10 flex flex-col items-start gap-3">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-slate-600 backdrop-blur dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Tag input
          </span>
          <h2 className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
            Add and remove chips
          </h2>
          <p className="max-w-prose text-sm text-slate-600 dark:text-slate-300">
            Type a value and press{" "}
            <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200">
              Enter
            </kbd>{" "}
            or{" "}
            <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200">
              ,
            </kbd>{" "}
            to create a chip. Press{" "}
            <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200">
              Backspace
            </kbd>{" "}
            on an empty field to delete the last one.
          </p>
        </header>

        <div className="flex flex-col gap-8 rounded-2xl border border-slate-200 bg-white/60 p-6 shadow-xl shadow-slate-900/5 backdrop-blur sm:p-8 dark:border-slate-800 dark:bg-slate-900/40 dark:shadow-black/20">
          <TagField
            label="Skills"
            accent="indigo"
            placeholder="Add a skill, e.g. TypeScript"
            hint="Paste a comma-separated list to add several at once."
            initial={["TypeScript", "React", "Accessibility"]}
            suggestions={[
              "Node.js",
              "GraphQL",
              "Figma",
              "Rust",
              "PostgreSQL",
              "Tailwind CSS",
              "Testing",
            ]}
          />

          <div className="h-px w-full bg-gradient-to-r from-transparent via-slate-200 to-transparent dark:via-slate-700" />

          <TagField
            label="Invite teammates"
            accent="emerald"
            placeholder="name@company.com"
            hint="Invalid addresses are flagged in red — fix or remove them before sending."
            initial={["maya@webinnoventix.com", "salman@"]}
            maxTags={8}
            validate={(value) => emailPattern.test(value)}
          />

          <div className="h-px w-full bg-gradient-to-r from-transparent via-slate-200 to-transparent dark:via-slate-700" />

          <TagField
            label="Article topics"
            accent="violet"
            placeholder="Add a topic tag"
            hint="Up to five topics keep the piece focused."
            initial={["SEO", "Content strategy"]}
            maxTags={5}
            suggestions={["AIEO", "Core Web Vitals", "Backlinks", "Analytics"]}
          />
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →