Forms formtagsinputautocompletepills
Tags Autocomplete Input
Multi-tag pill input component with keyboard tag addition, pill removal, and suggestion dropdown.
Preview
Astro TypeScript
Usage
Copy the full block below to use this component with its imports.
astro
---
import { TagsAutocompleteInput } from 'astro-component-kit';
---
<TagsAutocompleteInput id="article-tags" label="Article Tags" initialTags={['astro', 'design']} /> ---
import { TagsAutocompleteInput } from 'astro-component-kit';
---
<TagsAutocompleteInput id="article-tags" label="Article Tags" initialTags={['astro', 'design']} /> Manual Installation
If you are not using the npm package, create a new file src/components/lib/TagsAutocompleteInput.astro and paste the following code:
astro
---
/**
* TagsAutocompleteInput — Tag entry field with pill badges, keyboard deletion, and suggestion dropdown.
*
* @param {string} id - HTML ID for the tag input.
* @param {string} label - Input label. Default is 'Tags'.
* @param {string[]} initialTags - Pre-populated list of tags. Default is ['Astro', 'TypeScript'].
* @param {string[]} suggestions - Autocomplete candidate options.
* @param {string} placeholder - Input placeholder. Default is 'Add tag & press Enter...'.
*/
interface Props {
id: string;
label?: string;
initialTags?: string[];
suggestions?: string[];
placeholder?: string;
}
const {
id,
label = 'Tags',
initialTags = ['Astro', 'TypeScript'],
suggestions = ['React', 'Tailwind', 'Three.js', 'SSR', 'Vite', 'GraphQL'],
placeholder = 'Add tag & press Enter...',
} = Astro.props;
---
<div class="tags-field" data-tags-root>
<label for={id} class="tags-field__label">{label}</label>
<div class="tags-field__box" data-tags-box>
<div class="tags-field__list" data-tags-list>
{initialTags.map((tag) => (
<span class="tag-pill" data-tag={tag}>
<span class="tag-pill__name">{tag}</span>
<button type="button" class="tag-pill__remove" aria-label={`Remove tag ${tag}`} data-tag-remove>×</button>
</span>
))}
</div>
<input
type="text"
id={id}
placeholder={placeholder}
class="tags-field__input"
data-tags-input
autocomplete="off"
/>
</div>
<div class="tags-field__suggestions" data-tags-suggestions hidden>
{suggestions.map((sug) => (
<button type="button" class="sug-item" data-sug-val={sug}>
+ {sug}
</button>
))}
</div>
</div>
<style>
.tags-field {
display: flex;
flex-direction: column;
gap: var(--sp-2, 0.5rem);
width: 100%;
max-width: 440px;
font-family: var(--font-sans, system-ui, sans-serif);
position: relative;
}
.tags-field__label {
font-size: 0.85rem;
font-weight: 600;
color: var(--c-text-1, #ffffff);
}
.tags-field__box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--r-md, 12px);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
min-height: 44px;
}
.tags-field__box:focus-within {
border-color: rgba(184, 134, 11, 0.5);
box-shadow: 0 0 0 3px rgba(184, 134, 11, 0.15);
}
.tags-field__list {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0.25rem 0.65rem;
background: rgba(184, 134, 11, 0.15);
border: 1px solid rgba(184, 134, 11, 0.35);
border-radius: var(--r-full, 9999px);
font-size: 0.75rem;
font-weight: 600;
color: var(--c-accent, #fef3c7);
user-select: none;
animation: pill-pop 0.2s ease-out;
}
@keyframes pill-pop {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.tag-pill__remove {
background: transparent;
border: none;
color: var(--c-accent, #fef3c7);
font-size: 1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
opacity: 0.7;
transition: opacity 0.2s;
}
.tag-pill__remove:hover {
opacity: 1;
color: #ef4444;
}
.tags-field__input {
flex: 1;
min-width: 140px;
background: transparent;
border: none;
color: var(--c-text-1, #ffffff);
font-size: 0.88rem;
outline: none;
padding: 0.2rem 0;
}
.tags-field__input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.tags-field__suggestions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
padding: 0.4rem;
background: rgba(12, 12, 16, 0.9);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--r-sm, 8px);
}
.sug-item {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--r-full, 9999px);
color: var(--c-text-2, #a3a3a3);
font-size: 0.72rem;
font-weight: 600;
padding: 0.2rem 0.6rem;
cursor: pointer;
transition: all 0.2s ease;
}
.sug-item:hover {
background: rgba(184, 134, 11, 0.2);
border-color: rgba(184, 134, 11, 0.4);
color: var(--c-text-1, #ffffff);
}
</style>
<script>
function setupTagsFields() {
document.querySelectorAll('[data-tags-root]').forEach((root) => {
const input = root.querySelector('[data-tags-input]') as HTMLInputElement;
const list = root.querySelector('[data-tags-list]') as HTMLElement;
const suggestions = root.querySelector('[data-tags-suggestions]') as HTMLElement;
if (!input || !list) return;
function addTag(text: string) {
const clean = text.trim();
if (!clean) return;
// Check duplicate
const exists = Array.from(list.querySelectorAll('.tag-pill__name')).some(
(el) => el.textContent?.toLowerCase() === clean.toLowerCase()
);
if (exists) return;
const pill = document.createElement('span');
pill.className = 'tag-pill';
pill.setAttribute('data-tag', clean);
pill.innerHTML = `
<span class="tag-pill__name">${clean}</span>
<button type="button" class="tag-pill__remove" aria-label="Remove tag ${clean}" data-tag-remove>×</button>
`;
list.appendChild(pill);
input.value = '';
}
list.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.matches('[data-tag-remove]')) {
const pill = target.closest('.tag-pill');
if (pill) pill.remove();
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag(input.value);
} else if (e.key === 'Backspace' && !input.value) {
const last = list.querySelector('.tag-pill:last-child');
if (last) last.remove();
}
});
input.addEventListener('focus', () => {
if (suggestions) suggestions.hidden = false;
});
if (suggestions) {
suggestions.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('[data-sug-val]') as HTMLElement;
if (btn) {
const val = btn.getAttribute('data-sug-val');
if (val) addTag(val);
}
});
}
});
}
setupTagsFields();
document.addEventListener('astro:page-load', setupTagsFields);
</script> ---
/**
* TagsAutocompleteInput — Tag entry field with pill badges, keyboard deletion, and suggestion dropdown.
*
* @param {string} id - HTML ID for the tag input.
* @param {string} label - Input label. Default is 'Tags'.
* @param {string[]} initialTags - Pre-populated list of tags. Default is ['Astro', 'TypeScript'].
* @param {string[]} suggestions - Autocomplete candidate options.
* @param {string} placeholder - Input placeholder. Default is 'Add tag & press Enter...'.
*/
interface Props {
id: string;
label?: string;
initialTags?: string[];
suggestions?: string[];
placeholder?: string;
}
const {
id,
label = 'Tags',
initialTags = ['Astro', 'TypeScript'],
suggestions = ['React', 'Tailwind', 'Three.js', 'SSR', 'Vite', 'GraphQL'],
placeholder = 'Add tag & press Enter...',
} = Astro.props;
---
<div class="tags-field" data-tags-root>
<label for={id} class="tags-field__label">{label}</label>
<div class="tags-field__box" data-tags-box>
<div class="tags-field__list" data-tags-list>
{initialTags.map((tag) => (
<span class="tag-pill" data-tag={tag}>
<span class="tag-pill__name">{tag}</span>
<button type="button" class="tag-pill__remove" aria-label={`Remove tag ${tag}`} data-tag-remove>×</button>
</span>
))}
</div>
<input
type="text"
id={id}
placeholder={placeholder}
class="tags-field__input"
data-tags-input
autocomplete="off"
/>
</div>
<div class="tags-field__suggestions" data-tags-suggestions hidden>
{suggestions.map((sug) => (
<button type="button" class="sug-item" data-sug-val={sug}>
+ {sug}
</button>
))}
</div>
</div>
<style>
.tags-field {
display: flex;
flex-direction: column;
gap: var(--sp-2, 0.5rem);
width: 100%;
max-width: 440px;
font-family: var(--font-sans, system-ui, sans-serif);
position: relative;
}
.tags-field__label {
font-size: 0.85rem;
font-weight: 600;
color: var(--c-text-1, #ffffff);
}
.tags-field__box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--r-md, 12px);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
min-height: 44px;
}
.tags-field__box:focus-within {
border-color: rgba(184, 134, 11, 0.5);
box-shadow: 0 0 0 3px rgba(184, 134, 11, 0.15);
}
.tags-field__list {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0.25rem 0.65rem;
background: rgba(184, 134, 11, 0.15);
border: 1px solid rgba(184, 134, 11, 0.35);
border-radius: var(--r-full, 9999px);
font-size: 0.75rem;
font-weight: 600;
color: var(--c-accent, #fef3c7);
user-select: none;
animation: pill-pop 0.2s ease-out;
}
@keyframes pill-pop {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.tag-pill__remove {
background: transparent;
border: none;
color: var(--c-accent, #fef3c7);
font-size: 1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
opacity: 0.7;
transition: opacity 0.2s;
}
.tag-pill__remove:hover {
opacity: 1;
color: #ef4444;
}
.tags-field__input {
flex: 1;
min-width: 140px;
background: transparent;
border: none;
color: var(--c-text-1, #ffffff);
font-size: 0.88rem;
outline: none;
padding: 0.2rem 0;
}
.tags-field__input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.tags-field__suggestions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
padding: 0.4rem;
background: rgba(12, 12, 16, 0.9);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--r-sm, 8px);
}
.sug-item {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--r-full, 9999px);
color: var(--c-text-2, #a3a3a3);
font-size: 0.72rem;
font-weight: 600;
padding: 0.2rem 0.6rem;
cursor: pointer;
transition: all 0.2s ease;
}
.sug-item:hover {
background: rgba(184, 134, 11, 0.2);
border-color: rgba(184, 134, 11, 0.4);
color: var(--c-text-1, #ffffff);
}
</style>
<script>
function setupTagsFields() {
document.querySelectorAll('[data-tags-root]').forEach((root) => {
const input = root.querySelector('[data-tags-input]') as HTMLInputElement;
const list = root.querySelector('[data-tags-list]') as HTMLElement;
const suggestions = root.querySelector('[data-tags-suggestions]') as HTMLElement;
if (!input || !list) return;
function addTag(text: string) {
const clean = text.trim();
if (!clean) return;
// Check duplicate
const exists = Array.from(list.querySelectorAll('.tag-pill__name')).some(
(el) => el.textContent?.toLowerCase() === clean.toLowerCase()
);
if (exists) return;
const pill = document.createElement('span');
pill.className = 'tag-pill';
pill.setAttribute('data-tag', clean);
pill.innerHTML = `
<span class="tag-pill__name">${clean}</span>
<button type="button" class="tag-pill__remove" aria-label="Remove tag ${clean}" data-tag-remove>×</button>
`;
list.appendChild(pill);
input.value = '';
}
list.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.matches('[data-tag-remove]')) {
const pill = target.closest('.tag-pill');
if (pill) pill.remove();
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag(input.value);
} else if (e.key === 'Backspace' && !input.value) {
const last = list.querySelector('.tag-pill:last-child');
if (last) last.remove();
}
});
input.addEventListener('focus', () => {
if (suggestions) suggestions.hidden = false;
});
if (suggestions) {
suggestions.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('[data-sug-val]') as HTMLElement;
if (btn) {
const val = btn.getAttribute('data-sug-val');
if (val) addTag(val);
}
});
}
});
}
setupTagsFields();
document.addEventListener('astro:page-load', setupTagsFields);
</script>
Quick Info
- Category
- Forms
- Filename
TagsAutocompleteInput.astro- Dependencies
- None — pure Astro + CSS
- Tags
- formtagsinputautocompletepills