Tag Field
A tag input: Enter or comma adds a chip, Backspace arms then removes the last, with a count and suggestions.
npx shadcn@latest add https://sundaeui.dev/r/tag-field.json'use client'
import { useId, useRef, useState, type KeyboardEvent } from 'react'
import './tag-field.css'
export interface TagFieldProps {
label: string
value?: string[]
defaultValue?: string[]
onValueChange?: (tags: string[]) => void
/** Text being typed, if you want to control it. */
draft?: string
onDraftChange?: (text: string) => void
suggestions?: string[]
max?: number
placeholder?: string
chip?: 'pill' | 'square'
fill?: 'dark' | 'light'
className?: string
}
/** A tag input: type and press Enter or comma to add, Backspace to take the last one back. */
export function TagField({ label, value, defaultValue = [], onValueChange, draft, onDraftChange, suggestions = [], max = 6, placeholder = 'Add a tag', chip = 'pill', fill = 'dark', className }: TagFieldProps) {
const [own, setOwn] = useState(defaultValue)
const [ownDraft, setOwnDraft] = useState('')
const [armed, setArmed] = useState(false)
const [msg, setMsg] = useState('')
const input = useRef<HTMLInputElement>(null)
const id = useId()
const tags = value ?? own
const text = draft ?? ownDraft
const full = tags.length >= max
const setTags = (next: string[], note: string) => {
if (value === undefined) setOwn(next)
onValueChange?.(next)
setMsg(note)
}
const setText = (t: string) => {
if (draft === undefined) setOwnDraft(t)
onDraftChange?.(t)
}
const add = (raw: string) => {
const t = raw.trim().toLowerCase()
if (!t || full || tags.includes(t)) return setText('')
setTags([...tags, t], `Added ${t}`)
setText('')
}
const remove = (t: string) => {
setTags(
tags.filter((x) => x !== t),
`Removed ${t}`,
)
input.current?.focus()
}
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add(text)
} else if (e.key === 'Backspace' && !text && tags.length) {
e.preventDefault()
if (armed) {
remove(tags[tags.length - 1])
setArmed(false)
} else setArmed(true)
} else setArmed(false)
}
const left = suggestions.filter((s) => !tags.includes(s)).slice(0, 3)
return (
<div className={['sd-tf', className].filter(Boolean).join(' ')} data-fill={fill} data-chip={chip}>
<div className="sd-tf__top">
<label htmlFor={`${id}-in`} className="sd-tf__label">
{label}
</label>
<span className="sd-tf__count" data-full={full || undefined}>
{tags.length}/{max}
</span>
</div>
<div className="sd-tf__box" onClick={() => input.current?.focus()}>
<ul className="sd-tf__tags" aria-label={`${label}, ${tags.length} selected`}>
{tags.map((t, i) => (
<li key={t} className="sd-tf__tag" data-armed={(armed && i === tags.length - 1) || undefined}>
{t}
<button type="button" aria-label={`Remove ${t}`} onClick={() => remove(t)}>
<svg viewBox="0 0 12 12" aria-hidden>
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
</li>
))}
</ul>
<input
ref={input}
id={`${id}-in`}
value={text}
onChange={(e) => setText(e.target.value.replace(',', ''))}
onKeyDown={onKey}
onBlur={() => setArmed(false)}
placeholder={full ? 'Limit reached' : placeholder}
disabled={full && !text}
aria-describedby={`${id}-hint`}
className="sd-tf__input"
/>
</div>
{left.length > 0 && !full && (
<div className="sd-tf__sugg">
<span id={`${id}-hint`}>Try</span>
{left.map((s) => (
<button key={s} type="button" onClick={() => add(s)}>
+ {s}
</button>
))}
</div>
)}
{(!left.length || full) && (
<p id={`${id}-hint`} className="sd-tf__hint">
Enter or comma to add · Backspace twice to remove
</p>
)}
<span className="sd-tf__sr" role="status" aria-live="polite">
{msg}
</span>
</div>
)
}.sd-tf { --bg: #1d1d1d; --fg: #f7f7f7; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.09); --soft: rgb(255 255 255 / 0.07); --chip: rgb(255 255 255 / 0.1); --r: 999px;
width: min(100%, 300px); padding: 16px; border-radius: 22px; background: var(--bg); color: var(--fg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-tf[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.08); --soft: rgb(0 0 0 / 0.04); --chip: rgb(0 0 0 / 0.07); }
.sd-tf[data-chip='square'] { --r: 7px; }
.sd-tf__top { display: flex; justify-content: space-between; align-items: baseline; padding: 0 2px; }
.sd-tf__label { font-size: 13px; font-weight: 500; }
.sd-tf__count { font-size: 12px; color: var(--mute); font-feature-settings: 'tnum'; }
.sd-tf__count[data-full] { color: var(--fg); }
.sd-tf__box { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-height: 48px; margin-top: 8px; padding: 7px; border-radius: 14px; background: var(--soft); box-shadow: inset 0 0 0 1px var(--line); cursor: text; transition: box-shadow 200ms; }
.sd-tf__box:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-tf__tags { display: contents; list-style: none; }
.sd-tf__tag { display: inline-flex; align-items: center; gap: 2px; height: 30px; padding: 0 4px 0 11px; border-radius: var(--r); background: var(--chip); font-size: 13px; font-weight: 500; animation: sd-tf-in 300ms cubic-bezier(0.2, 1.4, 0.4, 1); transition: background-color 200ms, color 200ms; }
.sd-tf__tag[data-armed] { background: var(--fg); color: var(--bg); }
.sd-tf__tag button { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 999px; color: inherit; opacity: 0.6; transition: opacity 150ms, background-color 150ms; }
.sd-tf__tag button:hover { opacity: 1; background: var(--chip); }
.sd-tf__tag svg { width: 10px; height: 10px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; }
.sd-tf__input { flex: 1; min-width: 80px; height: 30px; padding: 0 6px; border: 0; background: transparent; color: var(--fg); font: inherit; font-size: 14px; outline: none; }
.sd-tf__input::placeholder { color: var(--mute); }
.sd-tf__sugg { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; font-size: 12.5px; color: var(--mute); }
.sd-tf__sugg button { height: 26px; padding: 0 10px; border-radius: var(--r); font-size: 12.5px; color: var(--fg); box-shadow: inset 0 0 0 1px var(--line); transition: background-color 150ms; }
.sd-tf__sugg button:hover { background: var(--soft); }
.sd-tf__hint { margin: 10px 2px 0; font-size: 12px; color: var(--mute); }
.sd-tf__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.sd-tf button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
@keyframes sd-tf-in { from { opacity: 0; transform: scale(0.7); } }
@media (prefers-reduced-motion: reduce) { .sd-tf * { animation: none !important; transition: none !important; } }Overview
Typed text becomes a chip on Enter or comma. Duplicates are ignored and a counter shows how close you are to the limit.
Backspace on an empty field first highlights the last chip, then removes it on the second press, so a stray key never deletes a tag. Suggestions sit underneath as one-tap chips.
When to use
- Free-form labels: topics, skills, keywords, email recipients.
- Filters where people type values rather than pick from a fixed list.
When not to use
- A fixed list of options: use a multi-select or checkboxes.
- One value: use a plain input.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/tag-field.jsonnpx shadcn@latest add https://sundaeui.dev/r/tag-field.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/tag-field.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/tag-field.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy tag-field.tsx and tag-field.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { TagField } from '@/components/sundae/tag-field'
<TagField label="Topics" defaultValue={['react']} suggestions={['css', 'a11y']} max={5} />Props
| Prop | Type | Default |
|---|---|---|
label*Visible label for the input. | string | — |
value / defaultValueControlled or starting tags. | string[] | [] |
onValueChangeCalled when a tag is added or removed. | (tags: string[]) => void | — |
suggestionsUp to three unused ones are offered. | string[] | [] |
maxTag limit; the input disables at the limit. | number | 6 |
draft / onDraftChangeControl the typed text if you need to. | string | — |
chipChip corner style. | 'pill' | 'square' | 'pill' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A real labelled input; each chip has a "Remove tag" button.
- Additions and removals are announced in a polite live region.
- Reduced motion removes the chip pop-in.
Customization
- --chip and --r set chip colour and radius.
- Tags are lower-cased and trimmed in add(); change it there for case-sensitive tags.
Keyboard
- Enter / ,
- Add the typed tag
- Backspace ×2
- Remove the last tag (first press highlights it)
- Tab
- Reach each chip’s remove button and the suggestions
FAQ
- Why does Backspace need two presses?
- The first press shows which chip will go. It stops people deleting a tag while they meant to fix a typo.
- Can I paste a list?
- Commas are treated as separators while typing; for pasted lists, split the clipboard text in onDraftChange and call your own add.
- Is Tag Field free?
- Yes. Tag Field is free for personal and commercial projects, with full source on this page.
- How do I install Tag Field?
- Run npx shadcn@latest add https://sundaeui.dev/r/tag-field.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Tag Field support keyboard and screen readers?
- A real labelled input; each chip has a "Remove tag" button. Additions and removals are announced in a polite live region.
- Does Tag Field respect reduced motion?
- Reduced motion removes the chip pop-in.
More like this
All forms →Date Picker
Forms · UnderstatedCombobox
Forms · UnderstatedRange Slider
Forms · UnderstatedOTP Field
Forms · RefinedRolling Counter
Forms · Playful- Pro
Wheel Picker
Forms · Refined