# Tag Input

Tokenized tag entry field with keyboard, paste, and validation support. Compound: Root / List / Tag / Input.

- Package: `@mielui/svelte`
- Component version: `1.1.0`
- Depends on Mielui components: _internal/utils, button, badge
- Shared utilities: components/_internal/button-attributes, components/_internal/field-metadata, hugeicons-icon, utils.cn, utils.createContext

## Install

~~~~sh
pnpm dlx @mielui/svelte add tag-input
~~~~

## API

This reference is generated at build time from the component manifest, public `index.ts`, and documentation examples below. Changes to those source files are reflected here in the published Markdown. Standard Svelte and HTML attributes accepted by the exported prop types are supported.


### Input

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| placeholder | string \| undefined | 'Add a tag…' | No | No | Text shown while there is no value. |
| element | HTMLInputElement \| undefined | undefined | No | Yes | Bindable reference to the DOM element. |
| class | string \| undefined | - | No | No | Classes added to the element. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### List

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| label | string \| undefined | 'Tags' | No | No | Accessible name of the tag list. |
| class | string \| undefined | - | No | No | Classes added to the element. |
| children | Snippet<[]> \| undefined | - | No | No | Content rendered inside. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Root

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| labels | TagInputLabels \| undefined | - | No | No | Overrides the built-in text and accessible names. Every key is optional and English is the fallback. |
| tags | string[] \| undefined | [] | No | Yes | Current tags. Bindable. |
| query | string \| undefined | '' | No | Yes | Text in the input. Bindable. |
| max | number \| undefined | - | No | No | Largest number of tags. |
| allowDuplicates | boolean \| undefined | false | No | No | Allows the same tag more than once. |
| disabled | boolean \| undefined | false | No | No | Prevents interaction and dims the control. |
| label | string \| undefined | - | No | No | Visible label linked to the control. |
| description | string \| undefined | - | No | No | Supporting text under the label, announced with the control. |
| invalid | boolean \| undefined | - | No | No | Marks the field invalid. It defaults to true when there are issues. |
| issues | readonly Readonly<{ message: string; }>[] \| undefined | [] | No | No | Validation issues shown under the control in place of the description. |
| name | string \| undefined | - | No | No | Field name submitted with the form. |
| required | boolean \| undefined | false | No | No | Requires at least one tag for native form submission. Carried by a dedicated validation anchor, since hidden inputs are barred from constraint validation. |
| variant | TagInputVariant \| undefined | 'outline' | No | No | `outline` has a border. `secondary` has a filled background. |
| validate | ((tag: string) => string \| boolean) \| undefined | - | No | No | Returns true to accept a tag, or a message that explains why it is refused. |
| normalize | ((tag: string) => string) \| undefined | - | No | No | Cleans a tag before it is added, such as trimming or lowercasing it. |
| delimiters | string[] \| undefined | [','] | No | No | Characters that finish a tag as you type. |
| addOnBlur | boolean \| undefined | true | No | No | Adds the typed text as a tag when the field loses focus. |
| addOnPaste | boolean \| undefined | true | No | No | Splits pasted text into tags. |
| onTagsChange | ((tags: string[]) => void) \| undefined | - | No | No | Called with the new list when tags change. |
| onAdd | ((tag: string) => void) \| undefined | - | No | No | Called with each tag that is added. |
| onRemove | ((tag: string) => void) \| undefined | - | No | No | Called with each tag that is removed. |
| onReject | ((rejection: TagInputRejection) => void) \| undefined | - | No | No | Called when a tag is refused, with the reason. |
| id | string \| undefined | - | No | No | Id of the element. |
| class | string \| undefined | - | No | No | Classes added to the element. |
| children | Snippet<[]> \| undefined | - | No | No | Content rendered inside. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Tag

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| value | string | - | Yes | No | Text of the tag. |
| index | number \| undefined | - | No | No | Position of the tag in the list. |
| removable | boolean \| undefined | true | No | No | Shows the remove button. |
| onRemove | ((tag: string) => void) \| undefined | - | No | No | Called with each tag that is removed. |
| class | string \| undefined | - | No | No | Classes added to the element. |
| children | Snippet<[]> \| undefined | - | No | No | Content rendered inside. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Public types

~~~~ts
import type { FieldIssue } from '@mielui/svelte/components/field';
import type { Snippet } from 'svelte';
import type { HTMLAttributes, HTMLButtonAttributes, HTMLInputAttributes } from 'svelte/elements';
import Root from './tag-input.svelte';
import Input from './tag-input-input.svelte';
import List from './tag-input-list.svelte';
import Tag from './tag-input-tag.svelte';

export type TagInputRejectionCode = 'duplicate' | 'invalid' | 'max-tags';

export type TagInputRejection = {
    value: string;
    code: TagInputRejectionCode;
    reason: string;
};

export type TagInputVariant = 'outline' | 'secondary';

export type TagInputLabels = {
    /** Accessible name of a tag's remove button. */
    remove?: (tag: string) => string;
    /** Announced when a tag is refused because it is already in the list. */
    duplicate?: (tag: string) => string;
    /** Announced when a tag is refused because the list is full. */
    maxReached?: (max: number) => string;
    /** Announced when `validate` refuses a tag without its own message. */
    invalid?: (tag: string) => string;
    /** Announced after a tag is added. `count` is the new number of tags. */
    added?: (tag: string, count: number) => string;
    /** Announced after a tag is removed. `count` is the new number of tags. */
    removed?: (tag: string, count: number) => string;
    /** Validation message when `required` is set and the list is empty. */
    required?: string;
};

export type TagInputProps = {
    /**
     * Overrides the built-in text and accessible names. Every key is optional and English is the
     * fallback.
     */
    labels?: TagInputLabels;
    /** Current tags. Bindable. */
    tags?: string[];
    /** Text in the input. Bindable. */
    query?: string;
    /** Largest number of tags. */
    max?: number;
    /** Allows the same tag more than once. */
    allowDuplicates?: boolean;
    /** Prevents interaction and dims the control. */
    disabled?: boolean;
    /** Visible label linked to the control. */
    label?: string;
    /** Supporting text under the label, announced with the control. */
    description?: string;
    /** Marks the field invalid. It defaults to true when there are issues. */
    invalid?: boolean;
    /** Validation issues shown under the control in place of the description. */
    issues?: readonly FieldIssue[];
    /** Field name submitted with the form. */
    name?: string;
    /**
     * Requires at least one tag for native form submission. Carried by a
     * dedicated validation anchor, since hidden inputs are barred from
     * constraint validation.
     */
    required?: boolean;
    /** `outline` has a border. `secondary` has a filled background. */
    variant?: TagInputVariant;
    /** Returns true to accept a tag, or a message that explains why it is refused. */
    validate?: (tag: string) => boolean | string;
    /** Cleans a tag before it is added, such as trimming or lowercasing it. */
    normalize?: (tag: string) => string;
    /** Characters that finish a tag as you type. */
    delimiters?: string[];
    /** Adds the typed text as a tag when the field loses focus. */
    addOnBlur?: boolean;
    /** Splits pasted text into tags. */
    addOnPaste?: boolean;
    /** Called with the new list when tags change. */
    onTagsChange?: (tags: string[]) => void;
    /** Called with each tag that is added. */
    onAdd?: (tag: string) => void;
    /** Called with each tag that is removed. */
    onRemove?: (tag: string) => void;
    /** Called when a tag is refused, with the reason. */
    onReject?: (rejection: TagInputRejection) => void;
    /** Id of the element. */
    id?: string;
    /** Classes added to the element. */
    class?: string;
    /** Content rendered inside. */
    children?: Snippet;
} & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'id'>;

export type TagInputListProps = {
    /** Accessible name of the tag list. */
    label?: string;
    /** Classes added to the element. */
    class?: string;
    /** Content rendered inside. */
    children?: Snippet;
} & Omit<HTMLAttributes<HTMLUListElement>, 'children' | 'class'>;

export type TagInputTagProps = {
    /** Text of the tag. */
    value: string;
    /** Position of the tag in the list. */
    index?: number;
    /** Shows the remove button. */
    removable?: boolean;
    /** Called with each tag that is removed. */
    onRemove?: (tag: string) => void;
    /** Classes added to the element. */
    class?: string;
    /** Content rendered inside. */
    children?: Snippet;
} & Omit<HTMLButtonAttributes, 'children' | 'class' | 'type' | 'value' | 'onclick'>;

export type TagInputInputProps = {
    /** Text shown while there is no value. */
    placeholder?: string;
    /** Bindable reference to the DOM element. */
    element?: HTMLInputElement | undefined;
    /** Classes added to the element. */
    class?: string;
} & Omit<HTMLInputAttributes, 'children' | 'class' | 'value'>;

export { Input, List, Root, Tag };
~~~~

## Examples

### Adding

~~~~svelte
<script lang="ts">
    import * as TagInput from '@mielui/svelte/components/tag-input';

    let tags = $state(['design']);
    let query = $state('');
</script>

<div class="w-full max-w-md space-y-3">
    <TagInput.Root
        bind:tags
        bind:query
        label="Labels"
        description="Space, comma or Enter adds a label. Text is lowercased."
        delimiters={[',', ' ']}
        normalize={(tag) => tag.trim().toLowerCase()}
        addOnBlur={false}
    >
        <TagInput.List />
        <TagInput.Input placeholder="Type Bug Fix and press space" />
    </TagInput.Root>
    <p class="text-sm text-foreground-muted">Still typing: {query === '' ? 'nothing' : query}</p>
</div>
~~~~

### Controlled

~~~~svelte
<script lang="ts">
    import * as TagInput from '@mielui/svelte/components/tag-input';

    let tags = $state(['announcements']);
    let events = $state<string[]>([]);

    function log(message: string) {
        events = [message, ...events].slice(0, 4);
    }
</script>

<div class="w-full max-w-md space-y-3">
    <TagInput.Root
        bind:tags
        label="Channels"
        description="Every change reports through callbacks."
        onAdd={(tag) => {
            log(`Added ${tag}`);
        }}
        onRemove={(tag) => {
            log(`Removed ${tag}`);
        }}
    >
        <TagInput.List />
        <TagInput.Input placeholder="Add a channel…" />
    </TagInput.Root>

    <ul aria-live="polite" class="space-y-1 text-sm text-foreground-muted">
        {#each events as event, index (index)}
            <li>{event}</li>
        {/each}
    </ul>
</div>
~~~~

### Hero

~~~~svelte
<script lang="ts">
    import { numberShuffle } from '@mielui/svelte/actions/number-shuffle';
    import { Button } from '@mielui/svelte/components/button';
    import * as TagInput from '@mielui/svelte/components/tag-input';

    let tags = $state(['svelte', 'design-system']);
    let message = $state('');

    function save() {
        message = tags.length ? `Saved topics: ${tags.join(', ')}.` : 'Saved without topics.';
    }
</script>

<div class="w-full max-w-md space-y-4">
    <TagInput.Root bind:tags label="Topics" description="Type a topic and press Enter.">
        <TagInput.List />
        <TagInput.Input placeholder="Add a topic…" />
    </TagInput.Root>

    <div class="flex items-center justify-between gap-3">
        <p class="text-sm text-foreground-muted">
            <span use:numberShuffle={{ value: tags.length }}>{tags.length}</span>
            topics
        </p>
        <div class="flex gap-2">
            <Button
                variant="ghost"
                onclick={() => {
                    tags = [];
                }}
            >
                Clear
            </Button>
            <Button onclick={save}>Save</Button>
        </div>
    </div>
    <p role="status" class="text-sm text-foreground-muted">{message}</p>
</div>
~~~~

### Max Tags

~~~~svelte
<script lang="ts">
    import * as TagInput from '@mielui/svelte/components/tag-input';

    let tags = $state(['svelte', 'mielui']);
    let notice = $state('');
</script>

<div class="w-full max-w-md space-y-2">
    <TagInput.Root
        bind:tags
        max={5}
        label="Labels"
        description="Up to 5 labels."
        onAdd={() => {
            notice = '';
        }}
        onRemove={() => {
            notice = '';
        }}
        onReject={(rejection) => {
            notice = rejection.reason;
        }}
    >
        <TagInput.List />
        <TagInput.Input placeholder="Add a label…" />
    </TagInput.Root>

    {#if notice}
        <p class="text-sm text-foreground-muted">{notice}</p>
    {/if}
</div>
~~~~

### Required

~~~~svelte
<script lang="ts">
    import { Button } from '@mielui/svelte/components/button';
    import * as TagInput from '@mielui/svelte/components/tag-input';

    let tags = $state<string[]>([]);
    let result = $state('Not submitted');
</script>

<form
    class="w-full max-w-md space-y-3"
    onsubmit={(event) => {
        event.preventDefault();
        result = `Submitted with ${tags.join(', ')}`;
    }}
>
    <TagInput.Root
        bind:tags
        name="reviewers"
        label="Reviewers"
        required
        labels={{ required: 'Add at least one reviewer.' }}
    >
        <TagInput.List />
        <TagInput.Input placeholder="Add a reviewer" />
    </TagInput.Root>
    <div class="flex items-center justify-between gap-3">
        <p class="text-sm text-foreground-muted">{result}</p>
        <Button type="submit">Request review</Button>
    </div>
</form>
~~~~

### Validation

~~~~svelte
<script lang="ts">
    import * as TagInput from '@mielui/svelte/components/tag-input';

    let tags = $state<string[]>([]);
    let reason = $state('');

    function validate(tag: string) {
        if (tag !== tag.toLowerCase()) {
            return 'Use lowercase letters.';
        }

        if (tag.includes(' ')) {
            return 'Use hyphens instead of spaces.';
        }

        return tag.length >= 2 || 'Tags need at least 2 characters.';
    }
</script>

<div class="w-full max-w-md">
    <TagInput.Root
        bind:tags
        {validate}
        issues={reason ? [{ message: reason }] : undefined}
        label="Slugs"
        description={'Use lowercase slugs without spaces. Try "Release Notes".'}
        onAdd={() => {
            reason = '';
        }}
        onReject={(rejection) => {
            reason = rejection.reason;
        }}
    >
        <TagInput.List />
        <TagInput.Input placeholder="e.g. release-notes" />
    </TagInput.Root>
</div>
~~~~

For the rendered reference, visit [/docs/components/tag-input](/docs/components/tag-input).
