Skip to content
Pebble Pro is here at $49 one timeSee what’s included ↗
Pebble
ComponentsDocsMCPPricing
⌕ Find a component↗Log in
All components 673

Categories

Core inputs & forms46Buttons & actions33Navigation36Overlays & feedback49Data display33Charts & data viz40Cards & layout43Marketing blocks38App shells & pages37AI interfaces40Text & typography effects33Backgrounds & visual effects38Motion & transitions32Numbers & counters32Media34E-commerce333D & interactive36Themed sets40
Start with the essentials.Installation guide ↗Connect your agent ↗
← All componentsCore inputs & forms

Tag input

Chips inside a field: Enter or comma adds, paste splits, Backspace selects then removes, duplicates shake, per-tag validation, a limit, suggestions and double-click editing.

Preview

Share with
  • priya@pebble.example
  • malik@lantern, invalid: Not an email address
  • tomas@pebble.example
“malik@lantern” is not an email. Double-click it to fix.
Topics
  • design-system
  • accessibility
  • search
  • pricing
4/5
Up to five. Enter or comma adds; Backspace twice removes.
Skills

    Install

    Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.

    npx shadcn@latest add https://pebbleui.com/r/tag-input.json

    Props

    PropTypeDefaultDescription
    value / defaultValuestring[]—Controlled or initial tags.
    onValueChange(tags: string[]) => void—Called after tags are added, edited or removed.
    placeholderstring"Add a tag…"Shown while there are no tags.
    maxnumber—Limit; shows a counter and locks the input when reached.
    validate(tag: string) => string | undefined—Message for an invalid tag. Invalid tags stay, drawn red, and the field turns invalid.
    suggestionsstring[][]Dropdown of matching unused entries while typing.
    transform(tag: string) => stringtrimNormalise a tag before adding (e.g. lower-case or slugify).
    separatorsstring[][","]Keys that commit a tag besides Enter; pasted text also splits on them, new lines and semicolons.
    disabledbooleanfalseDims the field and blocks edits.
    invalidbooleanfalseField-level invalid state.
    namestring—Hidden input per tag for native forms.
    Build notes
    - Merge logic (`mergeTags`) lives outside the component and returns
      `{ next, added, duplicate, limited }`, so the component only sets state.
    - Chips are `motion.li` in `AnimatePresence mode="popLayout"` with `layout`
      so neighbours glide when one leaves. The shake is an inner `motion.span`
      keyed `shake-<n>` with x keyframes `[0, −5, 5, −3, 3, 0]` over 360 ms;
      bumping `n` remounts it, replaying the shake even for the same chip.
    - Separators are matched in `onKeyDown`; paste builds a RegExp from the
      escaped separators plus `\n` and `;`, and only intercepts when it matches.
    - Editing swaps a chip for a small input (`autoFocus`, `size` = text
      length). The suggestions list is positioned inside the field wrapper
      (`absolute top-full`), not portalled, so focus never leaves the input.
    
    <!-- PEBBLE-LICENSE -->
    View source

    registry/input/tag-input/tag-input.tsx

    "use client";
    
    /* PEBBLE-LICENSE
    Built by Pebble. Component: tag-input.
    
    First-party license: MIT
    
    The following first-party terms apply only to original Pebble material.
    
    MIT License
    
    Copyright (c) 2026 Praxys Solutions LLC (Pebble)
    
    Permission is hereby granted, free of charge, to any person obtaining a copy
    of this software and associated documentation files (the "Software"), to deal
    in the Software without restriction, including without limitation the rights
    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    copies of the Software, and to permit persons to whom the Software is
    furnished to do so, subject to the following conditions:
    
    The above copyright notice and this permission notice shall be included in all
    copies or substantial portions of the Software.
    
    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    SOFTWARE.
    END PEBBLE-LICENSE */
    
    import * as React from "react";
    import { AnimatePresence, MotionConfig, motion } from "motion/react";
    
    import { cn } from "@/lib/utils";
    
    export type TagInputProps = {
      value?: string[];
      defaultValue?: string[];
      onValueChange?: (tags: string[]) => void;
      placeholder?: string;
      /** Most tags allowed; the field locks and shows a counter when reached. */
      max?: number;
      /** Returns a message for an invalid tag. Invalid tags are kept and shown red. */
      validate?: (tag: string) => string | undefined | null | false;
      /** Offered in a dropdown while typing; added tags are left out. */
      suggestions?: string[];
      /** Normalise each tag before it is added (default: trim). */
      transform?: (tag: string) => string;
      /** Characters that commit a tag besides Enter. Pasted text splits on these and new lines. */
      separators?: string[];
      disabled?: boolean;
      /** Field-level invalid state (also on when any tag is invalid). */
      invalid?: boolean;
      /** A hidden input per tag for native forms. */
      name?: string;
      id?: string;
      className?: string;
      "aria-label"?: string;
      "aria-labelledby"?: string;
      "aria-describedby"?: string;
    };
    
    const SPRING = { type: "spring", stiffness: 420, damping: 32, mass: 0.8 } as const;
    
    function escapeRegExp(s: string) {
      return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    }
    
    function mergeTags(
      tags: string[],
      raw: string[],
      transform: (t: string) => string,
      max: number | undefined,
    ) {
      const next = [...tags];
      const added: string[] = [];
      let duplicate: string | undefined;
      let limited = false;
      for (const r of raw) {
        const tag = transform(r);
        if (!tag) continue;
        const dup = next.find((t) => t.toLocaleLowerCase() === tag.toLocaleLowerCase());
        if (dup) {
          duplicate = dup;
          continue;
        }
        if (max != null && next.length >= max) {
          limited = true;
          break;
        }
        next.push(tag);
        added.push(tag);
      }
      return { next, added, duplicate, limited };
    }
    
    export function TagInput(props: TagInputProps) {
      const {
        placeholder = "Add a tag…",
        max,
        validate,
        suggestions = [],
        transform = (t: string) => t.trim(),
        separators = [","],
        disabled = false,
        invalid = false,
        name,
        id,
        className,
      } = props;
    
      const uid = React.useId();
      const inputId = id ?? `${uid}-input`;
      const listId = `${uid}-suggestions`;
      const inputRef = React.useRef<HTMLInputElement>(null);
    
      const controlled = Object.prototype.hasOwnProperty.call(props, "value");
      const [internal, setInternal] = React.useState<string[]>(props.defaultValue ?? []);
      const tags = (controlled ? props.value : internal) ?? [];
    
      const [query, setQuery] = React.useState("");
      const [selected, setSelected] = React.useState<number | null>(null);
      const [editing, setEditing] = React.useState<{ index: number; text: string } | null>(null);
      const [shake, setShake] = React.useState<{ tag: string; n: number } | null>(null);
      const [announcement, setAnnouncement] = React.useState("");
      const [focused, setFocused] = React.useState(false);
      const [active, setActive] = React.useState(-1);
    
      const full = max != null && tags.length >= max;
      const errors = tags.map((t) => (validate ? validate(t) || undefined : undefined));
      const anyInvalid = invalid || errors.some(Boolean);
    
      const commit = (next: string[]) => {
        setInternal(next);
        props.onValueChange?.(next);
      };
    
      const lower = (s: string) => s.toLocaleLowerCase();
      const has = (tag: string, except?: number) =>
        tags.findIndex((t, i) => i !== except && lower(t) === lower(tag));
    
      /** Adds tags, skipping blanks, duplicates and anything past `max`. */
      const add = (raw: string[]) => {
        const { next, added, duplicate, limited } = mergeTags(tags, raw, transform, max);
        if (duplicate) {
          setShake((s) => ({ tag: duplicate, n: (s?.n ?? 0) + 1 }));
          setAnnouncement(`${duplicate} is already added`);
        }
        if (limited) setAnnouncement(`Limit of ${max} reached`);
        if (added.length) {
          commit(next);
          setAnnouncement(`Added ${added.join(", ")}`);
        }
        return added.length > 0 || raw.every((r) => !transform(r));
      };
    
      const removeAt = (index: number) => {
        const tag = tags[index];
        commit(tags.filter((_, i) => i !== index));
        setAnnouncement(`Removed ${tag}`);
        setSelected(null);
        inputRef.current?.focus();
      };
    
      const q = lower(query.trim());
      const options = q
        ? suggestions.filter((s) => lower(s).includes(q) && has(s) < 0).slice(0, 6)
        : [];
      const open = focused && options.length > 0 && !full;
      const activeIndex = active < options.length ? active : -1;
    
      const splitter = new RegExp(`[${separators.map(escapeRegExp).join("")}\\n;]+`);
    
      const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
        const caretAtStart =
          event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0;
    
        if (event.key === "Enter" || separators.includes(event.key)) {
          if (event.key === "Enter" && open && activeIndex >= 0) {
            event.preventDefault();
            add([options[activeIndex]]);
            setQuery("");
            setActive(-1);
            return;
          }
          if (!query.trim()) {
            if (separators.includes(event.key)) {
              event.preventDefault();
              setQuery("");
            }
            return;
          }
          event.preventDefault();
          if (add([query])) setQuery("");
          return;
        }
        if (event.key === "ArrowDown" && open) {
          event.preventDefault();
          setActive((activeIndex + 1) % options.length);
          return;
        }
        if (event.key === "ArrowUp" && open) {
          event.preventDefault();
          setActive(activeIndex <= 0 ? options.length - 1 : activeIndex - 1);
          return;
        }
        if (event.key === "Escape") {
          if (selected != null) setSelected(null);
          else if (open) {
            // Hide suggestions until the next keystroke.
            setActive(-1);
            setFocused(false);
          }
          return;
        }
        // Chip navigation happens only with the caret at the very start.
        if (!caretAtStart || !tags.length) return;
        if (event.key === "Backspace" || event.key === "Delete") {
          event.preventDefault();
          if (selected != null) removeAt(selected);
          else if (event.key === "Backspace") {
            // First press selects the last chip, the second removes it.
            setSelected(tags.length - 1);
            setAnnouncement(`${tags[tags.length - 1]} selected. Press Backspace again to remove.`);
          }
        } else if (event.key === "ArrowLeft") {
          event.preventDefault();
          const next = selected == null ? tags.length - 1 : Math.max(0, selected - 1);
          setSelected(next);
          setAnnouncement(`${tags[next]} selected`);
        } else if (event.key === "ArrowRight" && selected != null) {
          event.preventDefault();
          const next = selected + 1;
          setSelected(next >= tags.length ? null : next);
          if (next < tags.length) setAnnouncement(`${tags[next]} selected`);
        }
      };
    
      const finishEdit = (save: boolean) => {
        if (!editing) return;
        const tag = transform(editing.text);
        if (save && tag && tag !== tags[editing.index]) {
          const dup = has(tag, editing.index);
          if (dup >= 0) {
            setShake((s) => ({ tag: tags[dup], n: (s?.n ?? 0) + 1 }));
            setAnnouncement(`${tags[dup]} is already added`);
          } else {
            commit(tags.map((t, i) => (i === editing.index ? tag : t)));
            setAnnouncement(`Changed to ${tag}`);
          }
        } else if (save && !tag) {
          removeAt(editing.index);
        }
        setEditing(null);
        inputRef.current?.focus();
      };
    
      return (
        <MotionConfig reducedMotion="user">
          <div
            data-slot="tag-input"
            data-disabled={disabled || undefined}
            data-invalid={anyInvalid || undefined}
            onMouseDown={(e) => {
              if (disabled || e.target === inputRef.current) return;
              if ((e.target as HTMLElement).closest("button, input")) return;
              e.preventDefault();
              inputRef.current?.focus();
            }}
            className={cn(
              "group/tags relative flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-md border border-border-control bg-card py-1 ps-1.5 pe-1.5 text-sm inset-shadow-xs",
              "transition-[border-color,box-shadow] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
              "hover:border-muted-foreground",
              "has-[input:focus-visible]:border-muted-foreground has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring has-[input:focus-visible]:ring-offset-2 has-[input:focus-visible]:ring-offset-background",
              anyInvalid &&
                "border-danger ring-[3px] ring-danger/20 hover:border-danger has-[input:focus-visible]:ring-danger/40",
              disabled &&
                "pointer-events-none cursor-not-allowed bg-muted opacity-50 inset-shadow-none",
              className,
            )}
          >
            <ul aria-label="Tags" className="contents">
              <AnimatePresence initial={false} mode="popLayout">
                {tags.map((tag, i) => {
                  const error = errors[i];
                  const isSelected = selected === i;
                  const isShaking = shake?.tag === tag;
                  if (editing?.index === i) {
                    return (
                      <motion.li key={`${tag}-edit`} layout className="flex">
                        <input
                          autoFocus
                          aria-label={`Edit ${tag}`}
                          value={editing.text}
                          size={Math.max(4, editing.text.length)}
                          onChange={(e) => setEditing({ index: i, text: e.target.value })}
                          onBlur={() => finishEdit(true)}
                          onKeyDown={(e) => {
                            if (e.key === "Enter") {
                              e.preventDefault();
                              finishEdit(true);
                            } else if (e.key === "Escape") {
                              e.preventDefault();
                              finishEdit(false);
                            }
                          }}
                          className="h-6 rounded-full bg-card px-2 text-xs font-medium text-foreground ring-2 ring-ring outline-none"
                        />
                      </motion.li>
                    );
                  }
                  return (
                    <motion.li
                      key={tag}
                      layout
                      data-slot="tag"
                      data-selected={isSelected || undefined}
                      data-invalid={error ? true : undefined}
                      initial={{ opacity: 0, scale: 0.8 }}
                      animate={{ opacity: 1, scale: 1 }}
                      exit={{ opacity: 0, scale: 0.8 }}
                      transition={SPRING}
                      onDoubleClick={() => !disabled && setEditing({ index: i, text: tag })}
                      title={error || "Double-click to edit"}
                      className="flex"
                    >
                      <motion.span
                        key={isShaking ? `shake-${shake.n}` : "still"}
                        initial={false}
                        animate={isShaking ? { x: [0, -5, 5, -3, 3, 0] } : { x: 0 }}
                        transition={{ duration: 0.36 }}
                        className={cn(
                          "inline-flex h-6 max-w-48 items-center gap-1 rounded-full ps-2 pe-0.5 text-xs font-medium select-none",
                          "transition-[box-shadow,background-color,color] duration-(--motion-fast)",
                          error
                            ? "bg-danger/10 text-danger-strong ring-1 ring-danger/40"
                            : "bg-card text-foreground shadow-raised-xs ring-1 inset-shadow-highlight ring-hairline",
                          isShaking && !error && "ring-warning",
                          isSelected && "ring-2 ring-ring",
                        )}
                      >
                        {error && (
                          <svg
                            viewBox="0 0 16 16"
                            fill="none"
                            stroke="currentColor"
                            strokeWidth={1.75}
                            strokeLinecap="round"
                            aria-hidden
                            className="size-3 shrink-0"
                          >
                            <circle cx="8" cy="8" r="6" />
                            <path d="M8 5v3.5M8 11h.01" />
                          </svg>
                        )}
                        <span className="truncate">{tag}</span>
                        {error && <span className="sr-only">, invalid: {error}</span>}
                        <button
                          type="button"
                          tabIndex={-1}
                          aria-label={`Remove ${tag}`}
                          disabled={disabled}
                          onClick={() => removeAt(i)}
                          className="grid size-5 cursor-pointer place-items-center rounded-full opacity-60 transition-[opacity,background-color] duration-(--motion-fast) hover:bg-muted hover:opacity-100"
                        >
                          <svg
                            viewBox="0 0 16 16"
                            fill="none"
                            stroke="currentColor"
                            strokeWidth={1.5}
                            strokeLinecap="round"
                            aria-hidden
                            className="size-3"
                          >
                            <path d="m4.5 4.5 7 7m0-7-7 7" />
                          </svg>
                        </button>
                      </motion.span>
                    </motion.li>
                  );
                })}
              </AnimatePresence>
            </ul>
    
            <input
              ref={inputRef}
              id={inputId}
              type="text"
              role="combobox"
              aria-autocomplete="list"
              aria-expanded={open}
              aria-controls={listId}
              aria-activedescendant={open && activeIndex >= 0 ? `${listId}-${activeIndex}` : undefined}
              aria-invalid={anyInvalid || undefined}
              aria-label={props["aria-label"]}
              aria-labelledby={props["aria-labelledby"]}
              aria-describedby={props["aria-describedby"]}
              autoComplete="off"
              enterKeyHint="enter"
              disabled={disabled}
              readOnly={full}
              placeholder={full ? "Limit reached" : tags.length ? "" : placeholder}
              value={query}
              onChange={(e) => {
                setQuery(e.target.value);
                setFocused(true);
                setSelected(null);
                setActive(-1);
                setShake(null);
              }}
              onKeyDown={onKeyDown}
              onPaste={(e) => {
                const text = e.clipboardData.getData("text");
                if (!splitter.test(text)) return;
                e.preventDefault();
                add(text.split(splitter));
              }}
              onFocus={() => setFocused(true)}
              onBlur={() => {
                setFocused(false);
                setSelected(null);
              }}
              className="h-7 min-w-24 flex-1 bg-transparent px-1.5 text-base text-foreground outline-none placeholder:text-muted-foreground read-only:cursor-default md:text-sm"
            />
    
            {max != null && (
              <span
                data-slot="tag-input-count"
                className={cn(
                  "ms-auto shrink-0 px-1 font-mono text-[11px] tabular-nums",
                  full ? "text-accent-strong" : "text-muted-foreground",
                )}
              >
                {tags.length}/{max}
              </span>
            )}
    
            {open && (
              <ul
                id={listId}
                role="listbox"
                aria-label="Suggestions"
                className={cn(
                  "absolute inset-x-0 top-full z-50 mt-1.5 max-h-60 overflow-y-auto rounded-lg bg-card p-1 text-card-foreground",
                  "shadow-raised-md ring-1 inset-shadow-highlight ring-hairline",
                  "motion-safe:animate-[popover-in_var(--motion-base)_var(--motion-enter)]",
                )}
              >
                {options.map((o, i) => (
                  <li
                    key={o}
                    id={`${listId}-${i}`}
                    role="option"
                    aria-selected={i === activeIndex}
                    onMouseDown={(e) => e.preventDefault()}
                    onMouseMove={() => setActive(i)}
                    onClick={() => {
                      add([o]);
                      setQuery("");
                      setActive(-1);
                    }}
                    className={cn(
                      "flex min-h-8 cursor-pointer items-center gap-2 rounded-md px-2 text-sm",
                      i === activeIndex && "bg-muted",
                    )}
                  >
                    <svg
                      viewBox="0 0 16 16"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={1.5}
                      strokeLinecap="round"
                      aria-hidden
                      className="size-3.5 text-muted-foreground"
                    >
                      <path d="M8 3.5v9M3.5 8h9" />
                    </svg>
                    {o}
                  </li>
                ))}
              </ul>
            )}
    
            <span aria-live="polite" className="sr-only">
              {announcement}
            </span>
            {name && tags.map((t) => <input key={t} type="hidden" name={name} value={t} />)}
          </div>
        </MotionConfig>
      );
    }
    
    Pebble

    Small details.
    Better interfaces.

    React components to explore, make your own, and build on.

    Library

    • Components
    • Installation
    • Pricing

    Resources

    • For agents
    • About
    • Support

    Legal

    • License
    • Terms
    • Privacy
    • Final sale

    Built by Pebble

    A product of Praxys Solutions LLC ↗

    Pebble Pro is live

    $49 one time, lifetime updates ↗