shadimage
buzzform
uitagssource: ok · hit

TagsField

Tag input field for BuzzForm v2.

npx shadcn add "https://form.buildnbuzz.com/r/tags.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

@buzzform/initfield@buzzform/copy

Files2 · source cached 10/8/2026, 9:05:52 PM

registry/shadcn/fields/tags.tsx
"use client";

import * as React from "react";
import type { TagsField as TagsFieldDef } from "@buildnbuzz/form-react";
import { useDataField } from "@buildnbuzz/form-react";
import { CopyButton } from "../components/copy-button";
import {
  TagInput,
  TagInputList,
  TagInputItem,
  TagInputControl,
  type TagInputDelimiter,
} from "../components/tag-input";
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { cn } from "@/lib/utils";

// ---------------------------------------------------------------------------
// UI options
// ---------------------------------------------------------------------------

interface TagsUi {
  /** Keys that create a new tag. Defaults to `["enter"]`. */
  delimiters?: ("enter" | "comma" | "space" | "tab")[];
  /** Visual variant for tag chips. Defaults to `"chips"`. */
  variant?: "chips" | "pills" | "inline";
  /** Show a copy button that copies all tags as a comma-separated string. */
  copyable?: boolean;
  /** className applied to `<FieldGroup>`. */
  className?: string;
  /** Inline width applied to `<FieldGroup>`. */
  width?: string | number;
  /** Controls required asterisk visibility. */
  asterisk?: boolean;
  /** Text and label overrides. */
  labels?: {
    /** Custom text for tag count. Defaults to `"{count} / {max} tags"`. */
    count?: React.ReactNode;
  };
}

// ---------------------------------------------------------------------------
// TagsField
// ---------------------------------------------------------------------------

export function TagsField() {
  const {
    fieldApi,
    field,
    isDisabled,
    isReadOnly,
    isRequired,
    label,
    placeholder,
    description,
    errors,
    isInvalid,
    descriptionId,
    errorId,
    ariaDescribedBy,
    handleBlur,
  } = useDataField<TagsFieldDef>();

  const ui = field.ui as TagsUi | undefined;

  const delimiters = (ui?.delimiters ?? ["enter"]).map((d) =>
    d.charAt(0).toUpperCase() + d.slice(1)
  ) as TagInputDelimiter[];

  const variant = ui?.variant ?? "chips";
  const copyable = ui?.copyable ?? false;

  const width = ui?.width;
  const widthStyle = width
    ? {
        width: typeof width === "number" ? `${width}px` : width,
        flex: "0 1 auto",
      }
    : undefined;

  const value = Array.isArray(fieldApi.state.value)
    ? (fieldApi.state.value as string[])
    : [];

  return (
    <FieldGroup
      data-field={fieldApi.name}
      className={ui?.className}
      style={widthStyle}
    >
      <Field data-invalid={isInvalid} data-disabled={isDisabled}>
        {label && (
          <FieldLabel htmlFor={fieldApi.name} className="gap-1 items-baseline">
            {label}
            {isRequired && ui?.asterisk !== false ? (
              <span className="text-destructive">*</span>
            ) : null}
          </FieldLabel>
        )}

        <FieldContent>
          <div className="relative">
            <TagInput
              value={value}
              onValueChange={fieldApi.handleChange}
              variant={variant}
              disabled={isDisabled}
              readOnly={isReadOnly}
              maxTags={field.maxTags}
              allowDuplicates={field.allowDuplicates}
              maxTagLength={field.maxTagLength}
            >
              <TagInputList className={cn(copyable && "pr-10")}>
                {value.map((tag, i) => (
                  <TagInputItem key={`${tag}-${i}`} index={i} />
                ))}
                <TagInputControl
                  id={fieldApi.name}
                  delimiters={delimiters}
                  placeholder={placeholder || "Add tags..."}
                  onBlur={handleBlur}
                  autoComplete={field.autoComplete}
                  aria-invalid={isInvalid}
                  aria-describedby={ariaDescribedBy}
                />
              </TagInputList>
            </TagInput>

            {copyable && value.length > 0 && (
              <CopyButton
                value={value.join(", ")}
                disabled={isDisabled}
                className="absolute right-0 top-0 h-full px-2.5 text-muted-foreground hover:text-foreground"
              />
            )}
          </div>

          {field.maxTags !== undefined && (
            <p className="mt-1 text-xs text-muted-foreground">
              {ui?.labels?.count ?? `${value.length} / ${field.maxTags} tags`}
            </p>
          )}
        </FieldContent>

        {description && !isInvalid && (
          <FieldDescription id={descriptionId}>{description}</FieldDescription>
        )}
        {isInvalid && <FieldError id={errorId} errors={errors} />}
      </Field>
    </FieldGroup>
  );
}
registry/shadcn/components/tag-input.tsx
"use client";

import * as React from "react";
import { cva } from "class-variance-authority";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";

// ---------------------------------------------------------------------------
// Variants (CVA)
// ---------------------------------------------------------------------------

const tagInputVariants = cva(
  "flex min-h-8 w-full flex-wrap items-center gap-1.5 rounded-lg border border-input bg-transparent bg-clip-padding px-3 py-1.5 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
  {
    variants: {
      disabled: {
        true: "cursor-not-allowed opacity-50",
      },
      readOnly: {
        true: "cursor-default bg-muted/30",
      },
    },
  }
);

const tagItemVariants = cva(
  "flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 font-medium whitespace-nowrap text-foreground transition-all",
  {
    variants: {
      variant: {
        chips: "rounded-sm bg-muted px-1.5 text-xs",
        pills: "rounded-full bg-muted px-3 text-xs",
        inline: "rounded-none border-0 border-b-2 border-muted-foreground/30 bg-transparent px-1 text-sm",
      },
    },
    defaultVariants: {
      variant: "chips",
    },
  }
);

// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------

type TagInputContextType = {
  value: string[];
  maxTags?: number;
  allowDuplicates?: boolean;
  maxTagLength?: number;
  onValueChange?: (value: string[]) => void;
  disabled?: boolean;
  readOnly?: boolean;
  variant: "chips" | "pills" | "inline";
  addTag: (tag: string) => boolean;
  removeTag: (index: number) => void;
  inputValue: string;
  setInputValue: (val: string) => void;
  inputRef: React.RefObject<HTMLInputElement | null>;
};

const TagInputContext = React.createContext<TagInputContextType | null>(null);

function useTagInputContext() {
  const context = React.useContext(TagInputContext);
  if (!context) {
    throw new Error("TagInput components must be used within a <TagInput />");
  }
  return context;
}

// ---------------------------------------------------------------------------
// TagInput Root
// ---------------------------------------------------------------------------

export type TagInputDelimiter = "Enter" | "Comma" | "Space" | "Tab" | string;

export interface TagInputProps {
  value?: string[];
  onValueChange?: (value: string[]) => void;
  disabled?: boolean;
  readOnly?: boolean;
  variant?: "chips" | "pills" | "inline";
  maxTags?: number;
  allowDuplicates?: boolean;
  maxTagLength?: number;
  children?: React.ReactNode;
}

export const TagInput = ({
  value = [],
  onValueChange,
  disabled,
  readOnly,
  variant = "chips",
  maxTags,
  allowDuplicates = false,
  maxTagLength,
  children,
}: TagInputProps) => {
  const [inputValue, setInputValue] = React.useState("");
  const inputRef = React.useRef<HTMLInputElement>(null);

  const addTag = (tag: string): boolean => {
    const trimmed = tag.trim();
    if (!trimmed) return false;
    if (maxTagLength && trimmed.length > maxTagLength) return false;
    if (!allowDuplicates && value.includes(trimmed)) return false;
    if (maxTags !== undefined && value.length >= maxTags) return false;

    onValueChange?.([...value, trimmed]);
    return true;
  };

  const removeTag = (index: number) => {
    if (disabled || readOnly) return;
    onValueChange?.(value.filter((_, i) => i !== index));
  };

  return (
    <TagInputContext.Provider
      value={{
        value,
        maxTags,
        allowDuplicates,
        maxTagLength,
        onValueChange,
        disabled,
        readOnly,
        variant,
        addTag,
        removeTag,
        inputValue,
        setInputValue,
        inputRef,
      }}
    >
      {children}
    </TagInputContext.Provider>
  );
};

// ---------------------------------------------------------------------------
// TagInputList
// ---------------------------------------------------------------------------

export type TagInputListProps = React.ComponentPropsWithoutRef<"div">;

export const TagInputList = React.forwardRef<HTMLDivElement, TagInputListProps>(
  ({ className, ...props }, ref) => {
    const { disabled, readOnly, inputRef } = useTagInputContext();

    return (
      <div
        ref={ref}
        onClick={() => !disabled && !readOnly && inputRef.current?.focus()}
        className={cn(tagInputVariants({ disabled, readOnly, className }))}
        {...props}
      />
    );
  }
);
TagInputList.displayName = "TagInputList";

// ---------------------------------------------------------------------------
// TagInputItem
// ---------------------------------------------------------------------------

export interface TagInputItemProps extends React.HTMLAttributes<HTMLDivElement> {
  index: number;
}

export const TagInputItem = React.forwardRef<HTMLDivElement, TagInputItemProps>(
  ({ className, index, children, ...props }, ref) => {
    const { variant, value, disabled, readOnly, removeTag } = useTagInputContext();
    const tag = value[index];

    return (
      <div
        ref={ref}
        className={cn(tagItemVariants({ variant, className }))}
        {...props}
      >
        {children ?? <span className="truncate max-w-40">{tag}</span>}
        {!disabled && !readOnly && (
          <button
            type="button"
            onClick={(e) => {
              e.stopPropagation();
              removeTag(index);
            }}
            className="ml-0.5 rounded-full p-0.5 opacity-50 hover:bg-muted-foreground/20 hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring"
            aria-label={`Remove ${tag}`}
          >
            <IconPlaceholder
              lucide="X"
              hugeicons="Cancel01Icon"
              tabler="IconX"
              phosphor="X"
              remixicon="RiCloseLine"
              className="size-3"
            />
          </button>
        )}
      </div>
    );
  }
);
TagInputItem.displayName = "TagInputItem";

// ---------------------------------------------------------------------------
// TagInputControl
// ---------------------------------------------------------------------------

export interface TagInputControlProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> {
  delimiters?: TagInputDelimiter[];
}

export const TagInputControl = React.forwardRef<HTMLInputElement, TagInputControlProps>(
  ({ className, delimiters = ["Enter"], placeholder, onBlur, onKeyDown, ...props }, ref) => {
    const {
      inputValue,
      setInputValue,
      addTag,
      removeTag,
      value: tags,
      maxTags,
      disabled,
      readOnly,
      inputRef,
    } = useTagInputContext();

    // Sync external forward ref with context ref
    React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);

    const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Backspace" && !inputValue && tags.length > 0) {
        removeTag(tags.length - 1);
        return;
      }

      const isDelimiter = delimiters.some((d) => {
        if (d === "Comma" && e.key === ",") return true;
        if (d === "Space" && e.key === " ") return true;
        return d.toLowerCase() === e.key.toLowerCase();
      });

      if (isDelimiter && inputValue.trim()) {
        e.preventDefault();
        if (addTag(inputValue)) {
          setInputValue("");
        }
      }

      onKeyDown?.(e);
    };

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      const val = e.target.value;
      const hasCommaDelimiter = delimiters.some((d) => d === "Comma" || d === ",");

      if (hasCommaDelimiter && val.includes(",")) {
        const parts = val.split(",");
        const last = parts.pop() ?? "";

        parts.forEach((p) => {
          addTag(p);
        });

        setInputValue(last);
        return;
      }

      setInputValue(val);
    };

    const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
      if (inputValue.trim()) {
        if (addTag(inputValue)) {
          setInputValue("");
        }
      }
      onBlur?.(e);
    };

    if (readOnly) return null;

    const canAddMore = maxTags === undefined || tags.length < maxTags;
    if (!canAddMore) return null;

    return (
      <input
        {...props}
        ref={inputRef}
        type="text"
        value={inputValue}
        onChange={handleChange}
        onKeyDown={handleKeyDown}
        onBlur={handleInputBlur}
        disabled={disabled}
        placeholder={tags.length === 0 ? placeholder : ""}
        className={cn(
          "flex-1 min-w-20 bg-transparent outline-none text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed",
          className
        )}
        autoComplete={props.autoComplete ?? "off"}
      />
    );
  }
);
TagInputControl.displayName = "TagInputControl";

More from buzzform

Browse all components in this library →