File Upload
Components used to allow users to upload files to your application.
Credits
Getting Started
Add Composable
Add the following composable to your composables
directory
Use File Upload
export type FileMetadata = {
name: string;
size: number;
type: string;
url: string;
id: string;
};
export type FileWithPreview = {
file: File | FileMetadata;
id: string;
preview?: string;
};
export type FileUploadOptions = {
maxFiles?: number;
maxSize?: number;
accept?: string;
multiple?: boolean;
initialFiles?: FileMetadata[];
onFilesChange?: (files: FileWithPreview[]) => void;
onFilesAdded?: (files: FileWithPreview[]) => void;
onError?: (errors: string[]) => void;
};
export const useFileUpload = (options: FileUploadOptions = {}) => {
const {
maxFiles = Number.POSITIVE_INFINITY,
maxSize = Number.POSITIVE_INFINITY,
accept = "*",
multiple = false,
initialFiles = [],
onFilesChange,
onFilesAdded,
onError,
} = options;
const files = ref<FileWithPreview[]>(
initialFiles.map((file) => ({
file: markRaw(file), // Prevent deep reactivity on file objects
id: file.id,
preview: file.url,
}))
);
const errors = ref<string[]>([]);
const isDragging = ref(false);
// Debounce error clearing for better UX during rapid operations
const debouncedClearErrors = useDebounceFn(() => {
errors.value = [];
}, 300);
// Cache parsed accept types for better performance
const acceptedTypes = computed(() => {
if (accept === "*") return null;
return accept.split(",").map((type) => type.trim());
});
const ariaLabel = computed(() => {
if (files.value.length > 0) {
return multiple ? "Change files" : "Change file";
}
return multiple ? "Upload files" : "Upload file";
});
const inputRef = shallowRef<HTMLInputElement | null>(null);
const dropzoneRef = shallowRef<HTMLElement | null>(null);
watch(inputRef, (newInput) => {
if (!newInput) return;
configureInput();
});
const configureInput = () => {
if (!inputRef.value) return;
inputRef.value.type = "file";
inputRef.value.className = "sr-only";
inputRef.value.accept = accept;
inputRef.value.multiple = multiple;
inputRef.value.hidden = true;
inputRef.value.name = "file-upload-input";
inputRef.value.id = "file-input-" + Math.random().toString(36).substring(2, 9);
inputRef.value.addEventListener("change", handleFileChange);
};
watch(dropzoneRef, (newDropzone) => {
if (!newDropzone) return;
configureDropzone();
});
const configureDropzone = () => {
if (!dropzoneRef.value) return;
dropzoneRef.value.addEventListener("dragenter", handleDragEnter);
dropzoneRef.value.addEventListener("dragleave", handleDragLeave);
dropzoneRef.value.addEventListener("dragover", handleDragOver);
dropzoneRef.value.addEventListener("drop", handleDrop);
dropzoneRef.value.setAttribute("aria-label", ariaLabel.value);
};
watch(ariaLabel, (newValue) => {
if (!dropzoneRef.value) return;
dropzoneRef.value.setAttribute("aria-label", newValue);
});
const validateFile = (file: File | FileMetadata): string | null => {
if (file instanceof File) {
if (file.size > maxSize) {
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`;
}
} else {
if (file.size > maxSize) {
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`;
}
}
const types = acceptedTypes.value;
if (types) {
const fileType = file instanceof File ? file.type || "" : file.type;
const fileExtension = `.${file instanceof File ? file.name.split(".").pop() : file.name.split(".").pop()}`;
const isAccepted = types.some((type) => {
if (type.startsWith(".")) {
return fileExtension.toLowerCase() === type.toLowerCase();
}
if (type.endsWith("/*")) {
const baseType = type.split("/")[0];
return fileType.startsWith(`${baseType}/`);
}
return fileType === type;
});
if (!isAccepted) {
return `File "${file instanceof File ? file.name : file.name}" is not an accepted file type.`;
}
}
return null;
};
const createPreview = (file: File | FileMetadata): string | undefined => {
if (file instanceof File) {
return URL.createObjectURL(file);
}
return file.url;
};
const generateUniqueId = (file: File | FileMetadata): string => {
if (file instanceof File) {
return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
return file.id;
};
const clearFiles = () => {
files.value.forEach((file) => {
if (file.preview && file.file instanceof File && file.file.type.startsWith("image/")) {
URL.revokeObjectURL(file.preview);
}
});
if (inputRef.value) {
inputRef.value.value = "";
}
files.value = [];
errors.value = [];
onFilesChange?.(files.value);
};
const addFiles = (newFiles: FileList | File[]) => {
if (!newFiles || newFiles.length === 0) return;
const newFilesArray = Array.from(newFiles);
const newErrors: string[] = [];
errors.value = [];
if (!multiple) {
clearFiles();
}
if (multiple && maxFiles !== Infinity && files.value.length + newFilesArray.length > maxFiles) {
newErrors.push(`You can only upload a maximum of ${maxFiles} files.`);
errors.value = newErrors;
onError?.(newErrors);
return;
}
const validFiles: FileWithPreview[] = [];
newFilesArray.forEach((file) => {
const isDuplicate = files.value.some(
(existingFile) =>
existingFile.file.name === file.name && existingFile.file.size === file.size
);
if (isDuplicate) {
return;
}
if (file.size > maxSize) {
newErrors.push(
multiple
? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`
: `File exceeds the maximum size of ${formatBytes(maxSize)}.`
);
return;
}
const error = validateFile(file);
if (error) {
newErrors.push(error);
onError?.(newErrors);
} else {
validFiles.push({
file: markRaw(file), // Prevent deep reactivity on file objects
id: generateUniqueId(file),
preview: createPreview(file),
});
}
});
if (validFiles.length > 0) {
files.value = !multiple ? validFiles : [...files.value, ...validFiles];
errors.value = newErrors;
onFilesChange?.(files.value);
onFilesAdded?.(validFiles);
onError?.(newErrors);
} else if (newErrors.length > 0) {
errors.value = newErrors;
onError?.(newErrors);
}
if (inputRef.value) {
inputRef.value.value = "";
}
};
const removeFile = (id: string | undefined) => {
if (!id) return;
const fileToRemove = files.value.find((file) => file.id === id);
if (
fileToRemove &&
fileToRemove.preview &&
fileToRemove.file instanceof File &&
fileToRemove.file.type.startsWith("image/")
) {
URL.revokeObjectURL(fileToRemove.preview);
}
files.value = files.value.filter((file) => file.id !== id);
debouncedClearErrors(); // Use debounced version
onFilesChange?.(files.value);
};
const clearErrors = () => {
errors.value = [];
};
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (dropzoneRef.value) {
dropzoneRef.value.setAttribute("data-dragging", "true");
isDragging.value = true;
}
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (
e.currentTarget &&
e.relatedTarget &&
(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)
) {
return;
}
if (dropzoneRef.value) {
dropzoneRef.value.removeAttribute("data-dragging");
isDragging.value = false;
}
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (dropzoneRef.value) {
dropzoneRef.value.removeAttribute("data-dragging");
isDragging.value = false;
}
if (inputRef.value?.disabled) {
return;
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
if (!multiple) {
const file = e.dataTransfer.files[0];
if (file) {
addFiles([file]);
}
} else {
addFiles(e.dataTransfer.files);
}
}
};
const handleFileChange = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
addFiles(target.files);
}
};
const openFileDialog = () => {
if (inputRef.value) {
inputRef.value.click();
}
};
watch(
files,
(newFiles) => {
onFilesChange?.(newFiles);
},
{ deep: true }
);
// Cleanup object URLs on unmount to prevent memory leaks
onBeforeUnmount(() => {
files.value.forEach((file) => {
if (file.preview && file.file instanceof File && file.file.type.startsWith("image/")) {
URL.revokeObjectURL(file.preview);
}
});
});
return {
files: readonly(files), // Make files readonly for external consumers
errors: readonly(errors),
addFiles,
removeFile,
clearFiles,
clearErrors,
handleFileChange,
openFileDialog,
onFilesAdded,
inputRef,
dropzoneRef,
isDragging: readonly(isDragging),
};
};
export const formatBytes = (bytes: number, decimals = 2): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + (sizes[i] || "");
};
Usage
Avatar Upload 1
Avatar Upload 2
Upload Avatar
Avatar uploader with droppable area
Single Image Uploader
Drop your image here or click to browse
Max size: 5MB
Single image uploader w/ max size
Single Image Uploader w/ Button
Drop your image here
SVG, PNG, JPG or GIF (max. 2MB)
Single image uploader w/ max size (drop area + button)
Multiple Image Uploader w/ Grid
Uploaded Files (4)
Multiple image uploader w/ image grid
Multiple Image Uploader w/ List + Button
Drop your images here
SVG, PNG, JPG or GIF (max. 5MB)
image-01.jpg
1.46MB
image-02.jpg
2.24MB
image-03.jpg
3.3MB
Multiple image uploader w/ image list
Single File Uploader
Multiple File Uploader
Upload files
Drag & drop or click to browse
document.pdf
516.34KB
intro.zip
246.95KB
conclusion.xlsx
344.6KB
Multiple files uploader w/ list
Multiple File Uploader w/ List Inside
Uploaded Files (3)
document.pdf
516.34KB
intro.zip
246.95KB
conclusion.xlsx
344.6KB
Multiple files uploader w/ list inside
Multiple File Uploader w/ Table
Files (3)
Name | Type | Size | Actions |
---|---|---|---|
516.34KB | |||
ZIP | 246.95KB | ||
XLSX | 344.6KB |
Multiple files uploader w/ table
Mixed Content w/ Card
Files (3)
intro.zip
246.95KB
image-01.jpg
1.46MB
audio.mp3
1.46MB
Mixed content w/ card