Skip to main content
astrocraft-ui/ components · 101

Tags Input

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add forms/tags-input

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/tags-input --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/tags-input --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/tags-input --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/tags-input --theme tailwind --bridge lumos

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

ComponentSlotsVariantsRuntime stateNative 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
---
// 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
---
// 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
---
// 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>
src/components/ui/forms/tags-input/index.ts
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
// 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.