Tags Input
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/tags-inputPlain-CSS theme — no build step
npx astrocraft-ui add forms/tags-input --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/tags-input --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/tags-input --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/tags-input --theme tailwind --bridge lumosLive demo
TagsInput
Enter or a comma commits a tag, Backspace in an empty box removes the last one, and pasting a comma- or newline-separated list adds them all — minus blanks and duplicates, which are matched without regard to case. Each chip carries its own hidden input, so this submits as a repeatedname with nothing to parse.
- astro
- headless
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `TagsInput.astro` | `tags-input` `tags-input-list` `tags-input-status` `tags-input-template` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | `data-value` | — |
| `TagsInputField.astro` | `tags-input-field` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | — | — |
| `TagsInputTag.astro` | `tags-input-remove` `tags-input-tag` `tags-input-tag-label` `tags-input-value` | — | — | — |
Source
What the command copies — 5 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/tags-input/TagsInput.astro — headless primitive (see ../../README.md).
// A list of chips plus a text box: Enter adds, Backspace in an empty box removes the last chip,
// pasting a comma- or newline-separated list adds them all at once, and every chip has its own
// remove button. Each chip carries a hidden input, so the field submits as a repeated `name` with no
// JSON, no join character, and nothing to parse on the server.
//
// The parsing rule — trimming, case-insensitive de-duplication, `max` — is in `tags.ts` and checked
// by `tags.test.ts`. The chip markup is in TagsInputTag, cloned from a <template>, so no markup is
// built in JavaScript here either.
//
// A live region reports what changed, because adding and removing chips moves nothing and announces
// nothing on its own: a screen reader user pressing Enter would otherwise get silence, and pressing
// Backspace would delete a tag they are never told about.
//
// ponytail: free text only. For tags chosen from a known set, use ComboBoxMulti — it is the same
// chips over a filtered listbox.
import type { HTMLAttributes } from "astro/types";
import TagsInputField from "./TagsInputField.astro";
import TagsInputTag from "./TagsInputTag.astro";
type Props = HTMLAttributes<"div"> & {
/** The tags to start with. */
value?: readonly string[];
/** Repeated on every chip's hidden input — the name the list submits under. */
name?: string;
/** Names the group, so the chips and the box are announced as one field. */
label?: string;
placeholder?: string;
max?: number;
removeLabel?: string;
/** Announced after each change, in place of a sentence built in JavaScript. */
changedText?: string;
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
};
const {
value = [],
name,
label = "Tags",
placeholder = "Add a tag…",
max,
removeLabel = "Remove",
size = "md",
state = "default",
class: className,
...rest
} = Astro.props;
---
<div
role="group"
aria-label={label}
data-slot="tags-input"
data-max={max}
data-size={size}
data-state={state}
class={className}
{...rest}
>
<ul data-slot="tags-input-list">
{value.map((tag) => <TagsInputTag value={tag} name={name} removeLabel={removeLabel} />)}
</ul>
<TagsInputField placeholder={placeholder} aria-label={label} size={size} state={state} />
<template data-slot="tags-input-template">
<TagsInputTag name={name} removeLabel={removeLabel} />
</template>
{/* The count is what makes an add or a remove audible; the chips themselves announce nothing. */}
<p role="status" aria-live="polite" data-slot="tags-input-status"></p>
</div>
<script>
import { onReadyOnce } from "../../_once";
import { parseTags } from "./tags";
function wire(root: HTMLElement) {
const list = root.querySelector<HTMLElement>('[data-slot="tags-input-list"]');
const field = root.querySelector<HTMLInputElement>('[data-slot="tags-input-field"]');
const template = root.querySelector<HTMLTemplateElement>('[data-slot="tags-input-template"]');
const status = root.querySelector<HTMLElement>('[data-slot="tags-input-status"]');
if (!list || !field || !template) return;
const max = Number(root.dataset.max);
const chips = () => [...list.querySelectorAll<HTMLElement>('[data-slot="tags-input-tag"]')];
const values = () => chips().map((chip) => chip.dataset.value ?? "");
const publish = () => {
const tags = values();
root.dataset.value = tags.join(",");
// The live region says the COUNT rather than a built sentence: a number is the one thing that
// needs no translating, and the tags themselves are already in the list beside it.
if (status) status.textContent = String(tags.length);
root.dispatchEvent(new Event("change", { bubbles: true }));
};
const add = (text: string) => {
const row = template.content.querySelector('[data-slot="tags-input-tag"]');
if (!row) return;
const fresh = parseTags(text, {
existing: values(),
max: Number.isFinite(max) ? max : undefined,
});
for (const tag of fresh) {
const chip = row.cloneNode(true) as HTMLElement;
chip.dataset.value = tag;
const label = chip.querySelector('[data-slot="tags-input-tag-label"]');
const input = chip.querySelector<HTMLInputElement>('[data-slot="tags-input-value"]');
const remove = chip.querySelector('[data-slot="tags-input-remove"]');
if (label) label.textContent = tag;
if (input) input.value = tag;
remove?.setAttribute(
"aria-label",
`${remove.getAttribute("data-label") ?? "Remove"} ${tag}`,
);
list.append(chip);
}
if (fresh.length > 0) publish();
};
field.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
// Enter in a tags field must not submit the form around it — the tag is the thing being
// committed, and losing a half-filled form to it is the bug every hand-rolled version has.
event.preventDefault();
add(field.value);
field.value = "";
return;
}
if (event.key === "Backspace" && field.value === "") {
const last = chips().at(-1);
if (!last) return;
event.preventDefault();
last.remove();
publish();
}
});
// A comma is a separator, not a character: typing it commits the tag, the same as Enter.
field.addEventListener("input", () => {
if (!field.value.includes(",")) return;
add(field.value);
field.value = "";
});
field.addEventListener("paste", (event) => {
event.preventDefault();
add(event.clipboardData?.getData("text") ?? "");
field.value = "";
});
// Committing what is typed on blur, so a tag is never lost to clicking away from the field.
field.addEventListener("blur", () => {
add(field.value);
field.value = "";
});
list.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const chip = target.closest<HTMLElement>('[data-slot="tags-input-tag"]');
if (!chip || !target.closest('[data-slot="tags-input-remove"]')) return;
chip.remove();
publish();
// The button that had focus has just been removed with its chip; without this, focus falls to
// <body> and a keyboard user is dropped at the top of the page.
field.focus();
});
publish();
}
onReadyOnce('[data-slot="tags-input"]', wire);
</script>
---
// src/components/ui/forms/tags-input/TagsInputField.astro — TagsInput compound part (see ../../README.md).
// The text box you type the next tag into. A separate file because it is a separate control: it has
// its own label, its own placeholder, and the chips are not part of it — which is exactly what
// screen reader users need to be true, and what a single `<input>` with chips drawn inside it
// pretends is not.
//
// It carries the shared `data-size` / `data-state` field surface, so it styles with Input.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"input"> & {
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
};
const { size = "md", state = "default", class: className, ...rest } = Astro.props;
---
<input
type="text"
autocomplete="off"
data-slot="tags-input-field"
data-size={size}
data-state={state}
class={className}
{...rest}
/>
---
// src/components/ui/forms/tags-input/TagsInputTag.astro — TagsInput compound part (see ../../README.md).
// One chip: its text, the hidden input that submits it, and a remove button named after the tag
// itself — "Remove react", not five buttons all called "Remove".
//
// THE HIDDEN INPUT IS INSIDE THE CHIP on purpose. Removing a tag is then removing one element, and
// the value the form submits cannot drift from what is on screen — there is no second list to keep
// in step. Repeating the same `name` on each is how HTML has always submitted a list of values.
//
// TagsInput renders one of these inside a <template> and clones it per new tag, so this file stays
// the only place a chip's markup is written (see FileItem.astro for the same pattern and why).
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"li"> & {
value?: string;
/** Repeated on every chip: the field name the list submits under. */
name?: string;
/** The first half of the remove button's name; the tag is appended. */
removeLabel?: string;
};
const { value = "", name, removeLabel = "Remove", class: className, ...rest } = Astro.props;
---
<li data-slot="tags-input-tag" data-value={value} class={className} {...rest}>
<span data-slot="tags-input-tag-label">{value}</span>
<input type="hidden" name={name} value={value} data-slot="tags-input-value" />
<button
type="button"
data-slot="tags-input-remove"
data-label={removeLabel}
aria-label={value ? `${removeLabel} ${value}` : removeLabel}
>
<slot name="remove"
><svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12"></path>
</svg></slot
>
</button>
</li>
import TagsInput from "./TagsInput.astro";
import TagsInputField from "./TagsInputField.astro";
import TagsInputTag from "./TagsInputTag.astro";
export { TagsInput, TagsInputField, TagsInputTag };
export default TagsInput;
// src/components/ui/forms/tags-input/tags.ts — the rule for turning typed or pasted text into tags, in a
// plain module so it is checkable without a DOM (see tags.test.ts). Shared by TagsInput and
// ComboBoxMulti, which take the same text and have to reach the same answer.
//
// The interesting case is paste. Someone copies a column out of a spreadsheet and drops it in: it
// arrives as "[email protected]\[email protected]\n", newline-separated, with a trailing empty line
// and quite possibly a name they have already added. One tag per line, no blanks, no duplicates —
// and it must not silently exceed `max`, because the field is often bounded by a server rule.
export interface ParseTagsOptions {
/** What separates tags in pasted text. Default: commas, newlines and tabs. */
separator?: RegExp;
/** Tags already in the field; matches are dropped rather than added twice. */
existing?: readonly string[];
/** Hard ceiling on the total, existing ones included. */
max?: number;
}
/**
* Split `text` into the tags that should actually be added.
*
* Comparison is case-insensitive, because "React" and "react" are one tag to everyone except a
* string equality check — while the tag is STORED as typed, since the case is the user's.
*
* @returns the new tags, in order, with blanks, duplicates and anything past `max` removed
* @example parseTags("react, vue,, react", { existing: ["svelte"], max: 3 }) // => ["react", "vue"]
*/
export function parseTags(
text: string,
{ separator = /[,\n\t]/, existing = [], max }: ParseTagsOptions = {},
): string[] {
const seen = new Set(existing.map((tag) => tag.trim().toLowerCase()));
const room = max === undefined ? Infinity : Math.max(0, max - existing.length);
const added: string[] = [];
for (const piece of text.split(separator)) {
if (added.length >= room) break;
const tag = piece.trim();
const key = tag.toLowerCase();
if (!tag || seen.has(key)) continue;
seen.add(key);
added.push(tag);
}
return added;
}
What you get
The component source, copied into your project by npx astrocraft-ui add forms/tags-input — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.