# 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.

- ID: `cmp_tag_field_01`
- Slug: `tag-field-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T05:03:42Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `text-inputs`
- Detail page: https://pagesugar.com/components/tag-field-01
- Preview: https://pagesugar.com/preview/tag-field-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-703f3d48e89bead6044ff583e244300d03d6e682ace6806d7fb839f580df0375` |
| `blue` | Blue accent | no | `sha256-39932602a8a2acae018d7357e58bd51313a1c6a95a6d8effde5250e12a49a00b` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: 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.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/tag-field-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Bind 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.

Required props: `label`, `value`

```svelte
<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>
```

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.
- maxTagLength counts 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.

## Usage guide

### 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, kit` adds 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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | Visible label. It names the input and the list of chips. |
| `value` | `string[]` | yes |  | 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 |  | Most tags the list accepts. Once full, the placeholder hides and further tags are refused with a message. |
| `maxTagLength` | `number` | no |  | 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 |  | 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 |  | Submits one hidden input per tag under this name. |
| `hint` | `string` | no |  | 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 |  | Placeholder in the input. Hidden once the list is full. |
| `showCount` | `boolean` | no |  | 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 |  | 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 |  | Input id. Defaults to one generated per instance; every other id is always generated. |
| `class` | `string` | no |  | Extra classes on the outer wrapper, for placement. |

## Customization

Change 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-accent colours the focus edge, its halo and the focused chip's ring. Keep it at 3:1 against the page.
- Chips: --tag-field-chip sets the soft chip fill and --tag-field-chip-ink its text; the remove icon and its hover are mixed from those two. The blue palette pairs #dbeafe with #1e3a8a.
- Danger: --tag-field-danger colours the error edge, the message and the ring on a refused duplicate's match. The default is red-700 (6.5:1 on white).
- Neutrals: --tag-field-ink is the label and typed text, --tag-field-muted the hint and count, --tag-field-placeholder the placeholder, --tag-field-hairline the resting edge (keep it at 3:1) and --tag-field-surface the fill.
- Worked retone for a #09090b band: --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: #fffdf9 and use chipStyle '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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--tag-field-accent` |
| `danger` | `--tag-field-danger` |
| `ink` | `--tag-field-ink` |
| `muted` | `--tag-field-muted` |
| `placeholder` | `--tag-field-placeholder` |
| `hairline` | `--tag-field-hairline` |
| `surface` | `--tag-field-surface` |
| `chip` | `--tag-field-chip` |
| `chipInk` | `--tag-field-chip-ink` |

## Accessibility

- A native \<label for\> names the input; the chips are a list (ul with role=list) labelled by the same label.
- aria-describedby on the input lists the hint, the count line, any error or refusal, then a hidden instruction on reaching the chips, then any aria-describedby you 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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `TagField.svelte`
- Suggested directory: `src/lib/components/tag-field-01`
- Files: 1
- Artifact digest: `sha256-703f3d48e89bead6044ff583e244300d03d6e682ace6806d7fb839f580df0375`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `TagField.svelte`

Role: entry · 26405 bytes · SHA-256 `78974ffe5aed86f9e9d31354949dd587dd9e4192ef20ebbbb882f7d6cfb6fa5c`

```svelte
<!--
	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, #18181b);
		--_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, #f0f0f1);
		--_chip-ink: var(--tag-field-chip-ink, #18181b);

		/* 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>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-703f3d48e89bead6044ff583e244300d03d6e682ace6806d7fb839f580df0375`
- Entry: `TagField.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_tag_field_01/1.0.0/neutral/sha256-703f3d48e89bead6044ff583e244300d03d6e682ace6806d7fb839f580df0375/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_tag_field_01/1.0.0/neutral/sha256-703f3d48e89bead6044ff583e244300d03d6e682ace6806d7fb839f580df0375/bundle.zip (12597 bytes, sha256 `512e3b4bfbb5ac197684bae762333f15052ee3c6da115f6cabe1785008a14c6b`)

Files:

- `TagField.svelte` (entry, 26405 bytes): https://pagesugar.com/artifacts/cmp_tag_field_01/1.0.0/neutral/sha256-703f3d48e89bead6044ff583e244300d03d6e682ace6806d7fb839f580df0375/source/TagField.svelte

### Blue accent (`blue`)

- Artifact digest: `sha256-39932602a8a2acae018d7357e58bd51313a1c6a95a6d8effde5250e12a49a00b`
- Entry: `TagField.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_tag_field_01/1.0.0/blue/sha256-39932602a8a2acae018d7357e58bd51313a1c6a95a6d8effde5250e12a49a00b/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_tag_field_01/1.0.0/blue/sha256-39932602a8a2acae018d7357e58bd51313a1c6a95a6d8effde5250e12a49a00b/bundle.zip (12609 bytes, sha256 `cdb5056e54b39f77a6f3d4fbdef68316b7ce4c8da5d8e43700575abf277889b2`)

Files:

- `TagField.svelte` (entry, 26405 bytes): https://pagesugar.com/artifacts/cmp_tag_field_01/1.0.0/blue/sha256-39932602a8a2acae018d7357e58bd51313a1c6a95a6d8effde5250e12a49a00b/source/TagField.svelte
