Uploading files is a critical feature in many web applications. However, the native file input element offered by browsers often lacks the aesthetics and enhanced functionality modern users expect. In this DevTip, we demonstrate how to build a custom file uploader using JavaScript and HTML to create a sleek, robust, and user-friendly experience.

An illustration depicting file uploading.

Understanding file uploaders

A file uploader is a component that enables users to select and upload files to a server. While the default HTML file input is functional, it offers little in customization or rich user experience. Building a custom file uploader allows you to:

  • Provide a consistent look and feel that aligns with your application design.
  • Enhance usability with features like drag-and-drop support.
  • Offer real-time feedback with progress indicators and error messages.

Setting up the HTML structure

Begin by creating the basic HTML structure for your custom file uploader. The following example demonstrates a clickable and drag-and-drop zone for file selection:

<!-- index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Custom File Uploader</title>
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div class="file-uploader">
      <p>Drag and drop files here or click to upload</p>
      <input type="file" id="file-input" multiple />
      <div class="upload-progress"></div>
    </div>

    <script src="script.js"></script>
  </body>
</html>

In this setup:

  • The .file-uploader div serves as both the drop zone and clickable area.
  • The hidden input element facilitates file selection.
  • The .upload-progress div displays upload progress for each file.

Styling the file uploader with CSS

Enhance the visual appeal and interactive feedback with the following CSS:

/* styles.css */
.file-uploader {
  border: 2px dashed #ccc;
  padding: 20px;
  text-align: center;
  cursor: pointer;
  position: relative;
  border-radius: 8px;
  transition: background-color 0.3s ease;
}

.file-uploader.dragover {
  background-color: #f0f0f0;
  border-color: #007bff;
}

.file-uploader input[type='file'] {
  display: none;
}

.upload-progress {
  margin-top: 20px;
}

.progress-bar {
  background-color: #007bff;
  height: 24px;
  margin-bottom: 8px;
  color: #fff;
  line-height: 24px;
  padding: 0 12px;
  width: 0%;
  transition: width 0.3s ease;
  border-radius: 4px;
  font-size: 14px;
}

.progress-bar.upload-complete {
  background-color: #28a745;
}

.progress-bar.upload-error {
  background-color: #dc3545;
}

Implementing JavaScript to handle file selection and upload

The JavaScript implementation below uses the Fetch API for a modern, robust approach to file uploads, including chunked upload support, comprehensive error handling, and CSRF protection.

class FileUploader {
  constructor() {
    this.fileUploader = document.querySelector('.file-uploader')
    this.fileInput = document.getElementById('file-input')
    this.progressContainer = document.querySelector('.upload-progress')
    this.maxFileSize = 10 * 1024 * 1024 // 10MB
    this.allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
    this.csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
    this.retryAttempts = 3
    this.chunkSize = 1024 * 1024 // 1MB chunks

    this.initializeEventListeners()
  }

  initializeEventListeners() {
    this.fileUploader.addEventListener('click', () => this.fileInput.click())
    this.fileInput.addEventListener('change', (event) => this.handleFiles(event.target.files))
    this.fileUploader.addEventListener('dragover', this.handleDragOver.bind(this))
    this.fileUploader.addEventListener('dragleave', this.handleDragLeave.bind(this))
    this.fileUploader.addEventListener('drop', this.handleDrop.bind(this))
  }

  handleDragOver(event) {
    event.preventDefault()
    this.fileUploader.classList.add('dragover')
  }

  handleDragLeave() {
    this.fileUploader.classList.remove('dragover')
  }

  handleDrop(event) {
    event.preventDefault()
    this.fileUploader.classList.remove('dragover')
    this.handleFiles(event.dataTransfer.files)
  }

  async handleFiles(files) {
    for (const file of files) {
      if (this.validateFile(file)) {
        await this.uploadFileWithChunks(file)
      }
    }
  }

  validateFile(file) {
    if (!this.allowedTypes.includes(file.type)) {
      this.showError(`${file.name} is not an allowed file type.`)
      return false
    }

    if (file.size > this.maxFileSize) {
      this.showError(`${file.name} exceeds the maximum file size of 10MB.`)
      return false
    }

    return true
  }

  async uploadFileWithChunks(file) {
    const progressBar = this.createProgressBar(file.name)
    const chunks = Math.ceil(file.size / this.chunkSize)
    let uploadedChunks = 0

    for (let i = 0; i < chunks; i++) {
      const start = i * this.chunkSize
      const end = Math.min(start + this.chunkSize, file.size)
      const chunk = file.slice(start, end)

      try {
        await this.uploadChunk(chunk, i, chunks, file.name)
        uploadedChunks++
        const progress = (uploadedChunks / chunks) * 100
        this.updateProgress(progressBar, progress)
      } catch (error) {
        if (await this.handleUploadError(error, progressBar)) {
          i-- // Retry the current chunk
        } else {
          break
        }
      }
    }

    if (uploadedChunks === chunks) {
      await this.finalizeUpload(file.name, progressBar)
    }
  }

  async uploadChunk(chunk, chunkIndex, totalChunks, fileName) {
    const formData = new FormData()
    formData.append('chunk', chunk)
    formData.append('chunkIndex', chunkIndex)
    formData.append('totalChunks', totalChunks)
    formData.append('fileName', fileName)

    const response = await fetch('/upload/chunk', {
      method: 'POST',
      headers: {
        'X-CSRF-Token': this.csrfToken,
      },
      body: formData,
    })

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`)
    }

    return response.json()
  }

  async finalizeUpload(fileName, progressBar) {
    try {
      const response = await fetch('/upload/finalize', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': this.csrfToken,
        },
        body: JSON.stringify({ fileName }),
      })

      if (response.ok) {
        progressBar.classList.add('upload-complete')
        progressBar.textContent = `${fileName} uploaded successfully`
      } else {
        throw new Error('Failed to finalize upload')
      }
    } catch (error) {
      this.handleUploadError(error, progressBar)
    }
  }

  createProgressBar(fileName) {
    const progressBar = document.createElement('div')
    progressBar.classList.add('progress-bar')
    progressBar.dataset.fileName = fileName
    progressBar.textContent = `Uploading ${fileName}`
    this.progressContainer.appendChild(progressBar)
    return progressBar
  }

  updateProgress(progressBar, percent) {
    progressBar.style.width = `${percent}%`
    progressBar.textContent = `${progressBar.dataset.fileName} - ${percent.toFixed(1)}%`
  }

  async handleUploadError(error, progressBar) {
    console.error('Upload error:', error)
    progressBar.classList.add('upload-error')

    if (progressBar.dataset.retryCount === undefined) {
      progressBar.dataset.retryCount = '0'
    }

    const retryCount = parseInt(progressBar.dataset.retryCount)
    if (retryCount < this.retryAttempts) {
      progressBar.dataset.retryCount = (retryCount + 1).toString()
      progressBar.textContent = `Retrying... (${retryCount + 1}/${this.retryAttempts})`
      await new Promise((resolve) => setTimeout(resolve, 1000))
      progressBar.classList.remove('upload-error')
      return true
    }

    progressBar.textContent = 'Upload failed after multiple attempts'
    return false
  }

  showError(message) {
    const errorBar = document.createElement('div')
    errorBar.classList.add('progress-bar', 'upload-error')
    errorBar.textContent = message
    this.progressContainer.appendChild(errorBar)
    setTimeout(() => errorBar.remove(), 5000)
  }
}

// Initialize the uploader when the DOM is ready
document.addEventListener('DOMContentLoaded', () => new FileUploader())

Testing the file uploader

To ensure reliable performance:

  • Test with various file types and sizes.
  • Verify the accuracy of chunked upload functionality.
  • Simulate network errors to validate retry logic.
  • Confirm proper CSRF token handling.
  • Test the drag-and-drop functionality across different browsers.
  • Check that progress indicators update accurately.
  • Ensure compatibility with modern browsers such as Chrome, Firefox, Edge, and Safari.

Conclusion

This modern implementation of a custom file uploader delivers a robust solution featuring chunked uploads, comprehensive error handling, and real-time progress tracking. By leveraging modern web APIs like Fetch, FormData, and the File API, you can provide a smooth upload experience while ensuring security with CSRF protection.

For a production-ready solution with additional features such as resumable uploads and cloud storage integration, consider using Uppy 4.x (see the Migration Guide)—a powerful, open-source file uploader maintained by Transloadit.