Components

File Dropzone

A drag-and-drop file manager with renaming, removal, validation, native form support, and callback-driven upload progress.

Preview

More examples

Callback uploads

Connect an asynchronous upload helper and report progress for every file.

File restrictions

Limit file types, count, and size while receiving structured rejection details.

Installation

Install directly from the hosted registry with the shadcn-svelte CLI.

Terminal
npx shadcn-svelte@latest add https://ui.akcodeworks.com/r/file-dropzone.json

Usage

Bind files for application state or provide name to submit the synchronized FileList through a native form. Users can add files by dropping or browsing, inspect file sizes, rename files, and remove individual entries. Pass onUpload to connect any upload helper; call context.onProgress with a value from 0 to 100 and observe context.signal to support cancellation. Uploads can start from each row, through Upload all, or automatically with autoUpload. Use accept, maxFiles, and maxFileSize to reject unsupported selections before upload.

API reference

PropTypeDefaultDescription
filesFile[] (bindable)[]Current selected files; bind this prop to manage the list externally.
onFilesChange(files: File[]) => void—Called whenever files are added, renamed, or removed.
onUpload(file: File, context: FileUploadContext) => Promise<void>—Async upload callback that receives the selected files and upload context.
FileUploadContext.onProgress(progress: number) => void—Reports upload progress for one file or the entire batch.
FileUploadContext.signalAbortSignal—Abort signal used to cancel in-flight upload work.
onUploadComplete(file: File) => void—Called after an upload finishes successfully.
onUploadError(file: File, error: Error) => void—Called when the upload callback throws or rejects.
onReject(rejections: FileRejection[]) => void—Called with files rejected by type, count, or size validation.
autoUploadbooleanfalseStarts the upload callback immediately after valid files are added.
namestring—Name used by the native form file input.
acceptstring—Accepted MIME types or file extensions.
multiplebooleantrueAllows more than one file to be selected.
maxFilesnumberInfinityMaximum number of files allowed in the list.
maxFileSizenumber (bytes)—Maximum allowed size of each file in bytes.
disabledbooleanfalsePrevents interaction when enabled.
dropLabelstringDrop files hereInstruction shown in the drop target.
browseLabelstringor choose filesLabel displayed by the file picker action.
classstring—Tailwind CSS classes applied to the component root.
dropzoneClassstring—Tailwind CSS classes applied to the dropzone.
listClassstring—Tailwind CSS classes applied to the list.
inputPropsHTML input props—Additional attributes forwarded to the native file input.

Source

These files are included in the registry item and copied into the consuming project.

file-dropzone.svelte
<script module lang="ts">	export type FileUploadStatus = 'idle' | 'uploading' | 'complete' | 'error' | 'cancelled';	export type FileDropzoneItem = {		id: string;		file: File;		progress: number;		status: FileUploadStatus;		error?: string;	};	export type FileRejectionReason = 'type' | 'size' | 'count' | 'duplicate';	export type FileRejection = {		file: File;		reason: FileRejectionReason;		message: string;	};	export type FileUploadContext = {		onProgress: (progress: number) => void;		signal: AbortSignal;	};	export type FileUploader = (file: File, context: FileUploadContext) => Promise<void>;	export function formatFileSize(bytes: number) {		if (bytes === 0) return '0 B';		const units = ['B', 'KB', 'MB', 'GB', 'TB'];		const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);		const value = bytes / 1024 ** index;		return `${value >= 10 || index === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;	}</script><script lang="ts">	import CheckIcon from '@lucide/svelte/icons/check';	import FileIcon from '@lucide/svelte/icons/file';	import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';	import PencilIcon from '@lucide/svelte/icons/pencil';	import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';	import UploadIcon from '@lucide/svelte/icons/upload';	import XIcon from '@lucide/svelte/icons/x';	import { onMount } from 'svelte';	import type { HTMLInputAttributes } from 'svelte/elements';	import { SvelteMap, SvelteSet } from 'svelte/reactivity';	import { Button } from '$lib/components/ui/button';	import { Input } from '$lib/components/ui/input';	import { cn } from '$lib/utils.js';	type FileDropzoneProps = {		files?: File[];		onFilesChange?: (files: File[]) => void;		onUpload?: FileUploader;		onUploadComplete?: (file: File) => void;		onUploadError?: (file: File, error: Error) => void;		onReject?: (rejections: FileRejection[]) => void;		autoUpload?: boolean;		name?: string;		accept?: string;		multiple?: boolean;		maxFiles?: number;		maxFileSize?: number;		disabled?: boolean;		dropLabel?: string;		browseLabel?: string;		class?: string;		dropzoneClass?: string;		listClass?: string;		inputProps?: Omit<			HTMLInputAttributes,			'type' | 'files' | 'value' | 'name' | 'accept' | 'multiple' | 'disabled' | 'oninput' | 'class'		>;	};	let {		files = $bindable([]),		onFilesChange,		onUpload,		onUploadComplete,		onUploadError,		onReject,		autoUpload = false,		name,		accept,		multiple = true,		maxFiles = Number.POSITIVE_INFINITY,		maxFileSize,		disabled = false,		dropLabel = 'Drop files here',		browseLabel = 'or choose files',		class: className,		dropzoneClass,		listClass,		inputProps	}: FileDropzoneProps = $props();	const componentId = $props.id();	const inputId = `${componentId}-input`;	const rejectionId = `${componentId}-rejection`;	let itemCounter = 0;	let fileInput = $state<HTMLInputElement | null>(null);	let dragging = $state(false);	let rejectionMessage = $state('');	let editingId = $state<string | null>(null);	let renameValue = $state('');	let items = $state<FileDropzoneItem[]>(files.map(createItem));	const controllers = new SvelteMap<string, AbortController>();	const uploading = $derived(items.some((item) => item.status === 'uploading'));	const canUpload = $derived(		Boolean(onUpload) && items.some((item) => ['idle', 'error', 'cancelled'].includes(item.status))	);	onMount(() => syncNativeInput(files));	function createItem(file: File): FileDropzoneItem {		return {			id: `${componentId}-file-${itemCounter++}`,			file,			progress: 0,			status: 'idle'		};	}	function syncNativeInput(nextFiles: File[]) {		if (!fileInput || typeof DataTransfer === 'undefined') return;		const transfer = new DataTransfer();		for (const file of nextFiles) transfer.items.add(file);		fileInput.files = transfer.files;	}	function commit(nextItems: FileDropzoneItem[]) {		items = nextItems;		files = nextItems.map((item) => item.file);		syncNativeInput(files);		onFilesChange?.(files);	}	function matchesAccept(file: File) {		if (!accept) return true;		return accept.split(',').some((rule) => {			const value = rule.trim().toLowerCase();			if (!value) return false;			if (value.startsWith('.')) return file.name.toLowerCase().endsWith(value);			if (value.endsWith('/*')) return file.type.toLowerCase().startsWith(value.slice(0, -1));			return file.type.toLowerCase() === value;		});	}	function rejectionFor(file: File, reason: FileRejectionReason): FileRejection {		const messages: Record<FileRejectionReason, string> = {			type: `${file.name} is not an accepted file type.`,			size: `${file.name} is larger than ${formatFileSize(maxFileSize ?? 0)}.`,			count: `Only ${maxFiles} file${maxFiles === 1 ? '' : 's'} can be added.`,			duplicate: `${file.name} has already been added.`		};		return { file, reason, message: messages[reason] };	}	function addFiles(incoming: File[]) {		if (disabled || incoming.length === 0) return;		const acceptedItems: FileDropzoneItem[] = [];		const rejections: FileRejection[] = [];		const existing = new SvelteSet(			items.map(({ file }) => `${file.name}:${file.size}:${file.lastModified}`)		);		const limit = multiple ? maxFiles : 1;		for (const file of incoming) {			const signature = `${file.name}:${file.size}:${file.lastModified}`;			if (existing.has(signature)) {				rejections.push(rejectionFor(file, 'duplicate'));			} else if (!matchesAccept(file)) {				rejections.push(rejectionFor(file, 'type'));			} else if (maxFileSize !== undefined && file.size > maxFileSize) {				rejections.push(rejectionFor(file, 'size'));			} else if ((multiple ? items.length : 0) + acceptedItems.length >= limit) {				rejections.push(rejectionFor(file, 'count'));			} else {				const item = createItem(file);				acceptedItems.push(item);				existing.add(signature);			}		}		const baseItems = multiple ? items : [];		commit([...baseItems, ...acceptedItems]);		rejectionMessage = rejections[0]?.message ?? '';		if (rejections.length) onReject?.(rejections);		if (autoUpload && onUpload) {			for (const item of acceptedItems) void uploadItem(item.id);		}	}	function handleSelection(event: Event) {		const input = event.currentTarget as HTMLInputElement;		addFiles(Array.from(input.files ?? []));	}	function handleDrop(event: DragEvent) {		event.preventDefault();		dragging = false;		addFiles(Array.from(event.dataTransfer?.files ?? []));	}	function updateItem(id: string, update: Partial<FileDropzoneItem>) {		items = items.map((item) => (item.id === id ? { ...item, ...update } : item));	}	function removeItem(id: string) {		controllers.get(id)?.abort();		controllers.delete(id);		if (editingId === id) editingId = null;		commit(items.filter((item) => item.id !== id));	}	function clearFiles() {		for (const controller of controllers.values()) controller.abort();		controllers.clear();		editingId = null;		rejectionMessage = '';		commit([]);	}	function beginRename(item: FileDropzoneItem) {		editingId = item.id;		renameValue = item.file.name;	}	function saveRename(event: SubmitEvent, item: FileDropzoneItem) {		event.preventDefault();		const nextName = renameValue.trim();		if (!nextName || nextName === item.file.name) {			editingId = null;			return;		}		const renamedFile = new File([item.file], nextName, {			type: item.file.type,			lastModified: item.file.lastModified		});		commit(			items.map((entry) =>				entry.id === item.id					? { ...entry, file: renamedFile, progress: 0, status: 'idle', error: undefined }					: entry			)		);		editingId = null;	}	async function uploadItem(id: string) {		const item = items.find((entry) => entry.id === id);		if (!item || !onUpload || item.status === 'uploading') return;		const controller = new AbortController();		controllers.set(id, controller);		updateItem(id, { progress: 0, status: 'uploading', error: undefined });		try {			await onUpload(item.file, {				signal: controller.signal,				onProgress: (progress) => {					updateItem(id, { progress: Math.min(100, Math.max(0, progress)) });				}			});			if (controller.signal.aborted) {				updateItem(id, { status: 'cancelled' });				return;			}			updateItem(id, { progress: 100, status: 'complete' });			onUploadComplete?.(item.file);		} catch (cause) {			if (controller.signal.aborted) {				updateItem(id, { status: 'cancelled' });				return;			}			const uploadError = cause instanceof Error ? cause : new Error('Upload failed.');			updateItem(id, { status: 'error', error: uploadError.message });			onUploadError?.(item.file, uploadError);		} finally {			controllers.delete(id);		}	}	async function uploadAll() {		const uploadable = items.filter((item) => ['idle', 'error', 'cancelled'].includes(item.status));		await Promise.all(uploadable.map((item) => uploadItem(item.id)));	}</script><div data-slot="file-dropzone" class={cn('grid gap-4', className)}>	<Input		{...inputProps}		bind:ref={fileInput}		id={inputId}		type="file"		{name}		{accept}		{multiple}		{disabled}		oninput={handleSelection}		class="sr-only"	/>	<button		type="button"		{disabled}		aria-describedby={rejectionMessage ? rejectionId : undefined}		onclick={() => fileInput?.click()}		ondragenter={(event) => {			event.preventDefault();			if (!disabled) dragging = true;		}}		ondragover={(event) => event.preventDefault()}		ondragleave={() => (dragging = false)}		ondrop={handleDrop}		class={cn(			'grid min-h-40 w-full place-items-center rounded-xl border border-dashed border-border bg-muted/20 p-6 text-center transition-colors outline-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50',			dragging && 'border-primary bg-primary/5',			dropzoneClass		)}	>		<span class="grid justify-items-center gap-2">			<span				class="grid size-10 place-items-center rounded-full border border-border bg-background shadow-xs"			>				<UploadIcon class="size-4 text-muted-foreground" aria-hidden="true" />			</span>			<span class="text-sm font-medium text-foreground">{dropLabel}</span>			<span class="text-xs text-muted-foreground">{browseLabel}</span>		</span>	</button>	{#if rejectionMessage}		<p id={rejectionId} class="text-xs text-destructive" role="alert">{rejectionMessage}</p>	{/if}	{#if items.length}		<div class="flex items-center justify-between gap-3">			<p class="text-sm font-medium text-foreground">				{items.length} file{items.length === 1 ? '' : 's'}			</p>			<div class="flex items-center gap-1">				{#if onUpload}					<Button size="sm" onclick={uploadAll} disabled={!canUpload || uploading}>						{#if uploading}<LoaderCircleIcon class="animate-spin" aria-hidden="true" />{/if}						Upload all					</Button>				{/if}				<Button variant="ghost" size="sm" onclick={clearFiles} {disabled}>Clear</Button>			</div>		</div>		<ul class={cn('grid gap-2', listClass)} aria-label="Selected files">			{#each items as item (item.id)}				<li class="rounded-xl border border-border bg-background p-3 shadow-xs">					<div class="flex min-w-0 items-center gap-3">						<span class="grid size-9 shrink-0 place-items-center rounded-lg bg-muted">							<FileIcon class="size-4 text-muted-foreground" aria-hidden="true" />						</span>						<div class="min-w-0 flex-1">							{#if editingId === item.id}								<form class="flex items-center gap-1" onsubmit={(event) => saveRename(event, item)}>									<Input										bind:value={renameValue}										aria-label={`Rename ${item.file.name}`}										class="h-7"									/>									<Button type="submit" variant="ghost" size="icon-sm" aria-label="Save filename">										<CheckIcon aria-hidden="true" />									</Button>									<Button										type="button"										variant="ghost"										size="icon-sm"										aria-label="Cancel rename"										onclick={() => (editingId = null)}									>										<XIcon aria-hidden="true" />									</Button>								</form>							{:else}								<p class="truncate text-sm font-medium text-foreground" title={item.file.name}>									{item.file.name}								</p>								<p class="mt-0.5 text-xs text-muted-foreground">									{formatFileSize(item.file.size)}									{#if item.status === 'complete'}										· Uploaded{/if}									{#if item.status === 'cancelled'}										· Cancelled{/if}								</p>							{/if}						</div>						{#if editingId !== item.id}							<div class="flex shrink-0 items-center gap-1">								{#if onUpload && item.status !== 'complete' && item.status !== 'uploading'}									<Button										variant="ghost"										size="icon-sm"										{disabled}										aria-label={item.status === 'error'											? `Retry ${item.file.name}`											: `Upload ${item.file.name}`}										onclick={() => uploadItem(item.id)}									>										{#if item.status === 'error'}											<RotateCcwIcon aria-hidden="true" />										{:else}											<UploadIcon aria-hidden="true" />										{/if}									</Button>								{/if}								<Button									variant="ghost"									size="icon-sm"									disabled={disabled || item.status === 'uploading'}									aria-label={`Rename ${item.file.name}`}									onclick={() => beginRename(item)}								>									<PencilIcon aria-hidden="true" />								</Button>								<Button									variant="ghost"									size="icon-sm"									{disabled}									aria-label={`Remove ${item.file.name}`}									onclick={() => removeItem(item.id)}								>									<XIcon aria-hidden="true" />								</Button>							</div>						{/if}					</div>					{#if item.status === 'uploading' || item.status === 'complete'}						<div class="mt-3 flex items-center gap-2">							<div								class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted"								role="progressbar"								aria-label={`Upload progress for ${item.file.name}`}								aria-valuemin="0"								aria-valuemax="100"								aria-valuenow={Math.round(item.progress)}							>								<div									class="h-full rounded-full bg-primary transition-[width]"									style:width={`${item.progress}%`}								></div>							</div>							<span class="w-9 text-right text-xs text-muted-foreground tabular-nums">								{Math.round(item.progress)}%							</span>						</div>					{/if}					{#if item.error}						<p class="mt-2 text-xs text-destructive" role="alert">{item.error}</p>					{/if}				</li>			{/each}		</ul>	{/if}</div>
index.ts
export { default as FileDropzone } from './file-dropzone.svelte';export { formatFileSize } from './file-dropzone.svelte';export type {	FileDropzoneItem,	FileRejection,	FileRejectionReason,	FileUploadContext,	FileUploader,	FileUploadStatus} from './file-dropzone.svelte';