Tag field
Short text tokens collected as removable chips inside one bordered field. Enter or a comma adds a tag, pasted lists split, duplicates are refused with a message, and each tag submits as its own hidden input.
cmp_tag_field_01 Preview
Demonstration — no data is sent
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_tag_field_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-39932602a8a2acae018d7357e58bd51313a1c6a95a6d8effde5250e12a49a00b
<!--
A tag field: short text tokens collected as removable chips inside one bordered field, with
the typing caret after the last chip. Enter or a comma adds what was typed, a pasted list is
split into tags, duplicates and over-long tags are refused with a message, and every chip is
reachable from the keyboard (Backspace or the back arrow from an empty field) and removable
with a named button. Each tag submits as its own hidden input; the component never persists,
suggests or moderates anything.
-->
<script lang="ts" module>
export type TagFieldChipStyle = 'soft' | 'outline';
/** Every string the field writes or announces, for other languages. */
export interface TagFieldMessages {
/** Announced after tags are added. */
added: (tags: string[], count: number) => string;
/** Announced after a tag is removed. */
removed: (tag: string, count: number) => string;
/** Announced when a chip's text returns to the input. */
editing: (tag: string) => string;
/**
* Shown when a tag is already in the list. Refusals are announced after the refused tag,
* so the visible line can stay short enough for one line beside the count.
*/
duplicate: (tag: string) => string;
/** Shown and announced when a tag is longer than maxTagLength. */
tooLong: (tag: string, max: number) => string;
/** Shown and announced when the list is full. */
limit: (max: number) => string;
/** The count line, such as '3 of 10 tags'. */
count: (count: number, max: number | undefined) => string;
/** Accessible name of a chip's remove button. */
remove: (tag: string) => string;
/** Read with each chip: how to edit, remove and move. */
chipInstructions: string;
/** Read with the input: how to add a tag and reach the chips. */
inputInstructions: string;
/** Read before the error or refusal message. */
errorPrefix: string;
/** The visible line under the field: how to add, save an edit, or make room. */
instruction: (mode: 'add' | 'edit' | 'full', separators: string[]) => string;
}
const plural = (n: number) => (n === 1 ? 'tag' : 'tags');
const keyNames: Record<string, string> = {
Enter: 'Enter',
Tab: 'Tab',
',': 'comma',
';': 'semicolon',
' ': 'Space'
};
export const defaultTagFieldMessages: TagFieldMessages = {
added: (tags, count) => `Added ${tags.join(', ')}. ${count} ${plural(count)}.`,
removed: (tag, count) => `Removed ${tag}. ${count} ${plural(count)}.`,
editing: (tag) => `Editing ${tag}. Press Enter to save or Escape to cancel.`,
duplicate: () => 'Already in the list.',
tooLong: (_tag, max) => `Keep tags to ${max} characters.`,
limit: () => 'Full. Remove one first.',
count: (count, max) =>
max === undefined ? `${count} ${plural(count)}` : `${count} of ${max} ${plural(max)}`,
remove: (tag) => `Remove tag ${tag}`,
chipInstructions:
'Enter to edit, Backspace to remove, arrow keys to move between tags, Escape to return to typing.',
inputInstructions: 'With the field empty, Backspace moves to the tags already added.',
errorPrefix: 'Error:',
instruction: (mode, separators) =>
mode === 'edit'
? 'Enter to save, Esc to cancel'
: mode === 'full'
? 'Remove a tag to add another'
: `${separators.map((key) => keyNames[key] ?? `\u201c${key}\u201d`).join(' or ')} to add`
};
</script>
<script lang="ts">
import { tick } from 'svelte';
import type { HTMLInputAttributes } from 'svelte/elements';
type Passthrough = Omit<
HTMLInputAttributes,
| 'type'
| 'value'
| 'id'
| 'name'
| 'class'
| 'children'
| 'disabled'
| 'placeholder'
| 'maxlength'
| 'aria-invalid'
>;
interface Props extends Passthrough {
/** Visible label. It also names the list of chips. */
label: string;
/** Bindable list of tags. */
value: string[];
/** Keys that add the typed tag. Single characters also split pasted text. */
separators?: string[];
/** Most tags the list accepts. */
maxTags?: number;
/** Most characters one tag may have. */
maxTagLength?: number;
/** Applied to each tag before it is checked and added. Default trims it. */
normalize?: (tag: string) => string;
/** Return a message to refuse a tag, or null to accept it. */
validate?: (tag: string, existing: string[]) => string | null;
/** Accept the same tag twice. Duplicates are compared without case. */
allowDuplicates?: boolean;
/** Submits one hidden input per tag under this name. */
name?: string;
/** Help text under the label: what to add and how. */
hint?: string;
/** Validation message from your code, shown under the field. */
error?: string | null;
/** Placeholder in the input. Hidden once the list is full. */
placeholder?: string;
/** Show the count line. Defaults to on when maxTags is set. */
showCount?: boolean;
/** 'soft' fills each chip; 'outline' draws a hairline around it. */
chipStyle?: TagFieldChipStyle;
/** Disables the input and every chip; disabled tags do not submit. */
disabled?: boolean;
/** Replace any of the strings the field shows or announces. */
messages?: Partial<TagFieldMessages>;
/** Input id. Defaults to one generated per instance. */
id?: string;
/** Extra classes on the outer wrapper, for placement. */
class?: string;
}
let {
label,
value = $bindable([]),
separators = ['Enter', ','],
maxTags,
maxTagLength,
normalize = (tag: string) => tag.trim(),
validate,
allowDuplicates = false,
name,
hint,
error = null,
placeholder,
showCount,
chipStyle = 'soft',
disabled = false,
messages,
id,
class: className,
...rest
}: Props = $props();
const uid = $props.id();
const inputId = $derived(id ?? `${uid}-input`);
const labelId = `${uid}-label`;
const hintId = `${uid}-hint`;
const countId = `${uid}-count`;
const messageId = `${uid}-message`;
const chipHelpId = `${uid}-chip-help`;
const inputHelpId = `${uid}-input-help`;
const text = $derived({ ...defaultTagFieldMessages, ...messages });
const tags = $derived(value ?? []);
const atLimit = $derived(maxTags !== undefined && tags.length >= maxTags);
const countShown = $derived(showCount ?? maxTags !== undefined);
let root: HTMLDivElement | undefined = $state();
let input: HTMLInputElement | undefined = $state();
let draft = $state('');
/** The last refusal; cleared as soon as the draft changes. */
let refusal = $state<string | null>(null);
let announcement = $state('');
/**
* The chip whose text is in the input. It stays in the list (and submits) until the edit is
* saved, so indices never shift under it and an abandoned edit loses nothing.
*/
let editingIndex = $state<number | null>(null);
/** The chip whose remove button is in the Tab order: the one focus is on. */
let activeIndex = $state<number | null>(null);
/** The existing chip a refused duplicate matches, outlined for as long as the refusal shows. */
let matchAt = $state<number | null>(null);
const matchIndex = $derived(refusal ? matchAt : null);
let announceTimer: ReturnType<typeof setTimeout> | undefined;
/** A press inside the field is under way, so a focusout without a target is not leaving it. */
let pressing = false;
const message = $derived(refusal ?? (error || null));
const mode = $derived(editingIndex !== null ? 'edit' : atLimit ? 'full' : 'add');
const describedBy = $derived(
[
...new Set(
[
hint && hintId,
countShown && countId,
message && messageId,
inputHelpId,
...(rest['aria-describedby'] ?? '').split(/\s+/)
].filter(Boolean)
)
].join(' ')
);
/* Set on teardown, so the focusout an unmount can fire never commits or starts a timer. */
let destroyed = false;
$effect(() => () => {
destroyed = true;
clearTimeout(announceTimer);
window.removeEventListener('pointerup', release);
window.removeEventListener('pointercancel', release);
});
/** Polite announcement, emptied first so the same words read again when repeated. */
function announce(words: string) {
announcement = '';
clearTimeout(announceTimer);
announceTimer = setTimeout(() => (announcement = words), 50);
}
const fold = (tag: string) => tag.toLocaleLowerCase();
const escape = (s: string) => s.replace(/[\\\]^-]/g, '\\$&');
/** Pasted or typed text split on the single-character separators and line breaks. */
function split(source: string): string[] {
const chars = [...separators.filter((s) => s.length === 1), '\n', '\r', '\t'];
return source.split(new RegExp(`[${chars.map(escape).join('')}]`));
}
/** How refused tags are joined back into the input: the first character separator. */
function joiner() {
const char = separators.find((s) => s.length === 1);
return char ? `${char} ` : ' ';
}
function refusalFor(tag: string, pool: string[]): { reason: string; match?: string } | null {
if (maxTags !== undefined && pool.length >= maxTags) return { reason: text.limit(maxTags) };
if (maxTagLength !== undefined && [...tag].length > maxTagLength)
return { reason: text.tooLong(tag, maxTagLength) };
if (!allowDuplicates) {
const match = pool.find((t) => fold(t) === fold(tag));
if (match !== undefined) return { reason: text.duplicate(tag), match };
}
const custom = validate?.(tag, pool);
return custom ? { reason: custom } : null;
}
/**
* Adds every acceptable candidate (in the edited chip's place while editing) and leaves the
* refused ones in the input, so nothing typed or pasted is lost.
*/
function commit(candidates: string[]): string[] {
const list = [...tags];
const at = editingIndex;
/* The chip being edited is about to be replaced, so it neither counts nor duplicates. */
const pool = at === null ? [...list] : list.filter((_, i) => i !== at);
const added: string[] = [];
const left: string[] = [];
let refused: { reason: string; match?: string; tag?: string } | null = null;
for (const candidate of candidates) {
const tag = normalize(candidate);
if (!tag) continue;
const problem = refusalFor(tag, pool);
if (problem) {
refused ??= { ...problem, tag };
left.push(candidate.trim());
continue;
}
pool.push(tag);
added.push(tag);
}
if (added.length) {
if (at === null) list.push(...added);
else list.splice(at, 1, ...added);
value = list;
editingIndex = null;
}
draft = left.join(joiner());
refusal = refused?.reason ?? null;
const match = refused?.match;
matchAt = match === undefined ? null : list.findIndex((t) => fold(t) === fold(match));
const words = [
added.length ? text.added(added, list.length) : '',
refused ? `${refused.tag}: ${refused.reason}` : ''
]
.filter(Boolean)
.join(' ');
if (words) announce(words);
return left;
}
/** Leaves an edit: the chip was never taken out, so nothing needs putting back. */
function cancelEdit() {
editingIndex = null;
draft = '';
refusal = null;
}
/** Focuses the chip nearest index in the direction of travel, skipping one being edited. */
async function focusChip(index: number, step = -1) {
await tick();
const chips = [...(root?.querySelectorAll<HTMLButtonElement>('.tag-field__chip') ?? [])];
let i = Math.max(0, Math.min(index, chips.length - 1));
while (chips[i]?.disabled) i += step;
if (chips[i]) chips[i].focus();
else input?.focus();
}
async function remove(index: number) {
if (disabled || index === editingIndex) return;
const [tag] = tags.slice(index, index + 1);
value = tags.filter((_, i) => i !== index);
if (editingIndex !== null && index < editingIndex) editingIndex -= 1;
refusal = null;
announce(text.removed(tag, tags.length));
/* Focus the chip that took this one's place, or the input when it was the last. */
if (index < tags.length) focusChip(index, 1);
else {
await tick();
input?.focus();
}
}
async function edit(index: number) {
if (disabled || index === editingIndex) return;
if (editingIndex !== null) cancelEdit();
else if (draft.trim()) {
commit(split(draft));
if (draft) return;
}
editingIndex = index;
draft = tags[index];
refusal = null;
announce(text.editing(tags[index]));
await tick();
input?.focus();
input?.select();
}
/** Whether the key moves towards the end of the list, in the field's reading direction. */
function forward(key: string): boolean | null {
const rtl = root ? getComputedStyle(root).direction === 'rtl' : false;
if (key === 'ArrowRight') return !rtl;
if (key === 'ArrowLeft') return rtl;
return null;
}
function onInputKeydown(event: KeyboardEvent) {
if (event.isComposing || event.keyCode === 229) return;
/* Enter always saves an edit, as the instruction says, whatever adds a new tag. */
if (separators.includes(event.key) || (event.key === 'Enter' && editingIndex !== null)) {
if (!draft.trim()) {
/* An emptied edit keeps its chip; any other empty Enter is left to the form. */
if (editingIndex !== null) {
event.preventDefault();
cancelEdit();
}
return;
}
event.preventDefault();
commit(split(draft));
return;
}
if (event.key === 'Escape') {
if (editingIndex !== null) {
event.preventDefault();
cancelEdit();
} else if (refusal) refusal = null;
return;
}
if (draft === '' && editingIndex === null && tags.length) {
if (event.key === 'Backspace' || forward(event.key) === false) {
event.preventDefault();
focusChip(tags.length - 1);
}
}
}
/**
* Separators that arrive without a keydown (mobile keyboards, autocorrect) still split, once
* any composition has finished.
*/
function tokenize(field: HTMLInputElement) {
draft = field.value;
const parts = split(draft);
if (parts.length < 2) return;
const last = parts.pop()!;
const left = commit(parts);
draft = [...left, last.trimStart()].filter(Boolean).join(joiner());
field.value = draft;
}
function onInput(event: Event) {
refusal = null;
const field = event.currentTarget as HTMLInputElement;
/* Read the element, not draft: this handler can run before the binding updates it. */
if ((event as InputEvent).isComposing) draft = field.value;
else tokenize(field);
}
function onPaste(event: ClipboardEvent) {
const pasted = event.clipboardData?.getData('text') ?? '';
if (split(pasted).length < 2 || !input) return;
event.preventDefault();
const start = input.selectionStart ?? draft.length;
const end = input.selectionEnd ?? draft.length;
commit(split(draft.slice(0, start) + pasted + draft.slice(end)));
}
function onChipKeydown(event: KeyboardEvent, index: number, onRemove = false) {
const step = forward(event.key);
if (step !== null) {
event.preventDefault();
if (step && index === tags.length - 1) input?.focus();
else focusChip(index + (step ? 1 : -1), step ? 1 : -1);
return;
}
switch (event.key) {
case 'Home':
event.preventDefault();
focusChip(0, 1);
return;
case 'End':
event.preventDefault();
input?.focus();
return;
case 'Backspace':
case 'Delete':
event.preventDefault();
remove(index);
return;
case 'Escape':
event.preventDefault();
input?.focus();
return;
case 'Enter':
case 'F2':
if (onRemove && event.key === 'Enter') return;
event.preventDefault();
edit(index);
return;
}
/* Typing on a chip goes to the input, so the character lands where it is expected. */
const printable = event.key.length === 1 && event.key !== ' ';
if (printable && !event.ctrlKey && !event.metaKey && !event.altKey) input?.focus();
}
function release() {
/* After the click that follows, so its focus changes still count as inside. */
setTimeout(() => (pressing = false));
}
/** A press anywhere in the field that is not a chip control focuses the input. */
function onPointerDown(event: PointerEvent) {
pressing = true;
window.addEventListener('pointerup', release, { once: true });
window.addEventListener('pointercancel', release, { once: true });
if (!input || disabled || event.button !== 0 || event.target === input) return;
if ((event.target as Element).closest('button')) return;
event.preventDefault();
input.focus();
}
function onFocusIn(event: FocusEvent) {
const tag = (event.target as Element).closest<HTMLElement>('.tag-field__tag');
activeIndex = tag ? Number(tag.dataset.index) : null;
}
/** Leaving the field adds what was typed, and never loses a chip that was being edited. */
function onFocusOut(event: FocusEvent) {
const next = event.relatedTarget as Node | null;
if (destroyed || pressing || (next && root?.contains(next))) return;
activeIndex = null;
if (draft.trim()) commit(split(draft));
/* A refused edit keeps its text and message; only an emptied one is abandoned. */
if (editingIndex !== null && !draft.trim()) cancelEdit();
}
/* A new error from the consumer's own validation is announced like the field's refusals. */
let seenError: string | null | undefined;
$effect(() => {
const current = error || null;
if (seenError !== undefined && current && current !== seenError) announce(current);
seenError = current;
});
</script>
<div
bind:this={root}
class={['tag-field min-w-0', className]}
data-chip={chipStyle}
data-invalid={message ? '' : undefined}
data-disabled={disabled ? '' : undefined}
onfocusin={onFocusIn}
onfocusout={onFocusOut}
>
<label
id={labelId}
for={inputId}
class="block text-sm leading-5 font-medium text-pretty break-words text-(--_ink)"
>
{label}
</label>
{#if hint}
<p id={hintId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
{hint}
</p>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={[
'tag-field__control mt-2 flex min-h-10 cursor-text flex-wrap items-center gap-1 rounded-lg border p-1',
'border-(--_edge) bg-(--_surface) pointer-coarse:gap-2',
'transition-[border-color,box-shadow] duration-150 ease-(--_ease) motion-reduce:transition-none',
'focus-within:border-(--_ring) focus-within:shadow-(--_halo)'
]}
onpointerdown={onPointerDown}
>
{#if tags.length}
<!-- role="list" keeps list semantics that display: contents drops in WebKit. -->
<ul role="list" class="contents" aria-labelledby={labelId}>
{#each tags as tag, index (index)}
<li
role="listitem"
class={[
'tag-field__tag flex max-w-full min-w-0 items-start rounded text-(--_chip-ink)',
chipStyle === 'soft' ? 'bg-(--_chip)' : 'shadow-(--_chip-outline)'
]}
data-index={index}
data-match={matchIndex === index ? '' : undefined}
data-editing={editingIndex === index ? '' : undefined}
>
<button
type="button"
tabindex="-1"
class={[
'tag-field__chip relative min-w-6 cursor-default py-1 ps-2 text-start text-[13px] leading-5 font-medium break-words outline-none',
'pointer-coarse:min-w-12 pointer-coarse:py-2 pointer-coarse:text-sm',
disabled ? 'pe-2' : 'pe-1'
]}
aria-describedby={chipHelpId}
disabled={disabled || editingIndex === index}
onkeydown={(event) => onChipKeydown(event, index)}
ondblclick={() => edit(index)}
onclick={(event) => event.detail === 0 && edit(index)}>{tag}</button
>
{#if !disabled}
<button
type="button"
tabindex={activeIndex === index ? 0 : -1}
disabled={editingIndex === index}
class={[
'tag-field__remove relative my-0.5 me-0.5 grid size-6 shrink-0 place-items-center rounded-xs outline-none',
'text-(--_chip-muted) hover:bg-(--_chip-hover) hover:text-(--_chip-ink) focus-visible:shadow-(--_remove-ring)',
'transition-[color,background-color,scale] duration-150 ease-(--_ease)',
'active:scale-90 active:duration-75 motion-reduce:transition-none motion-reduce:active:scale-100',
'pointer-coarse:my-0 pointer-coarse:size-9',
editingIndex === index && 'invisible'
]}
aria-label={text.remove(tag)}
onkeydown={(event) => onChipKeydown(event, index, true)}
onclick={() => remove(index)}
>
<svg class="size-3.5" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M4.5 4.5l7 7m0-7l-7 7"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
</svg>
</button>
{/if}
</li>
{/each}
</ul>
{/if}
<input
bind:this={input}
autocomplete="off"
{...rest}
bind:value={draft}
type="text"
id={inputId}
placeholder={atLimit ? undefined : placeholder}
aria-describedby={describedBy}
aria-invalid={message ? 'true' : undefined}
{disabled}
class={[
'tag-field__input h-7 flex-1 bg-transparent ps-2 pe-1 text-sm text-(--_ink) outline-none',
'placeholder:text-(--_placeholder) disabled:cursor-not-allowed',
'pointer-coarse:h-9 pointer-coarse:text-base',
/* Full, the input only needs room for the caret, so no empty row opens under the chips. */
atLimit && editingIndex === null ? 'min-w-8' : 'min-w-24'
]}
onkeydown={onInputKeydown}
oninput={onInput}
oncompositionend={(event) => tokenize(event.currentTarget)}
onpaste={onPaste}
/>
</div>
<!-- One line is always held here: the instruction, replaced by a refusal or error. -->
<div class="mt-1 flex min-h-5 items-start gap-4 text-[13px] leading-5">
{#if message}
<p id={messageId} class="flex min-w-0 items-start gap-1 text-(--_danger)">
<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
<path
d="M8 4.75v3.5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
<circle cx="8" cy="11" r="1" fill="currentColor" />
</svg>
</span>
<span class="min-w-0 text-pretty break-words"
><span class="sr-only">{`${text.errorPrefix} `}</span>{message}</span
>
</p>
{:else if !disabled}
<!-- The input's description already says this to assistive technology. -->
<p class="min-w-0 text-pretty text-(--_placeholder)" aria-hidden="true">
{text.instruction(mode, separators)}
</p>
{/if}
{#if countShown}
<p
id={countId}
class={[
'ms-auto shrink-0 tabular-nums',
atLimit ? 'font-medium text-(--_ink)' : 'text-(--_muted)'
]}
>
{text.count(tags.length, maxTags)}
</p>
{/if}
</div>
<span id={chipHelpId} hidden>{text.chipInstructions}</span>
<span id={inputHelpId} hidden
>{`${text.instruction(mode, separators)}. ${text.inputInstructions}`}</span
>
<p class="sr-only" role="status" aria-live="polite" aria-atomic="true">{announcement}</p>
{#if name}
{#each tags as tag, index (index)}
<input type="hidden" {name} value={tag} {disabled} />
{/each}
{/if}
</div>
<style>
/* Public tokens: set --tag-field-* on the field or any ancestor to retone it. */
.tag-field {
--_accent: var(--tag-field-accent, #1d4ed8);
--_danger: var(--tag-field-danger, #b91c1c);
--_ink: var(--tag-field-ink, #18181b);
--_muted: var(--tag-field-muted, #52525b);
--_placeholder: var(--tag-field-placeholder, #71717a);
/* 3:1 against white: the edge is the only thing marking an empty field (WCAG 1.4.11). */
--_hairline: var(--tag-field-hairline, rgb(0 0 0 / 0.44));
--_surface: var(--tag-field-surface, #ffffff);
/* zinc-100 and a half: a fill that reads as a chip on white without a boundary. */
--_chip: var(--tag-field-chip, #dbeafe);
--_chip-ink: var(--tag-field-chip-ink, #1e3a8a);
/* Formulas mixed from the tokens, so every retone carries them along. */
--_chip-muted: color-mix(in oklab, var(--_chip-ink) 70%, var(--_chip));
--_chip-hover: color-mix(in oklab, var(--_chip-ink) 10%, var(--_chip));
--_chip-outline: inset 0 0 0 1px color-mix(in oklab, var(--_hairline) 45%, transparent);
--_ease: cubic-bezier(0.2, 0, 0, 1);
--_edge: var(--_hairline);
--_ring: var(--_accent);
/* Focus anywhere in the field: a two-pixel edge (border plus ring) and a soft halo. */
--_halo: 0 0 0 1px var(--_ring), 0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
--_remove-ring: inset 0 0 0 1.5px var(--_chip-ink);
}
/* Invalid or refused: the edge and the focus ring carry the danger colour. */
.tag-field[data-invalid] {
--_edge: var(--_danger);
--_ring: var(--_danger);
}
/* Hover only where typing is possible: one step darker edge. */
@media (hover: hover) {
.tag-field:not([data-disabled]) .tag-field__control:hover {
--_edge: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
}
.tag-field[data-invalid] .tag-field__control:hover {
--_edge: var(--_danger);
}
}
/*
* A focused chip carries its own ring. Chips sit 4 px apart, so the ring is offset by 1 px
* rather than 2, and never touches the chip beside it.
*/
.tag-field__tag:has(:focus-visible) {
outline: 2px solid var(--_accent);
outline-offset: 1px;
}
/* The chip being edited holds its place as a dashed outline until the edit is saved. */
.tag-field__tag[data-editing] {
background-color: transparent;
box-shadow: none;
outline: 1px dashed var(--_hairline);
outline-offset: -1px;
color: var(--_muted);
}
/* A refused duplicate points at the chip it matches for as long as the refusal shows. */
.tag-field__tag[data-match] {
outline: 1px solid var(--_danger);
outline-offset: 1px;
}
/*
* On touch, the chip and remove hit areas reach 44 px tall into the 8 px gaps around the chip,
* and the remove button's reaches 44 px wide too, without the visible chip growing.
*/
@media (pointer: coarse) {
.tag-field__chip::after,
.tag-field__remove::after {
content: '';
position: absolute;
inset: -4px;
}
.tag-field__chip::after {
inset-inline: 0;
}
}
/* Disabled: faded and not interactive; the fill stays, so the chips keep their edges. */
.tag-field[data-disabled] .tag-field__control {
cursor: not-allowed;
opacity: 0.55;
}
/* Forced colours drop shadows and repaint fills, so focus and chips get system outlines. */
@media (forced-colors: active) {
.tag-field__control:focus-within {
outline: 2px solid Highlight;
outline-offset: 2px;
}
.tag-field__tag {
outline: 1px solid CanvasText;
}
.tag-field__tag:has(:focus-visible) {
outline: 2px solid Highlight;
}
}
</style>
Usage#
On this pageBind value to a string array and give it a label and a name; each tag submits as a hidden input under that name. Set maxTags, maxTagLength, normalize and validate to shape what is accepted. It does not suggest tags from a list, persist them, or moderate what people type: that is your form's and your server's job.
- Suggested location
src/lib/components/tag-field-01
Limitations
- Adding, editing and removing tags needs JavaScript. Before hydration the existing tags render and submit, but the input adds nothing.
- No suggestions or autocomplete: it is a free-text field. A combobox with suggestions is a different component.
- A tag left typed in the input is added when focus leaves the field; one the field refuses stays in the input with its message and is not submitted.
- Chips are not in the Tab order; Tab goes straight to the input. Backspace or the back arrow in an empty input reaches them, and once a chip has focus, Tab reaches its remove button.
maxTagLengthcounts code points, so an emoji built from several code points counts as more than one character.- The live region announces in the messages you pass; translate every string in messages along with the page.
Example
<script lang="ts">
import TagField from '$lib/components/tag-field-01/TagField.svelte';
let skills = $state(['Roadmapping', 'User research']);
</script>
<form method="POST" class="max-w-md">
<TagField
label="Skills"
name="skills"
bind:value={skills}
maxTags={10}
maxTagLength={32}
placeholder="Add a skill"
hint="Teammates see these on your cards when they need someone to ask."
/>
</form>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Tag field#
A free-text field that turns what people type into removable chips. Use it for skills, keywords, labels or allergies: short values where the list is the answer.
Adding tags#
- Enter or a comma adds the typed tag. Change the keys with
separators; any single character in it also splits pasted text, as do line breaks and tabs. - Pasting
svelte, tailwind, kitadds three tags. Any the field refuses stay in the input, joined by the first separator, with the reason under the field. - An empty Enter is left alone, so it still submits the form.
- A tag left in the input is added when focus leaves the field.
What gets refused#
In order: a full list (maxTags), a tag over maxTagLength, a duplicate (unless
allowDuplicates, compared without case), then your validate. The message shows under the
field, is announced politely, and clears as soon as the input changes. A refused duplicate
briefly outlines the chip it matches.
Keyboard#
| Where | Key | Does |
|---|---|---|
| Input | Enter, comma | Adds the typed tag |
| Input | Backspace or back arrow, empty | Moves to the last chip |
| Input | Escape | Cancels an edit, or clears a refusal |
| Chip | Left and right arrows | Previous and next chip (mirrored in RTL); past the last, the input |
| Chip | Home, End | First chip; the input |
| Chip | Backspace, Delete | Removes it; focus goes to the next chip or the input |
| Chip | Enter, F2, double-click | Returns its text to the input to edit |
| Chip | Escape | Back to the input |
While a tag is being edited its chip stays in place as a dashed outline and keeps submitting its original text. Enter saves the new text in that place; Escape, an emptied Enter, or leaving the field with the text refused cancels the edit and leaves the chip as it was.
Once a chip has focus, Tab reaches its remove button, so every chip can be removed with a named button from the keyboard as well as with Backspace or Delete.
Forms#
With name, each tag renders <input type="hidden" name="{name}" value="{tag}">, so
formData.getAll('skills') returns the list. The visible input has no name and never submits.
Translating#
Every visible and announced string is in messages. Pass the ones you need; the rest fall
back to English. The type TagFieldMessages is exported from the component's module script.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Visible label. It names the input and the list of chips. |
value | string[] | Yes | None | Bindable list of tags. |
separators | string[] | No | ['Enter', ','] | Keys that add the typed tag. Single characters also split pasted text, as do line breaks and tabs. |
maxTags | number | No | None | Most tags the list accepts. Once full, the placeholder hides and further tags are refused with a message. |
maxTagLength | number | No | None | Most characters per tag, counted in code points after normalize. Longer tags are refused with a message, whether typed or pasted. |
normalize | (tag: string) => string | No | (tag) => tag.trim() | Applied to each tag before it is checked and added. A tag that normalises to an empty string is ignored. |
validate | (tag: string, existing: string[]) => string | null | No | None | Return a message to refuse a tag, or null to accept it. Runs after the limit, length and duplicate checks. |
allowDuplicates | boolean | No | false | Accept a tag already in the list. Duplicates are compared without case. |
name | string | No | None | Submits one hidden input per tag under this name. |
hint | string | No | None | Help text under the label: what to add, how, and how many. |
error | string | null | No | null | Validation message from your code. Shown with an icon under the field, turns the edge red and sets aria-invalid. A refusal from the field itself takes its place while it shows. |
placeholder | string | No | None | Placeholder in the input. Hidden once the list is full. |
showCount | boolean | No | None | Show the count line, such as 3 of 10 tags. Defaults to on when maxTags is set. |
chipStyle | 'soft' | 'outline' | No | 'soft' | 'soft' fills each chip; 'outline' draws a hairline around it. |
disabled | boolean | No | false | Disables the input and every chip and hides the remove buttons. Disabled tags do not submit. |
messages | Partial<TagFieldMessages> | No | None | Replace any string the field shows or announces: added, removed, editing, duplicate, tooLong, limit, count, remove, instruction (the line under the field), chipInstructions, inputInstructions and errorPrefix. |
id | string | No | None | Input id. Defaults to one generated per instance; every other id is always generated. |
class | string | No | None | Extra classes on the outer wrapper, for placement. |
Customization#
On this pageChange the copy through props and messages, and retone the field through nine --tag-field-* CSS variables. The remove icon, its hover and the outline chip's edge are mixed from those, so they follow any retone.
- Accent:
--tag-field-accentcolours the focus edge, its halo and the focused chip's ring. Keep it at 3:1 against the page. - Chips:
--tag-field-chipsets the soft chip fill and--tag-field-chip-inkits text; the remove icon and its hover are mixed from those two. The blue palette pairs#dbeafewith#1e3a8a. - Danger:
--tag-field-dangercolours the error edge, the message and the ring on a refused duplicate's match. The default isred-700(6.5:1 on white). - Neutrals:
--tag-field-inkis the label and typed text,--tag-field-mutedthe hint and count,--tag-field-placeholderthe placeholder,--tag-field-hairlinethe resting edge (keep it at 3:1) and--tag-field-surfacethe fill. - Worked retone for a
#09090bband:--tag-field-accent:#fafafa;--tag-field-ink:#fafafa;--tag-field-muted:#a1a1aa;--tag-field-placeholder:#a1a1aa;--tag-field-hairline: rgb(255 255 255 / 0.35);--tag-field-surface:#18181b;--tag-field-chip:#27272a;--tag-field-chip-ink:#fafafa;--tag-field-danger:#f87171. - Cream page such as a bakery's
#fbf7f0: set--tag-field-surface:#fffdf9and usechipStyle'outline', which reads as written onto the page rather than stuck on. - Behaviour: separators={['Enter', ',', ';']} adds a semicolon; normalize={(t) => t.trim().
toLowerCase()} stores lowercase tags; validate returns your own message, such as for a reserved word. - Layout: the field fills its container and wraps chips onto new lines; the input keeps 96 px. Pass class for placement.
- Copy: translate every entry in messages along with the page, including the remove button's name and the instructions read with the input and chips.
Public CSS variables
| Variable | Token |
|---|---|
--tag-field-accent | accent |
--tag-field-danger | danger |
--tag-field-ink | ink |
--tag-field-muted | muted |
--tag-field-placeholder | placeholder |
--tag-field-hairline | hairline |
--tag-field-surface | surface |
--tag-field-chip | chip |
--tag-field-chip-ink | chipInk |
Accessibility#
On this page- A native <label for> names the input; the chips are a list (ul with
role=list) labelled by the same label. aria-describedbyon the input lists the hint, the count line, any error or refusal, then a hidden instruction on reaching the chips, then anyaria-describedbyyou pass.- Additions, removals, edits and refusals are announced in one polite, atomic live region. Refusals are also shown under the field with an icon and a visually hidden
errorPrefix, never by colour alone. - Each chip is a button named by its text and described by hidden instructions; Enter or F2 edits it, Backspace or Delete removes it, the arrows (mirrored in right-to-left), Home and End move, Escape returns to the input.
- Each chip has a remove button named 'Remove tag
{tag}'. While a chip has focus its remove button is the next Tab stop, so it can be pressed from the keyboard. After a removal, focus moves to the chip that took its place, or to the input. - Chips stay out of the Tab order so Tab goes straight to the input; Backspace or the back arrow in an empty input reaches them. A chip being edited stays in place as a dashed outline and keeps submitting its original text until the edit is saved.
- The focused chip shows a two-pixel accent ring; the field shows a two-pixel edge while anything inside has focus. In forced-colours mode both use Highlight.
- On touch screens chips grow to 36 px and each remove button's hit area reaches 44 px.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.