Type and press Enter to add removable tags. Submits as repeated hidden inputs.

Last updated

Uses JavaScript

Lets you type a tag and press Enter or comma to add it as a removable chip, and Backspace on an empty field to remove the last one. Each tag submits as a repeated name hidden input; JS un-names the free-text field so it stops double-submitting once it’s just for typing.

Without JS: Initial tags render as plain, non-removable chips (their remove buttons are hidden) and still submit via their hidden inputs. The free-text field keeps its `name`, so whatever's typed but not yet added also submits alongside the tags.

AstroTailwind
Show code
<TagInput name="skills" value={['Astro', 'Tailwind']} placeholder="Add a skill…" />

Add

npx @ava-ui/apogee add tag-input

No Apogee component dependencies. Depends on @lucide/astro (renders the remove icon). Ships one scoped JS island (progressive enhancement).

Usage

---
import TagInput from '@/components/apogee/TagInput/TagInput.astro'
---
<TagInput name="skills" value={['Astro', 'Tailwind']} />

API

@composable — exposed to Content Editors in AVA. Any other attribute passes through to the root <div> (not the free-text <input>).

Prop Type Default Description
name string — Field name each tag submits under, as a repeated hidden input.
value string[] [] Initial tags, rendered as removable chips.
placeholder string 'Add tag…' Placeholder on the free-text field.
variant 'outline' | 'faded' | 'ghost' | 'headless' 'outline' The box style, shared with every text field.
hasError boolean false Marks the field invalid (aria-invalid, critical border).
id string generated Id of the typing field; the label points at it.
label string — Label, through Field.
hint string — Helper text under the box.
error string — Error message; marks the field invalid.
required boolean false Native required on the typing field while there are no tags.
size 'small' | 'medium' | 'large' | 'xlarge' 'medium' Minimum height and text size, matching the other fields.
maxTags number — Stops accepting tags at this count; the field reads “Limit reached” until one is removed.
allowDuplicates boolean false Accept a tag that already exists (the check is case-insensitive).
class string — Extra classes on the Field root, appended last.

Label, sizes and limits

design

Up to two.

Required until a tag is added.

Show code
<TagInput name="topics" label="Topics" hint="Up to two." maxTags={2} value={['design']} />
<TagInput name="aliases" label="Aliases" allowDuplicates size="small" />
<TagInput name="emails" label="Invite" size="large" placeholder="name@company.com" />

States

  • Empty vs. seeded — start with value for pre-set tags, or omit it for an empty field.
  • Focus-within — the whole container gets a visible ring when the free-text field has focus.
  • Hover on a chip’s remove button — the icon darkens from --foreground-neutral-faded to --foreground-neutral.
  • No JS — remove buttons on the initial tags are hidden (no-js:hidden); the free-text field keeps its name, so typed-but-not-yet-added text still submits alongside the tags.
DesignEngineeringMarketing
Show code
<TagInput name="tags" value={['Design', 'Engineering', 'Marketing']} />

Examples

Empty, ready for input:

Show code
<TagInput name="skills" />

Custom placeholder:

<TagInput name="topics" placeholder="Add a topic and press Enter…" />

Each tag submits as a separate name entry — reading it server-side yields an array:

const skills = formData.getAll('skills') // ['Astro', 'Tailwind', …]

Events

The root dispatches apogee:tag-input-change (detail: { values: string[] }, bubbles) after every add or remove. Duplicate labels are ignored.

<TagInput name="tags" id="tags" value={['astro']} />
<output for="tags">astro</output>
<script>
  document.querySelector('.apogee-taginput')?.addEventListener('apogee:tag-input-change', (e) => {
    document.querySelector('output').textContent = e.detail.values.join(', ')
  })
</script>

Accessibility

Key Action
Enter , Add the typed text as a tag.
Backspace In an empty field, removes the last tag.
Tab Moves between the text field and each remove button.
Enter Space On a remove button, remove that tag; focus moves to the next remove button, or the text field.
  • The free-text field is a real <input type="text">; each tag’s remove control is a real <button type="button" aria-label="Remove {tag}"> — both keyboard-reachable via Tab in source order.
  • The focus-within ring shows the whole control is active, not just the text field.
  • No JS — initial tags still submit (hidden inputs), and the free-text field keeps its name, so typed-but-not-yet-added text submits too; the remove buttons are hidden, so nothing can be removed without JS.

Tokens

Token Used for
--border-neutral Container border
--background-page Container + field background
--border-primary Focus border (focus-within)
--background-neutral-faded Chip background
--foreground-neutral Chip text + remove icon (hover)
--foreground-neutral-faded Field placeholder + remove icon (default)

Do / Don’t

  • Do seed value with existing tags when editing — they submit even before JS finishes loading.
  • Do keep placeholder short and action-oriented (“Add a skill…”).
  • Don’t assume typed text is lost without JS — the free-text field keeps its name and submits as one entry, though it won’t be split into separate tags server-side unless you parse it yourself (Enter/comma splitting is JS-only).
  • Don’t use TagInput for a single value — that’s a plain text <input>.

Content

  • Placeholder names what’s being added (“Add a skill…”), not “Type here”.
  • Tags are short, sentence case, no ending punctuation — same rules as Tag.