Chip & TagInput
Compact removable tokens, and a tag input that adds chips on Enter/comma.
import { Chip, TagInput }from "@/components/ui"
Specification
Displays a compact, optionally-removable token; TagInput lets users build a list of chips by typing and pressing Enter or comma.
Props (11)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | ReactNode | — | The chip's content/label (Chip). |
| onRemove | () => void | — | When provided, shows a remove (×) button and is called on click (Chip). |
| className | string | — | Extra classes merged onto the chip (Chip). |
| id | string | — | Explicit element id, wired to the label (TagInput). |
| label | string | — | Visible field label shown above the input (TagInput). |
| hint | string | — | Helper text shown below the field (TagInput). |
| error | string | — | Error message; renders the field in the error state (TagInput). |
| value* | string[] | — | The current list of tags (TagInput). |
| onChange* | (tags: string[]) => void | — | Called with the updated tag list when tags are added or removed (TagInput). |
| placeholder | string | "Add tag…" | Placeholder shown when there are no tags (TagInput). |
| disabled | boolean | — | Disables adding and removing tags (TagInput). |
Chips + tag input
Read-onlyRemovable
designfrontend
Enter or comma to add.
Full source
The complete component, read from
src/components/ui/chip.tsx.chip.tsx
"use client";
import { useState, type ReactNode, type KeyboardEvent } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Field, controlBase, controlClasses } from "./field";
interface ChipProps {
children: ReactNode;
onRemove?: () => void;
className?: string;
}
export function Chip({ children, onRemove, className }: ChipProps) {
return (
<span className={cn("inline-flex items-center gap-1 rounded-md bg-accent-light px-2 py-0.5 text-xs font-medium text-accent", className)}>
{children}
{onRemove && (
<button
type="button"
onClick={onRemove}
aria-label="Remove"
className="rounded-full hover:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<X size={11} aria-hidden />
</button>
)}
</span>
);
}
interface TagInputProps {
id?: string;
label?: string;
hint?: string;
error?: string;
value: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
disabled?: boolean;
}
export function TagInput({ id, label, hint, error, value, onChange, placeholder = "Add tag…", disabled }: TagInputProps) {
const [draft, setDraft] = useState("");
const add = (raw: string) => {
const t = raw.trim().replace(/,$/, "").trim();
if (t && !value.includes(t)) onChange([...value, t]);
setDraft("");
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if ((e.key === "Enter" || e.key === ",") && draft.trim()) {
e.preventDefault();
add(draft);
} else if (e.key === "Backspace" && !draft && value.length) {
onChange(value.slice(0, -1));
}
};
return (
<Field label={label} hint={hint} error={error} htmlFor={id}>
<div
className={cn(
controlBase,
controlClasses(error, disabled),
"flex min-h-10 flex-wrap items-center gap-1 px-2 py-1",
!disabled && "cursor-text"
)}
>
{value.map((t) => (
<Chip key={t} onRemove={disabled ? undefined : () => onChange(value.filter((x) => x !== t))}>
{t}
</Chip>
))}
<input
id={id}
value={draft}
disabled={disabled}
placeholder={value.length === 0 ? placeholder : ""}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => draft.trim() && add(draft)}
className="min-w-24 flex-1 bg-transparent px-1 py-0.5 text-sm text-text outline-none"
/>
</div>
</Field>
);
}Dependencies
To use Chip & TagInput you also need the following. Copy these into your project alongside it.
Recommended folder structure
project structure
src/components/ui/ ├─ chip.tsx └─ field.tsx src/lib/ └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/components/ui/field.tsx
field.tsx
"use client";
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface FieldCounter {
text: string;
warn?: boolean;
}
interface FieldProps {
label?: string;
hint?: string;
error?: string;
required?: boolean;
htmlFor?: string;
counter?: FieldCounter;
messageId?: string;
children: ReactNode;
}
export function Field({
label,
hint,
error,
required,
htmlFor,
counter,
messageId,
children,
}: FieldProps) {
const message = error || hint;
return (
<div className="flex w-full flex-col gap-1.5">
{label && (
<label
htmlFor={htmlFor}
className="flex items-center gap-1 text-[13px] font-medium text-text"
>
{label}
{required && (
<span className="text-error" aria-hidden>
*
</span>
)}
</label>
)}
{children}
<div className="flex items-start justify-between gap-3">
<p
id={messageId}
className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
>
{message || "\u00A0"}
</p>
{counter && (
<span
className={cn(
"shrink-0 text-xs tabular-nums",
counter.warn ? "text-error" : "text-text-tertiary"
)}
>
{counter.text}
</span>
)}
</div>
</div>
);
}
export const controlBase = cn(
"w-full rounded-lg border text-sm bg-surface text-text transition-colors",
"placeholder:text-text-tertiary",
"focus:outline-none focus-visible:outline-none focus-visible:border-accent",
"disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
"read-only:bg-bg-secondary"
);
export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
return cn(
error ? "border-error focus-visible:border-error" : "border-border",
readOnly && "read-only:bg-bg-secondary",
disabled && "text-text-tertiary"
);
}src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}