Tag Input

Enter or a comma adds the draft as a tag, and Backspace in an empty field removes the last one. Pasting text separated by commas, semicolons, or new lines adds each part.

Type a topic and press Enter.
<script lang="ts">
    import * as TagInput from '@sivir-ui/svelte/components/tag-input';

    let tags = $state(['svelte', 'design-system', 'tailwind']);
</script>

<TagInput.Root
    bind:tags
    label="Topics"
    description="Type a topic and press Enter."
>
    <TagInput.List />
    <TagInput.Input placeholder="Add a topic…" />
</TagInput.Root>

Installation

Copy the command below and run it in your terminal.

bunx @sivir-ui/svelte add tag-input

Usage

Bind tags on Root, and place List and Input inside it. Duplicates are rejected unless you set allowDuplicates. Set name to submit each tag with a form.

import * as TagInput from '@sivir-ui/svelte/components/tag-input';

let tags = $state(['svelte']);

<TagInput.Root bind:tags label="Topics">
  <TagInput.List />
  <TagInput.Input placeholder="Add a topic…" />
</TagInput.Root>

Examples

Responding to changes

onAdd and onRemove report single-tag edits; onTagsChange reports the whole list.

Release alerts post to these channels.
No changes yet
<script lang="ts">
    import { Button } from '@sivir-ui/svelte/components/button';
    import * as TagInput from '@sivir-ui/svelte/components/tag-input';
    import * as Typography from '@sivir-ui/svelte/components/typography';

    let tags = $state(['announcements']);
    let lastChange = $state('No changes yet');
</script>

<div class="flex w-full max-w-md flex-col gap-3">
    <TagInput.Root
        bind:tags
        label="Channels"
        description="Release alerts post to these channels."
        onAdd={(tag) => {
            lastChange = `Added ${tag}`;
        }}
        onRemove={(tag) => {
            lastChange = `Removed ${tag}`;
        }}
    >
        <TagInput.List />
        <TagInput.Input placeholder="Add a channel…" />
    </TagInput.Root>

    <div class="flex items-center justify-between gap-3">
        <Typography.Metadata aria-live="polite">{lastChange}</Typography.Metadata>
        <Button
            variant="ghost"
            disabled={tags.length === 0}
            onclick={() => {
                tags = [];
                lastChange = 'Cleared all channels';
            }}
        >
            Clear all
        </Button>
    </div>
</div>

Validation

Return false or an error message from validate to reject a tag. onReject receives the reason; pass it to error to show it under the field.

Lowercase, with hyphens. Try adding “Release Notes”.
<script lang="ts">
    import * as TagInput from '@sivir-ui/svelte/components/tag-input';

    let tags = $state<string[]>([]);
    let error = $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}
        {error}
        label="Slugs"
        description="Lowercase, with hyphens. Try adding “Release Notes”."
        onAdd={() => {
            error = '';
        }}
        onReject={(rejection) => {
            error = rejection.reason;
        }}
    >
        <TagInput.List />
        <TagInput.Input placeholder="e.g. release-notes" />
    </TagInput.Root>
</div>

Limiting tags

max caps the list. Extra tags are rejected with the code max-tags.

Up to 5 labels.
<script lang="ts">
    import * as TagInput from '@sivir-ui/svelte/components/tag-input';

    let tags = $state(['svelte', 'sivir', 'ui', 'forms']);
    let error = $state('');
</script>

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

Anatomy

TagInput.Root - Owns tags and draft entry state.

TagInput.List - Groups the entered tags.

TagInput.Tag - Renders one tag with its remove control.

TagInput.Input - Accepts new tag text.