TagInput
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.
Show codeHide 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
Up to two.
Required until a tag is added.
Show codeHide 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
valuefor 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-fadedto--foreground-neutral. - No JS — remove buttons on the initial tags are hidden (
no-js:hidden); the free-text field keeps itsname, so typed-but-not-yet-added text still submits alongside the tags.
Show codeHide code
<TagInput name="tags" value={['Design', 'Engineering', 'Marketing']} />Examples
Empty, ready for input:
Show codeHide 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 viaTabin 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
valuewith existing tags when editing — they submit even before JS finishes loading. - Do keep
placeholdershort and action-oriented (“Add a skill…”). - Don’t assume typed text is lost without JS — the free-text field
keeps its
nameand 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.