Tags Input
A shadcn-style tags input component built with Ark UI primitives.
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputBasicDemo = () => (
<TagsInput className="mx-auto w-full max-w-sm">
<TagsInputScaffold />
</TagsInput>
);
export default TagsInputBasicDemo;
Installation
npx shadcn@latest add @ark-cn/tags-inputInstall the dependency required by this primitive:
npm install @ark-ui/react lucide-reactCopy the component source into your app:
TSXcomponents/ui/tags-input.tsx
"use client";
import {
TagsInput as TagsInputPrimitive,
useTagsInput,
useTagsInputContext,
} from "@ark-ui/react/tags-input";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export type TagsInputProps = TagsInputPrimitive.RootProps;
export const TagsInput = ({
className,
children,
...props
}: TagsInputProps) => (
<TagsInputPrimitive.Root
className={cn("flex w-full flex-col gap-1.5", className)}
data-slot="tags-input"
{...props}
>
{children}
<TagsInputPrimitive.HiddenInput />
</TagsInputPrimitive.Root>
);
export type TagsInputRootProviderProps = TagsInputPrimitive.RootProviderProps;
export const TagsInputRootProvider = ({
className,
children,
...props
}: TagsInputRootProviderProps) => (
<TagsInputPrimitive.RootProvider
className={cn("flex w-full flex-col gap-1.5", className)}
data-slot="tags-input-root-provider"
{...props}
>
{children}
<TagsInputPrimitive.HiddenInput />
</TagsInputPrimitive.RootProvider>
);
export const TagsInputLabel = ({
className,
...props
}: TagsInputPrimitive.LabelProps) => (
<TagsInputPrimitive.Label
className={cn(
"text-sm font-medium text-foreground data-disabled:opacity-64",
className,
)}
data-slot="tags-input-label"
{...props}
/>
);
export const TagsInputControl = ({
className,
...props
}: TagsInputPrimitive.ControlProps) => (
<TagsInputPrimitive.Control
className={cn(
"relative inline-flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-input bg-background p-[calc(--spacing(1)-1px)] text-base shadow-xs/5 outline-none ring-ring/24 transition-shadow focus-within:border-ring focus-within:ring-[3px] has-disabled:pointer-events-none has-disabled:opacity-64 has-data-invalid:border-destructive/40 focus-within:has-data-invalid:border-destructive/70 focus-within:has-data-invalid:ring-destructive/16 sm:min-h-8 sm:text-sm",
className,
)}
data-slot="tags-input-control"
{...props}
/>
);
export const TagsInputItem = ({
className,
...props
}: TagsInputPrimitive.ItemProps) => (
<TagsInputPrimitive.Item
className={cn(
"inline-flex max-w-full items-center outline-none",
className,
)}
data-slot="tags-input-item"
{...props}
/>
);
export const TagsInputItemPreview = ({
className,
...props
}: TagsInputPrimitive.ItemPreviewProps) => (
<TagsInputPrimitive.ItemPreview
className={cn(
"inline-flex h-6 max-w-full items-center gap-1 rounded-md border border-border bg-muted/50 px-1.5 text-xs data-highlighted:border-primary/30 data-highlighted:bg-primary/10",
className,
)}
data-slot="tags-input-item-preview"
{...props}
/>
);
export const TagsInputItemText = ({
className,
...props
}: TagsInputPrimitive.ItemTextProps) => (
<TagsInputPrimitive.ItemText
className={cn("truncate", className)}
data-slot="tags-input-item-text"
{...props}
/>
);
export const TagsInputItemDeleteTrigger = ({
className,
children,
...props
}: TagsInputPrimitive.ItemDeleteTriggerProps) => (
<TagsInputPrimitive.ItemDeleteTrigger
className={cn(
"inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded p-0.5 text-muted-foreground hover:bg-background hover:text-foreground",
className,
)}
data-slot="tags-input-item-delete-trigger"
{...props}
>
{children ?? <XIcon className="size-3" />}
</TagsInputPrimitive.ItemDeleteTrigger>
);
export const TagsInputItemInput = ({
className,
...props
}: TagsInputPrimitive.ItemInputProps) => (
<TagsInputPrimitive.ItemInput
className={cn(
"h-6 min-w-18 rounded-md border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
data-slot="tags-input-item-input"
{...props}
/>
);
export const TagsInputInput = ({
className,
...props
}: TagsInputPrimitive.InputProps) => (
<TagsInputPrimitive.Input
className={cn(
"min-w-18 flex-1 border-0 bg-transparent py-0.5 text-sm outline-none placeholder:text-muted-foreground/72",
className,
)}
data-slot="tags-input-input"
{...props}
/>
);
export const TagsInputClearTrigger = ({
className,
children,
...props
}: TagsInputPrimitive.ClearTriggerProps) => (
<TagsInputPrimitive.ClearTrigger
className={cn(
"inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground",
className,
)}
data-slot="tags-input-clear-trigger"
{...props}
>
{children ?? <XIcon className="size-3.5" />}
</TagsInputPrimitive.ClearTrigger>
);
export const TagsInputContext = TagsInputPrimitive.Context;
export type TagsInputScaffoldProps = {
label?: string;
placeholder?: string;
};
export const TagsInputScaffold = ({
label = "Frameworks",
placeholder = "Add framework",
}: TagsInputScaffoldProps) => (
<TagsInputContext>
{(api) => (
<>
<TagsInputLabel>{label}</TagsInputLabel>
<TagsInputControl>
{api.value.map((value, index) => (
<TagsInputItem
key={`${value}-${index}`}
index={index}
value={value}
>
<TagsInputItemPreview>
<TagsInputItemText>{value}</TagsInputItemText>
<TagsInputItemDeleteTrigger />
</TagsInputItemPreview>
<TagsInputItemInput />
</TagsInputItem>
))}
<TagsInputInput placeholder={placeholder} />
<TagsInputClearTrigger />
</TagsInputControl>
</>
)}
</TagsInputContext>
);
export { useTagsInput, useTagsInputContext };
Update import aliases to match your project setup.
Usage
import * as TagsInput from "@/components/ui/tags-input"Read exported parts in src/components/ui/tags-input.tsx and compose the primitive according to the Ark UI pattern for this component.
Examples
Basic
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputBasicDemo = () => (
<TagsInput className="mx-auto w-full max-w-sm">
<TagsInputScaffold />
</TagsInput>
);
export default TagsInputBasicDemo;
Allow Duplicates
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputAllowDuplicatesDemo = () => (
<TagsInput allowDuplicates className="mx-auto w-full max-w-sm">
<TagsInputScaffold />
</TagsInput>
);
export default TagsInputAllowDuplicatesDemo;
Blur Behavior
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputBlurBehaviorDemo = () => (
<TagsInput blurBehavior="add" className="mx-auto w-full max-w-sm">
<TagsInputScaffold />
</TagsInput>
);
export default TagsInputBlurBehaviorDemo;
Controlled Input Value
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputControlledInputValueDemo = () => {
const [inputValue, setInputValue] = useState("");
return (
<div className="mx-auto flex w-full max-w-sm flex-col gap-2">
<div className="flex flex-wrap gap-2">
<Button
onClick={() => setInputValue("React")}
size="sm"
variant="outline"
>
Set "React"
</Button>
<Button onClick={() => setInputValue("")} size="sm" variant="outline">
Clear
</Button>
</div>
<TagsInput
inputValue={inputValue}
onInputValueChange={(d) => setInputValue(d.inputValue)}
className="w-full"
>
<TagsInputScaffold />
</TagsInput>
</div>
);
};
export default TagsInputControlledInputValueDemo;
Controlled
import { useState } from "react";
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputControlledDemo = () => {
const [value, setValue] = useState<string[]>(["React", "Solid"]);
return (
<TagsInput
value={value}
onValueChange={(d) => setValue(d.value)}
className="mx-auto w-full max-w-sm"
>
<TagsInputScaffold />
</TagsInput>
);
};
export default TagsInputControlledDemo;
Delimiter
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TAG_DELIMITER = /[,;\s]/;
const TagsInputDelimiterDemo = () => (
<TagsInput delimiter={TAG_DELIMITER} className="mx-auto w-full max-w-sm">
<TagsInputScaffold placeholder="comma, semicolon or space" />
</TagsInput>
);
export default TagsInputDelimiterDemo;
Disabled
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputDisabledDemo = () => (
<TagsInput
defaultValue={["React", "Solid", "Vue"]}
disabled
className="mx-auto w-full max-w-sm"
>
<TagsInputScaffold />
</TagsInput>
);
export default TagsInputDisabledDemo;
Disabled Editing
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputDisabledEditingDemo = () => (
<TagsInput editable={false} className="mx-auto w-full max-w-sm">
<TagsInputScaffold />
</TagsInput>
);
export default TagsInputDisabledEditingDemo;
Invalid
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputInvalidDemo = () => (
<TagsInput invalid className="mx-auto w-full max-w-sm">
<TagsInputScaffold />
</TagsInput>
);
export default TagsInputInvalidDemo;
Max Tag Length
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputMaxTagLengthDemo = () => (
<TagsInput maxLength={10} className="mx-auto w-full max-w-sm">
<TagsInputScaffold label="Frameworks (max 10 chars)" />
</TagsInput>
);
export default TagsInputMaxTagLengthDemo;
Max With Overflow
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputMaxWithOverflowDemo = () => (
<TagsInput allowOverflow max={3} className="mx-auto w-full max-w-sm">
<TagsInputScaffold />
</TagsInput>
);
export default TagsInputMaxWithOverflowDemo;
Nested
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputNestedDemo = () => (
<div className="mx-auto flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border p-3">
<TagsInput defaultValue={["frontend", "ui"]}>
<TagsInputScaffold label="Parent tags" />
</TagsInput>
<div className="ms-3 border-border border-s ps-3">
<TagsInput defaultValue={["react"]}>
<TagsInputScaffold label="Nested tags" />
</TagsInput>
</div>
</div>
);
export default TagsInputNestedDemo;
Paste Behavior
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputPasteBehaviorDemo = () => (
<TagsInput addOnPaste delimiter="," className="mx-auto w-full max-w-sm">
<TagsInputScaffold />
</TagsInput>
);
export default TagsInputPasteBehaviorDemo;
Programmatic Control
import { Button } from "@/components/ui/button";
import {
TagsInputRootProvider,
TagsInputScaffold,
useTagsInput,
} from "@/components/ui/tags-input";
const TagsInputProgrammaticControlDemo = () => {
const api = useTagsInput();
return (
<div className="mx-auto flex w-full max-w-sm flex-col gap-2">
<div className="flex flex-wrap gap-2">
<Button
onClick={() => api.addValue("React")}
size="sm"
variant="outline"
>
Add React
</Button>
<Button
onClick={() => api.addValue("Solid")}
size="sm"
variant="outline"
>
Add Solid
</Button>
<Button
onClick={() => api.setValue(["Vue", "Svelte"])}
size="sm"
variant="outline"
>
Set Vue + Svelte
</Button>
<Button onClick={() => api.clearValue()} size="sm" variant="outline">
Clear all
</Button>
</div>
<TagsInputRootProvider value={api} className="w-full">
<TagsInputScaffold />
</TagsInputRootProvider>
</div>
);
};
export default TagsInputProgrammaticControlDemo;
Readonly
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputReadonlyDemo = () => (
<TagsInput
defaultValue={["React", "Solid", "Vue"]}
readOnly
className="mx-auto w-full max-w-sm"
>
<TagsInputScaffold />
</TagsInput>
);
export default TagsInputReadonlyDemo;
Root Provider
values: []
import {
TagsInputRootProvider,
TagsInputScaffold,
useTagsInput,
} from "@/components/ui/tags-input";
const TagsInputRootProviderDemo = () => {
const api = useTagsInput();
return (
<div className="mx-auto flex w-full max-w-sm flex-col gap-2">
<TagsInputRootProvider value={api} className="w-full">
<TagsInputScaffold />
</TagsInputRootProvider>
<p className="text-muted-foreground text-xs">
values: {JSON.stringify(api.value)}
</p>
</div>
);
};
export default TagsInputRootProviderDemo;
Sanitize
import { useState } from "react";
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputSanitizeDemo = () => {
const [value, setValue] = useState<string[]>([]);
return (
<TagsInput
value={value}
onValueChange={(details) =>
setValue(details.value.map((item) => item.trim().toLowerCase()))
}
className="mx-auto w-full max-w-sm"
>
<TagsInputScaffold label="Email addresses" placeholder="Add email" />
</TagsInput>
);
};
export default TagsInputSanitizeDemo;
Validation
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const VALID_TAG_PATTERN = /^[a-zA-Z0-9-]+$/;
const TagsInputValidationDemo = () => (
<TagsInput
validate={({ value, inputValue }) =>
Boolean(inputValue?.trim()) &&
!value.includes(inputValue) &&
inputValue.length >= 3 &&
VALID_TAG_PATTERN.test(inputValue)
}
className="mx-auto w-full max-w-sm"
>
<TagsInputScaffold label="Min 3 chars, alphanumeric + hyphen" />
</TagsInput>
);
export default TagsInputValidationDemo;
With Combobox
React
Solid
Vue
Svelte
Angular
Preact
Next.js
Astro
import { useListCollection } from "@ark-ui/react";
import {
Combobox as ComboboxPrimitive,
useCombobox,
} from "@ark-ui/react/combobox";
import { useFilter } from "@ark-ui/react/locale";
import { CheckIcon } from "lucide-react";
import { useId } from "react";
import {
TagsInputClearTrigger,
TagsInputContext,
TagsInputControl,
TagsInputInput,
TagsInputItem,
TagsInputItemDeleteTrigger,
TagsInputItemInput,
TagsInputItemPreview,
TagsInputItemText,
TagsInputLabel,
TagsInputRootProvider,
useTagsInput,
} from "@/components/ui/tags-input";
const TagsInputWithComboboxDemo = () => {
const uid = useId();
const { contains } = useFilter({ sensitivity: "base" });
const { collection, filter } = useListCollection({
initialItems: [
"React",
"Solid",
"Vue",
"Svelte",
"Angular",
"Preact",
"Next.js",
"Astro",
],
filter: contains,
});
const tagsApi = useTagsInput({
ids: { input: `tags-input-${uid}`, control: `tags-control-${uid}` },
});
const comboboxApi = useCombobox({
ids: { input: `tags-input-${uid}`, control: `tags-control-${uid}` },
collection,
value: [],
allowCustomValue: true,
selectionBehavior: "clear",
onInputValueChange: (details) => filter(details.inputValue),
onValueChange: (details) => {
if (details.value[0]) {
tagsApi.addValue(details.value[0]);
}
},
});
return (
<div className="mx-auto w-full max-w-sm">
<ComboboxPrimitive.RootProvider value={comboboxApi}>
<TagsInputRootProvider value={tagsApi} className="w-full">
<TagsInputLabel>Frameworks</TagsInputLabel>
<TagsInputContext>
{(api) => (
<TagsInputControl>
{api.value.map((value, index) => (
<TagsInputItem
key={`${value}-${index}`}
index={index}
value={value}
>
<TagsInputItemPreview>
<TagsInputItemText>{value}</TagsInputItemText>
<TagsInputItemDeleteTrigger />
</TagsInputItemPreview>
<TagsInputItemInput />
</TagsInputItem>
))}
<ComboboxPrimitive.Input asChild>
<TagsInputInput placeholder="Search framework" />
</ComboboxPrimitive.Input>
<TagsInputClearTrigger />
</TagsInputControl>
)}
</TagsInputContext>
</TagsInputRootProvider>
<ComboboxPrimitive.Positioner>
<ComboboxPrimitive.Content className="z-50 mt-1 max-h-56 min-w-(--reference-width) overflow-auto rounded-md border border-border bg-popover p-1 shadow-md">
<ComboboxPrimitive.Empty className="px-2 py-1.5 text-muted-foreground text-sm">
No frameworks found
</ComboboxPrimitive.Empty>
{collection.items.map((item) => (
<ComboboxPrimitive.Item
key={item}
item={item}
className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 text-sm data-highlighted:bg-accent"
>
<ComboboxPrimitive.ItemText>{item}</ComboboxPrimitive.ItemText>
<ComboboxPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
))}
</ComboboxPrimitive.Content>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.RootProvider>
</div>
);
};
export default TagsInputWithComboboxDemo;
With Field
Additional info
import { Field, FieldDescription, FieldError } from "@/components/ui/field";
import { TagsInput, TagsInputScaffold } from "@/components/ui/tags-input";
const TagsInputWithFieldDemo = () => (
<Field className="mx-auto w-full max-w-sm">
<TagsInput>
<TagsInputScaffold />
</TagsInput>
<FieldDescription>Additional info</FieldDescription>
<FieldError>Error info</FieldError>
</Field>
);
export default TagsInputWithFieldDemo;
API reference
This component mirrors the upstream Ark UI primitive.
See the ARK UI documentation for the full API.
Accessibility
See the Ark UI documentation for clarification.