File Upload

Components used to allow users to upload files to your application.

Credits

Getting Started

Installation

Add the following packages to your project

npx ni @vueuse/core

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

No image uploaded

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)

image-01.jpg
image-02.jpg
image-03.jpg
image-04.jpg

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

image-01.jpg

1.46MB

image-02.jpg

image-02.jpg

2.24MB

image-03.jpg

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

All filesMax 10 filesUp to 100MB

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)

NameTypeSizeActions
document.pdfPDF516.34KB
intro.zipZIP246.95KB
conclusion.xlsxXLSX344.6KB

Multiple files uploader w/ table

Mixed Content w/ Card

Files (3)

intro.zip

246.95KB

image-01.jpg

image-01.jpg

1.46MB

audio.mp3

1.46MB

Mixed content w/ card