deepmoon

Tag Input

reacttypescript

Tags: react, typescript

Max 3 tags

Installation

$ npx shadcn@latest add https://deepmoon.dev/r/tag-input.json

npm dependencies: lucide-react

Props

TagInputProps
PropTypeRequired
valuestring[]Yes
onChange(value: string[]) => voidYes
placeholderstringNo
maxTagsnumberNo
disabledbooleanNo
classNamestringNo
aria-labelstringNo

Code

tag-input.tsx
"use client";

import * as React from "react";
import { X } from "lucide-react";

import { cn } from "@/lib/utils";

export interface TagInputProps {
  value: string[];
  onChange: (value: string[]) => void;
  placeholder?: string;
  maxTags?: number;
  disabled?: boolean;
  className?: string;
  "aria-label"?: string;
}

/**
 * Freeform version of the chip pattern used by Multiselect, without a
 * listbox popup — there's nothing to search or pick, so it's just a plain
 * text input with rendered removable chips. Fully accessible by
 * construction: no custom ARIA widget role needed, native input semantics
 * cover it.
 */
export function TagInput({
  value,
  onChange,
  placeholder = "Add a tag...",
  maxTags,
  disabled = false,
  className,
  "aria-label": ariaLabel = "Tags",
}: TagInputProps) {
  const [draft, setDraft] = React.useState("");
  const inputRef = React.useRef<HTMLInputElement>(null);
  const atMax = maxTags !== undefined && value.length >= maxTags;

  const commit = (raw: string) => {
    const tag = raw.trim();
    if (!tag || atMax || value.includes(tag)) return;
    onChange([...value, tag]);
    setDraft("");
  };

  const removeTag = (tag: string) => {
    onChange(value.filter((t) => t !== tag));
    inputRef.current?.focus();
  };

  return (
    <div
      onClick={() => !disabled && inputRef.current?.focus()}
      className={cn(
        "flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-2 py-1.5",
        "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
        disabled && "cursor-not-allowed opacity-50",
        className,
      )}
    >
      {value.map((tag) => (
        <span
          key={tag}
          className="flex items-center gap-1 rounded bg-secondary py-0.5 pl-2 pr-1 text-xs font-medium text-secondary-foreground"
        >
          {tag}
          {!disabled ? (
            <button
              type="button"
              onClick={(event) => {
                event.stopPropagation();
                removeTag(tag);
              }}
              aria-label={`Remove ${tag}`}
              className="rounded-sm p-0.5 text-secondary-foreground/70 outline-none hover:bg-secondary-foreground/10 hover:text-secondary-foreground focus-visible:ring-1 focus-visible:ring-ring"
            >
              <X className="size-3" aria-hidden="true" />
            </button>
          ) : null}
        </span>
      ))}
      <input
        ref={inputRef}
        type="text"
        aria-label={ariaLabel}
        disabled={disabled || atMax}
        value={draft}
        onChange={(event) => setDraft(event.target.value)}
        onKeyDown={(event) => {
          if (event.key === "Enter" || event.key === ",") {
            event.preventDefault();
            commit(draft);
          } else if (event.key === "Backspace" && draft === "" && value.length > 0) {
            removeTag(value[value.length - 1]);
          }
        }}
        onBlur={() => commit(draft)}
        placeholder={atMax ? `Max ${maxTags} tags` : value.length === 0 ? placeholder : ""}
        className="min-w-16 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
      />
    </div>
  );
}