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“malik@lantern” is not an email. Double-click it to fix.
TopicsUp 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.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| value / defaultValue | string[] | — | Controlled or initial tags. |
| onValueChange | (tags: string[]) => void | — | Called after tags are added, edited or removed. |
| placeholder | string | "Add a tag…" | Shown while there are no tags. |
| max | number | — | 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. |
| suggestions | string[] | [] | Dropdown of matching unused entries while typing. |
| transform | (tag: string) => string | trim | Normalise a tag before adding (e.g. lower-case or slugify). |
| separators | string[] | [","] | Keys that commit a tag besides Enter; pasted text also splits on them, new lines and semicolons. |
| disabled | boolean | false | Dims the field and blocks edits. |
| invalid | boolean | false | Field-level invalid state. |
| name | string | — | 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>
);
}
