Attachment

Users choose, drop, or paste files, and each accepted file appears as a card with a preview and a remove button. Attachment does not upload; your app sends the files.

Drop, paste, or choose files

PNG, JPG, or PDF up to 5 MB. Three files at most.

<script lang="ts">
    import * as Attachment from '@sivir-ui/svelte/components/attachment';

    let files = $state<File[]>([]);
</script>

<Attachment.Root
    bind:files
    accept="image/png,image/jpeg,.pdf"
    maxFiles={3}
    maxSize={5 * 1024 * 1024}
    class="flex w-full max-w-xl flex-col gap-3"
>
    <div
        class="flex flex-col items-center gap-3 rounded-[var(--radius-lg)] border-[length:var(--border-size)] border-dashed border-border px-6 py-8 text-center"
    >
        <div class="flex flex-col gap-1">
            <p class="text-sm font-label text-foreground">Drop, paste, or choose files</p>
            <p class="text-xs text-foreground-muted">PNG, JPG, or PDF up to 5 MB. Three files at most.</p>
        </div>
        <Attachment.Trigger variant="outline">Choose files</Attachment.Trigger>
    </div>
    <Attachment.List class="grid-cols-1" />
</Attachment.Root>

Installation

Copy the command below and run it in your terminal.

bunx @sivir-ui/svelte add attachment

Usage

Bind files on Root to read the selected File objects. Files arrive from the picker, from a drop anywhere on Root, or from a paste into a focused field inside it. Duplicates and files that break accept, maxSize, or maxFiles are not added and are passed to onReject. Your app owns uploading and upload status.

import * as Attachment from '@sivir-ui/svelte/components/attachment';
import type { AttachmentRejection } from '@sivir-ui/svelte/components/attachment';

let files: File[] = $state([]);

function handleReject(rejections: AttachmentRejection[]) {
  console.log(rejections);
}

<Attachment.Root
  bind:files
  accept="image/*,.pdf"
  maxFiles={3}
  maxSize={5 * 1024 * 1024}
  onReject={handleReject}
>
  <Attachment.Trigger>Choose files</Attachment.Trigger>
  <Attachment.List />
</Attachment.Root>

While files are dragged over the root, an overlay confirms the drop. When the drag already breaks a rule the browser can check, such as the file limit or a MIME type in accept, the overlay says why before the drop. Set addOnPaste={false} to leave pasted files to your own handler.

Composition

List renders a default Item for each file. Pass a children snippet to render your own item, which receives the file. An Item without children renders Preview, Name, Status, and Remove. Compose them yourself to omit, reorder, or add controls; extra children sit beside the remove button.

import * as Attachment from '@sivir-ui/svelte/components/attachment';

<Attachment.List>
  {#snippet children(file)}
    <Attachment.Item
      {file}
      status={uploads.get(file)?.status}
      progress={uploads.get(file)?.progress}
    >
      <Attachment.Preview />
      <Attachment.Name />
      <Attachment.Status />
      <Attachment.Remove />
    </Attachment.Item>
  {/snippet}
</Attachment.List>

Inside a root, Remove removes the file from the bound list and moves focus to a neighboring file, or back to the trigger when the list empties. Pass onRemove to an item to handle removal yourself. Items animate in and out and the list reflows, using the theme's motion tokens. Reduced motion turns the animation off.

The list is a grid that fits as many columns of at least 14rem as the space allows and falls back to one column on narrow screens. Add grid-cols-1 to its class for full-width rows.

Examples

Upload progress

<script lang="ts">
    import RotateCw from '@lucide/svelte/icons/rotate-cw';
    import type { AttachmentStatus } from '@sivir-ui/svelte/components/attachment';
    import * as Attachment from '@sivir-ui/svelte/components/attachment';
    import { Button } from '@sivir-ui/svelte/components/button';
    import { onDestroy } from 'svelte';
    import { SvelteMap } from 'svelte/reactivity';

    type Upload = {
        status: AttachmentStatus;
        progress?: number;
        error?: string;
    };

    let files = $state<File[]>([]);

    const uploads = new SvelteMap<File, Upload>();
    const timers = new Map<File, ReturnType<typeof setInterval>>();
    const attempts = new Map<File, number>();

    function stop(file: File) {
        clearInterval(timers.get(file));
        timers.delete(file);
    }

    function upload(file: File) {
        stop(file);

        const attempt = (attempts.get(file) ?? 0) + 1;
        const failsOnce = attempt === 1 && files.indexOf(file) === 1;
        let progress = 0;

        attempts.set(file, attempt);
        uploads.set(file, {
            status: 'uploading',
            progress
        });

        const timer = setInterval(() => {
            progress = Math.min(100, progress + 6 + Math.random() * 14);

            if (failsOnce && progress >= 45) {
                stop(file);
                uploads.set(file, {
                    status: 'error',
                    error: 'Connection lost'
                });
                return;
            }

            if (progress >= 100) {
                stop(file);
                uploads.set(file, {
                    status: 'complete'
                });
                return;
            }

            uploads.set(file, {
                status: 'uploading',
                progress
            });
        }, 200);

        timers.set(file, timer);
    }

    function setFiles(next: File[]) {
        for (const file of files) {
            if (!next.includes(file)) {
                stop(file);
                uploads.delete(file);
            }
        }

        files = next;

        for (const file of next) {
            if (!uploads.has(file)) {
                upload(file);
            }
        }
    }

    onDestroy(() => {
        for (const file of timers.keys()) {
            stop(file);
        }
    });
</script>

<Attachment.Root bind:files={() => files, setFiles} class="flex w-full max-w-xl flex-col gap-3">
    <Attachment.Trigger variant="outline" class="self-start">Upload files</Attachment.Trigger>
    <Attachment.List class="grid-cols-1">
        {#snippet children(file)}
            {@const current = uploads.get(file)}
            <Attachment.Item
                {file}
                status={current?.status}
                progress={current?.progress}
                error={current?.error}
            >
                <Attachment.Preview />
                <Attachment.Name />
                <Attachment.Status />
                {#if current?.status === 'error'}
                    <Button
                        type="button"
                        variant="ghost"
                        size="icon"
                        aria-label={`Retry ${file.name}`}
                        onclick={() => upload(file)}
                        class="size-7 min-w-7 rounded-full text-foreground-muted hover:text-foreground"
                    >
                        <RotateCw size={14} strokeWidth={2} aria-hidden="true" />
                    </Button>
                {/if}
                <Attachment.Remove />
            </Attachment.Item>
        {/snippet}
    </Attachment.List>
</Attachment.Root>

Keep upload state in a map keyed by file and pass it to each item. In this demo the second file fails its first attempt, and a retry button is composed beside Remove only while the item is in error.

Rejections

Two images, 1 MB each.

<script lang="ts">
    import type { AttachmentRejection } from '@sivir-ui/svelte/components/attachment';
    import * as Attachment from '@sivir-ui/svelte/components/attachment';

    let files = $state<File[]>([]);
    let rejections = $state.raw<AttachmentRejection[]>([]);
</script>

<Attachment.Root
    bind:files
    accept="image/*"
    maxFiles={2}
    maxSize={1024 * 1024}
    onReject={(next) => {
        rejections = next;
    }}
    class="flex w-full max-w-xl flex-col gap-3"
>
    <div class="flex flex-wrap items-center gap-3">
        <Attachment.Trigger variant="outline">Add images</Attachment.Trigger>
        <p class="text-sm text-foreground-muted">Two images, 1 MB each.</p>
    </div>
    {#if rejections.length > 0}
        <ul class="flex flex-col gap-1 text-sm text-error">
            {#each rejections as rejection (rejection.file)}
                <li class="min-w-0 truncate">
                    <span class="font-label">{rejection.file.name}</span>
                    {rejection.reason}
                </li>
            {/each}
        </ul>
    {/if}
    <Attachment.List />
</Attachment.Root>

Each rejection carries a typed code and a reason sentence such as “This file is larger than the 1.0 MB limit.” Show the reason next to the trigger. Root also announces added, removed, and rejected files to screen readers.

In a composer

<script lang="ts">
    import * as Attachment from '@sivir-ui/svelte/components/attachment';
    import * as Composer from '@sivir-ui/svelte/components/composer';

    let files = $state<File[]>([]);
    let value = $state('');

    async function send() {
        await new Promise((resolve) => {
            setTimeout(resolve, 600);
        });
        value = '';
        files = [];
    }
</script>

<Attachment.Root bind:files class="w-full max-w-2xl">
    <Composer.Root bind:value onSubmit={send}>
        <Attachment.List />
        <Composer.Input aria-label="Prompt" placeholder="Paste a screenshot or drop files..." />
        <Composer.Toolbar>
            <Composer.Actions>
                <Attachment.Trigger />
            </Composer.Actions>
            <Composer.Submit />
        </Composer.Toolbar>
    </Composer.Root>
</Attachment.Root>

Wrap the composer in Root so files can be dropped anywhere on it and screenshots can be pasted into the prompt. Place List directly before Composer.Input: inside a composer, attachments render as slim chips on the prompt's first line, and the text moves below them once it wraps.

Upload status

<script lang="ts">
    import type { AttachmentStatus } from '@sivir-ui/svelte/components/attachment';
    import * as Attachment from '@sivir-ui/svelte/components/attachment';
    import { onMount } from 'svelte';

    type Example = {
        file: File;
        status: AttachmentStatus;
        progress?: number;
        error?: string;
    };

    let examples = $state.raw<Example[]>([]);

    onMount(() => {
        examples = [
            {
                file: new File([new Uint8Array(48_000)], 'release-notes.md', {
                    type: 'text/markdown'
                }),
                status: 'ready'
            },
            {
                file: new File([new Uint8Array(620_000)], 'architecture.pdf', {
                    type: 'application/pdf'
                }),
                status: 'uploading',
                progress: 64
            },
            {
                file: new File([new Uint8Array(384_000)], 'audit-log.csv', { type: 'text/csv' }),
                status: 'complete'
            },
            {
                file: new File([new Uint8Array(112_000)], 'secrets.env', { type: 'text/plain' }),
                status: 'error',
                error: 'Blocked by policy'
            }
        ];
    });
</script>

<div class="grid w-full max-w-2xl gap-3 sm:grid-cols-2">
    {#each examples as example (example.file.name)}
        <Attachment.Item
            file={example.file}
            status={example.status}
            progress={example.progress}
            error={example.error}
            removable={false}
        />
    {/each}
</div>

Render standalone items outside a root when another part of your interface owns the file list.

Anatomy

Attachment.Root - Manages selected files and accepts drops and pastes.

Attachment.Trigger - Opens the file picker.

Attachment.List - Lists selected files.

Attachment.Item - Holds one file and its upload state.

Attachment.Preview - Shows an image thumbnail or file icon.

Attachment.Name - Shows the file name.

Attachment.Status - Shows size, progress, or the outcome.

Attachment.Remove - Removes the file.