Tag Input
Chip input with paste-to-split, dedupe, validation hook and max tags.
Examples
Installation
npx shadcn@latest add https://hirael.com/r/tag-input.jsonUsage
import {
TagInput,
TagInputContainer,
TagInputTag,
TagInputTags,
TagInputField,
TagInputError,
} from "@/components/tag-input"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<TagInput />
+ native element props| Prop | Type | Default |
|---|---|---|
value | string[] | none |
defaultValue | string[] | none |
onValueChange | ((value: string[]) => void) | none |
disabled | boolean | none |
readOnly | boolean | none |
maxTags | number | none |
limitMessageError shown when `maxTags` is reached. | ((max: number) => string) | defaultLimitMessage |
validate | TagValidator | none |
commitKeys | string[] | DEFAULT_COMMIT_KEYS |
splitOn | RegExp | DEFAULT_SPLIT_ON |
children | React.ReactNode | none |
<TagInputContainer />
+ native element propsNo props of its own; forwards everything to the underlying element.
<TagInputTag />
+ native element props| Prop | Type | Default |
|---|---|---|
index* | number | none |
removeLabelAccessible name of the remove button. | ((tag: string) => string) | defaultRemoveLabel |
<TagInputTags />
No configurable props.
<TagInputField />
+ native element propsNo props of its own; forwards everything to the underlying element.
<TagInputError />
+ native element propsNo props of its own; forwards everything to the underlying element.
Component source
'use client';
import * as React from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { composeRefs } from '@/registry/hirael/lib/compose-refs';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { InputGroup } from '@/registry/hirael/bases/radix/ui/input-group';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';
export type TagValidator = (candidate: string, current: string[]) => true | string;
interface Ctx {
value: string[];
draft: string;
setDraft: (next: string) => void;
error: string | null;
setError: (next: string | null) => void;
errorId: string;
add: (candidates: string[]) => boolean;
remove: (index: number) => void;
disabled?: boolean;
readOnly?: boolean;
commitKeys: string[];
splitOn: RegExp;
inputRef: React.RefObject<HTMLInputElement | null>;
}
const DEFAULT_COMMIT_KEYS = ['Enter', ','];
const DEFAULT_SPLIT_ON = /[,\n\t]+/;
const defaultLimitMessage = (max: number) => `Limit ${max} tag${max === 1 ? '' : 's'}.`;
const TagInputContext = React.createContext<Ctx | null>(null);
const useTagInput = () => {
const ctx = React.useContext(TagInputContext);
if (!ctx) {
throw new Error('TagInput compound parts must be used inside <TagInput>');
}
return ctx;
};
export interface TagInputProps extends Omit<React.ComponentProps<'div'>, 'defaultValue'> {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
readOnly?: boolean;
maxTags?: number;
/** Error shown when `maxTags` is reached. */
limitMessage?: (max: number) => string;
validate?: TagValidator;
commitKeys?: string[];
splitOn?: RegExp;
children?: React.ReactNode;
}
const TagInput = ({
value: valueProp,
defaultValue,
onValueChange,
disabled,
readOnly,
maxTags,
limitMessage = defaultLimitMessage,
validate,
commitKeys = DEFAULT_COMMIT_KEYS,
splitOn = DEFAULT_SPLIT_ON,
className,
children,
...props
}: TagInputProps) => {
const [value, setValue] = useControllableState<string[]>({
prop: valueProp,
defaultProp: defaultValue ?? [],
onChange: onValueChange,
});
const [draft, setDraft] = React.useState('');
const [error, setError] = React.useState<string | null>(null);
const errorId = React.useId();
const inputRef = React.useRef<HTMLInputElement | null>(null);
const add = React.useCallback(
(candidates: string[]): boolean => {
if (disabled || readOnly) return false;
const next = [...value];
let added = false;
let duplicate = false;
let failure: string | null = null;
for (const tag of candidates.map((c) => c.trim()).filter(Boolean)) {
if (maxTags !== undefined && next.length >= maxTags) {
failure = limitMessage(maxTags);
break;
}
if (next.some((t) => t.toLowerCase() === tag.toLowerCase())) {
duplicate = true;
continue;
}
const result = validate?.(tag, next) ?? true;
if (result !== true) {
failure = result;
continue;
}
next.push(tag);
added = true;
}
if (added) setValue(next);
if (added || failure) setError(failure);
// A duplicate is already there, so it counts as handled and the draft clears.
return added || (duplicate && !failure);
},
[disabled, readOnly, value, maxTags, limitMessage, validate, setValue],
);
const remove = React.useCallback(
(index: number) => {
if (disabled || readOnly) return;
setValue(value.filter((_, i) => i !== index));
setError(null);
},
[disabled, readOnly, value, setValue],
);
const ctx = React.useMemo<Ctx>(
() => ({
value,
draft,
setDraft,
error,
setError,
errorId,
add,
remove,
disabled,
readOnly,
commitKeys,
splitOn,
inputRef,
}),
[value, draft, error, errorId, add, remove, disabled, readOnly, commitKeys, splitOn],
);
return (
<TagInputContext.Provider value={ctx}>
<div data-slot="tag-input" className={cn('flex flex-col gap-2', className)} {...props}>
{children}
</div>
</TagInputContext.Provider>
);
};
type TagInputContainerProps = React.ComponentProps<'div'>;
const TagInputContainer = ({ className, children, onMouseDown, ...props }: TagInputContainerProps) => {
const ctx = useTagInput();
return (
<InputGroup
data-slot="tag-input-container"
data-disabled={ctx.disabled || undefined}
data-readonly={ctx.readOnly || undefined}
onMouseDown={(e) => {
onMouseDown?.(e);
if (e.defaultPrevented) return;
if (e.target === e.currentTarget) {
e.preventDefault();
ctx.inputRef.current?.focus();
}
}}
className={cn(
'h-auto min-h-9 flex-wrap gap-1 px-1.5 py-1 text-sm focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50',
(ctx.disabled || ctx.readOnly) && 'cursor-not-allowed opacity-60',
className,
)}
{...props}
>
{children}
</InputGroup>
);
};
interface TagInputTagProps extends Omit<React.ComponentProps<'span'>, 'children'> {
index: number;
/** Accessible name of the remove button. */
removeLabel?: (tag: string) => string;
}
const defaultRemoveLabel = (tag: string) => `Remove ${tag}`;
const TagInputTag = ({ index, removeLabel = defaultRemoveLabel, className, ...props }: TagInputTagProps) => {
const ctx = useTagInput();
const tag = ctx.value[index];
if (tag === undefined) return null;
return (
<Badge variant="secondary" data-slot="tag-input-tag" className={cn('gap-1 pe-1 font-normal', className)} {...props}>
<span className="min-w-0 truncate">{tag}</span>
{!(ctx.disabled || ctx.readOnly) && (
<button
type="button"
tabIndex={-1}
aria-label={removeLabel(tag)}
onClick={() => ctx.remove(index)}
className="inline-flex size-3.5 items-center justify-center rounded-[2px] text-secondary-foreground/70 transition-colors hover:bg-secondary-foreground/20 hover:text-secondary-foreground"
>
<X className="size-2.5" />
</button>
)}
</Badge>
);
};
const TagInputTags = () => {
const ctx = useTagInput();
return (
<>
{ctx.value.map((tag, i) => (
<TagInputTag key={`${tag}-${i}`} index={i} />
))}
</>
);
};
type TagInputFieldProps = Omit<React.ComponentProps<'input'>, 'value' | 'defaultValue' | 'onChange' | 'type'>;
const TagInputField = ({
placeholder = 'Add tag…',
className,
onKeyDown,
onPaste,
onBlur,
ref,
...props
}: TagInputFieldProps) => {
const ctx = useTagInput();
const { inputRef } = ctx;
const composedRef = React.useMemo(() => composeRefs(inputRef, ref), [inputRef, ref]);
const commitDraft = () => {
if (ctx.draft.trim() && ctx.add([ctx.draft])) ctx.setDraft('');
};
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
// Enter or comma confirming an IME composition must not commit the half-composed draft.
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (ctx.commitKeys.includes(e.key)) {
// Enter on an empty draft still submits the surrounding form.
if (ctx.draft.trim() || e.key !== 'Enter') e.preventDefault();
commitDraft();
} else if (e.key === 'Backspace' && !ctx.draft && ctx.value.length > 0) {
e.preventDefault();
ctx.remove(ctx.value.length - 1);
}
};
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
onPaste?.(e);
const text = e.clipboardData.getData('text');
if (e.defaultPrevented || !ctx.splitOn.test(text)) return;
e.preventDefault();
ctx.add(text.split(ctx.splitOn));
};
return (
<input
ref={composedRef}
value={ctx.draft}
onChange={(e) => {
ctx.setDraft(e.target.value);
if (ctx.error) ctx.setError(null);
}}
onKeyDown={handleKey}
onPaste={handlePaste}
onBlur={(e) => {
onBlur?.(e);
commitDraft();
}}
placeholder={ctx.value.length === 0 ? placeholder : undefined}
disabled={ctx.disabled}
readOnly={ctx.readOnly}
aria-invalid={ctx.error ? true : undefined}
aria-describedby={ctx.error ? ctx.errorId : undefined}
data-slot="tag-input-field"
className={cn(
'min-w-[6rem] flex-1 bg-transparent px-1.5 py-0.5 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',
className,
)}
{...props}
/>
);
};
const TagInputError = ({ className, ...props }: React.ComponentProps<'p'>) => {
const ctx = useTagInput();
if (!ctx.error) return null;
return (
<p
role="alert"
id={ctx.errorId}
data-slot="tag-input-error"
className={cn('text-sm text-destructive', className)}
{...props}
>
{ctx.error}
</p>
);
};
export { TagInput, TagInputContainer, TagInputTag, TagInputTags, TagInputField, TagInputError };
Dependencies
shadcn registry
badgecompose-refsinput-groupuse-controllable-state
npm
lucide-react