Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/tags-input.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook3 stories available:
Code
"use client";
import * as React from "react";
import { X } from "lucide-react";
import { cn } from "../../lib/utils";
function normalizeTag(tag: string) {
return tag.trim();
}
function getNormalizedTags(tags: string[]) {
return tags
.map(normalizeTag)
.filter(
(tag, index, values) => tag.length > 0 && values.indexOf(tag) === index,
);
}
function shouldAddTagFromKey(key: string) {
return key === "Enter" || key === ",";
}
type TagsInputStateOptions = {
defaultValue: string[];
onValueChange?: (value: string[]) => void;
value?: string[];
};
type TagsInputHandlersOptions = {
disabled: boolean;
inputValue: string;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
tags: string[];
updateTags: (nextTags: string[]) => void;
};
type TagListProps = {
disabled: boolean;
onRemove: (tag: string) => void;
tags: string[];
};
function useTagsInputState({
defaultValue,
onValueChange,
value,
}: TagsInputStateOptions) {
const [uncontrolledValue, setUncontrolledValue] = React.useState(() =>
getNormalizedTags(defaultValue),
);
const isControlled = value !== undefined;
const tags = React.useMemo(
() => getNormalizedTags(value ?? uncontrolledValue),
[uncontrolledValue, value],
);
const updateTags = React.useCallback(
(nextTags: string[]) => {
const normalizedTags = getNormalizedTags(nextTags);
if (!isControlled) {
setUncontrolledValue(normalizedTags);
}
onValueChange?.(normalizedTags);
},
[isControlled, onValueChange],
);
return { tags, updateTags };
}
function useTagsInputHandlers({
disabled,
inputValue,
onKeyDown,
setInputValue,
tags,
updateTags,
}: TagsInputHandlersOptions) {
const removeTag = React.useCallback(
(tagToRemove: string) => {
updateTags(tags.filter((tag) => tag !== tagToRemove));
},
[tags, updateTags],
);
const commitTag = React.useCallback(() => {
const nextTag = normalizeTag(inputValue);
if (nextTag.length === 0 || tags.includes(nextTag)) {
setInputValue("");
return;
}
updateTags([...tags, nextTag]);
setInputValue("");
}, [inputValue, setInputValue, tags, updateTags]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || disabled) {
return;
}
if (shouldAddTagFromKey(event.key)) {
event.preventDefault();
commitTag();
return;
}
if (
(event.key === "Backspace" || event.key === "Delete") &&
inputValue.length === 0
) {
const lastTag = tags.at(-1);
if (lastTag) {
event.preventDefault();
removeTag(lastTag);
}
}
},
[commitTag, disabled, inputValue.length, onKeyDown, removeTag, tags],
);
return { handleKeyDown, removeTag };
}
function TagList({ disabled, onRemove, tags }: TagListProps) {
return (
<ul className="flex flex-wrap items-center gap-2">
{tags.map((tag) => (
<li
className="flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-sm text-foreground"
key={tag}
>
<span>{tag}</span>
<button
aria-label={`Remove ${tag}`}
className="rounded-sm text-muted-foreground outline-none ring-offset-background transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onRemove(tag);
}}
type="button"
>
<X className="size-3.5" />
</button>
</li>
))}
</ul>
);
}
export type TagsInputProps = Omit<
React.ComponentPropsWithoutRef<"input">,
"defaultValue" | "onChange" | "value"
> & {
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
value?: string[];
};
const TagsInput = React.forwardRef<HTMLInputElement, TagsInputProps>(
(
{
className,
defaultValue = [],
disabled = false,
onBlur,
onKeyDown,
onValueChange,
placeholder = "Add a tag",
value,
...props
},
ref,
) => {
const [inputValue, setInputValue] = React.useState("");
const { tags, updateTags } = useTagsInputState({
defaultValue,
onValueChange,
value,
});
const { handleKeyDown, removeTag } = useTagsInputHandlers({
disabled,
inputValue,
onKeyDown,
setInputValue,
tags,
updateTags,
});
return (
<div
aria-disabled={disabled || undefined}
className={cn(
"flex min-h-10 w-full flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
disabled && "cursor-not-allowed opacity-50",
className,
)}
data-disabled={disabled ? "true" : undefined}
role="group"
>
<TagList disabled={disabled} onRemove={removeTag} tags={tags} />
<input
{...props}
className="min-w-[8rem] flex-1 border-0 bg-transparent p-0 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed"
disabled={disabled}
onBlur={onBlur}
onChange={(event) => {
setInputValue(event.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
ref={ref}
type="text"
value={inputValue}
/>
</div>
);
},
);
TagsInput.displayName = "TagsInput";
export { TagsInput };
typescript