Forms
Tag Input
Chips you add with Enter, remove with backspace, and paste in as a comma-separated list.
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/tag-input.json'use client';
import { useId, useRef, useState } from 'react';
const css = `
.tags { width: min(100%, 400px); }
.tags__label {
display: block;
margin-bottom: 8px;
font: 500 12px/1 ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0.09em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.tags__box {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
cursor: text;
transition: border-color 160ms ease;
}
/* :focus-within, so the box lights up when the inner field takes focus — the
border belongs to the box but the focus belongs to the input. */
.tags__box:focus-within { border-color: #ffca7a; }
.tags__list {
display: contents;
margin: 0;
padding: 0;
list-style: none;
}
.tags__tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 4px 3px 9px;
border-radius: 999px;
background: rgba(255, 202, 122, 0.16);
color: #ffca7a;
font: 500 12.5px/1.5 ui-sans-serif, system-ui, sans-serif;
animation: tag-in 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.tags__remove {
display: grid;
place-items: center;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(255, 202, 122, 0.2);
color: inherit;
font: 600 11px/1 ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
}
.tags__remove:hover { background: rgba(255, 202, 122, 0.36); }
.tags__remove:focus-visible { outline: 2px solid #ffca7a; outline-offset: 2px; }
.tags__field {
flex: 1;
min-width: 110px;
border: 0;
background: none;
color: #f3f0e9;
font: 400 13.5px/1.6 ui-sans-serif, system-ui, sans-serif;
outline: none;
}
.tags__field::placeholder { color: rgba(255, 255, 255, 0.3); }
.tags__help {
margin: 8px 0 0;
font: 400 11.5px/1.4 ui-sans-serif, system-ui, sans-serif;
color: rgba(255, 255, 255, 0.34);
}
.tags__live {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@keyframes tag-in {
from { opacity: 0; transform: scale(0.86); }
to { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.tags__tag { animation: none; }
}
`;
export function TagInput({
defaultTags = ['glass', 'hover'],
onChange,
}: {
defaultTags?: string[];
onChange?: (tags: string[]) => void;
}) {
const [tags, setTags] = useState(defaultTags);
const [draft, setDraft] = useState('');
const [announcement, setAnnouncement] = useState('');
const fieldRef = useRef<HTMLInputElement>(null);
const id = useId();
function commit(next: string[], message: string) {
setTags(next);
setAnnouncement(message);
onChange?.(next);
}
function add(raw: string, from = tags) {
const value = raw.trim().replace(/^#/, '').toLowerCase();
if (!value) return from;
if (from.includes(value)) {
setAnnouncement(`${value} is already added`);
return from;
}
return [...from, value];
}
return (
<>
<style>{css}</style>
<div className="tags">
<label className="tags__label" htmlFor={`${id}-field`}>
Tags
</label>
{/* The box looks like the input; the real field is borderless inside
it, so clicking anywhere in the box focuses it. */}
<div
className="tags__box"
onClick={e => {
if (e.target === e.currentTarget) fieldRef.current?.focus();
}}
>
<ul className="tags__list">
{tags.map((tag, i) => (
<li className="tags__tag" key={tag}>
{tag}
<button
type="button"
className="tags__remove"
aria-label={`Remove ${tag}`}
onClick={() => {
commit(
tags.filter((_, n) => n !== i),
`${tag} removed, ${tags.length - 1} tags`,
);
fieldRef.current?.focus();
}}
>
×
</button>
</li>
))}
</ul>
<input
ref={fieldRef}
className="tags__field"
id={`${id}-field`}
type="text"
placeholder="Add a tag…"
aria-describedby={`${id}-help`}
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const next = add(draft);
if (next !== tags) commit(next, `${draft.trim()} added, ${next.length} tags`);
setDraft('');
}
// Only on an empty field, so backspace still edits text normally.
if (e.key === 'Backspace' && !draft && tags.length) {
e.preventDefault();
const last = tags[tags.length - 1];
commit(tags.slice(0, -1), `${last} removed, ${tags.length - 1} tags`);
}
}}
onPaste={e => {
// A comma or newline separated list arrives as many tags at once.
const text = e.clipboardData.getData('text');
if (!/[,\n]/.test(text)) return;
e.preventDefault();
let next = tags;
for (const part of text.split(/[,\n]/)) next = add(part, next);
commit(next, `${next.length} tags`);
setDraft('');
}}
/>
</div>
<p className="tags__help" id={`${id}-help`}>
Enter or comma to add · Backspace to remove the last
</p>
<p className="tags__live" role="status" aria-live="polite">
{announcement}
</p>
</div>
</>
);
}