Better Upload

Upload Dropzone with Progress

A dropzone that uploads multiple files. It shows the progress of each file upload.

Demo

PDF

invoice_123.pdf

2 MB

Completed

IMG

photo.jpeg

12 MB

Installation

npx shadcn@latest add "https://better-upload.com/r/upload-dropzone-progress.json"

Install the following dependencies:

npm install lucide-react react-dropzone

Make sure to have shadcn/ui set up in your project, with the progress component installed.

Copy and paste the following code into your project.

import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import type { UploadHookControl } from 'better-upload/client';
import { formatBytes } from 'better-upload/client/helpers';
import { Dot, File, Upload } from 'lucide-react';
import { useId } from 'react';
import { useDropzone } from 'react-dropzone';

type UploadDropzoneProgressProps = {
  control: UploadHookControl<true>;
  accept?: string;
  metadata?: Record<string, unknown>;
  description?:
    | {
        fileTypes?: string;
        maxFileSize?: string;
        maxFiles?: number;
      }
    | string;
  uploadOverride?: (
    ...args: Parameters<UploadHookControl<true>['upload']>
  ) => void;

  // Add any additional props you need.
};

export function UploadDropzoneProgress({
  control: { upload, isPending, progresses },
  accept,
  metadata,
  description,
  uploadOverride,
}: UploadDropzoneProgressProps) {
  const id = useId();

  const { getRootProps, getInputProps, isDragActive, inputRef } = useDropzone({
    onDrop: (files) => {
      if (files.length > 0) {
        if (uploadOverride) {
          uploadOverride(files, { metadata });
        } else {
          upload(files, { metadata });
        }
      }
      inputRef.current.value = '';
    },
    noClick: true,
  });

  return (
    <div className="flex flex-col gap-3">
      <div
        className={cn(
          'relative rounded-lg border border-dashed transition-colors',
          {
            'border-primary/70': isDragActive,
          }
        )}
      >
        <label
          {...getRootProps()}
          className={cn(
            'dark:bg-input/10 flex w-full min-w-72 cursor-pointer flex-col items-center justify-center rounded-lg bg-transparent px-2 py-6 transition-colors',
            {
              'text-muted-foreground cursor-not-allowed': isPending,
              'hover:bg-accent dark:hover:bg-accent/30': !isPending,
            }
          )}
          htmlFor={id}
        >
          <div className="my-2">
            <Upload className="size-6" />
          </div>

          <div className="mt-3 space-y-1 text-center">
            <p className="text-sm font-semibold">Drag and drop files here</p>

            <p className="text-muted-foreground max-w-64 text-xs">
              {typeof description === 'string' ? (
                description
              ) : (
                <>
                  {description?.maxFiles &&
                    `You can upload ${description.maxFiles} file${description.maxFiles !== 1 ? 's' : ''}.`}{' '}
                  {description?.maxFileSize &&
                    `${description.maxFiles !== 1 ? 'Each u' : 'U'}p to ${description.maxFileSize}.`}{' '}
                  {description?.fileTypes &&
                    `Accepted ${description.fileTypes}.`}
                </>
              )}
            </p>
          </div>

          <input
            {...getInputProps()}
            type="file"
            multiple
            id={id}
            accept={accept}
            disabled={isPending}
          />
        </label>

        {isDragActive && (
          <div className="bg-background pointer-events-none absolute inset-0 rounded-lg">
            <div className="dark:bg-accent/30 bg-accent flex size-full flex-col items-center justify-center rounded-lg">
              <div className="my-2">
                <Upload className="size-6" />
              </div>

              <p className="mt-3 text-sm font-semibold">Drop files here</p>
            </div>
          </div>
        )}
      </div>

      <div className="grid gap-2">
        {progresses.map((progress) => (
          <div
            key={progress.objectKey}
            className={cn(
              'dark:bg-input/10 flex items-center gap-2 rounded-lg border bg-transparent p-3',
              {
                'bg-red-500/[0.04]! border-red-500/60':
                  progress.status === 'failed',
              }
            )}
          >
            <FileIcon type={progress.type} />

            <div className="grid grow gap-1">
              <div className="flex items-center gap-0.5">
                <p className="max-w-40 truncate text-sm font-medium">
                  {progress.name}
                </p>
                <Dot className="text-muted-foreground size-4" />
                <p className="text-muted-foreground text-xs">
                  {formatBytes(progress.size)}
                </p>
              </div>

              <div className="flex h-4 items-center">
                {progress.progress < 1 && progress.status !== 'failed' ? (
                  <Progress className="h-1.5" value={progress.progress * 100} />
                ) : progress.status === 'failed' ? (
                  <p className="text-xs text-red-500">Failed</p>
                ) : (
                  <p className="text-muted-foreground text-xs">Completed</p>
                )}
              </div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

const iconCaptions = {
  'image/': 'IMG',
  'video/': 'VID',
  'audio/': 'AUD',
  'application/pdf': 'PDF',
  'application/zip': 'ZIP',
  'application/x-rar-compressed': 'RAR',
  'application/x-7z-compressed': '7Z',
  'application/x-tar': 'TAR',
  'application/json': 'JSON',
  'application/javascript': 'JS',
  'text/plain': 'TXT',
  'text/csv': 'CSV',
  'text/html': 'HTML',
  'text/css': 'CSS',
  'application/xml': 'XML',
  'application/x-sh': 'SH',
  'application/x-python-code': 'PY',
  'application/x-executable': 'EXE',
  'application/x-disk-image': 'ISO',
};

function FileIcon({ type }: { type: string }) {
  const caption = Object.entries(iconCaptions).find(([key]) =>
    type.startsWith(key)
  )?.[1];

  return (
    <div className="relative shrink-0">
      <File className="text-muted-foreground size-12" strokeWidth={1} />

      {caption && (
        <span className="bg-primary text-primary-foreground absolute bottom-2.5 left-0.5 select-none rounded px-1 py-px text-xs font-semibold">
          {caption}
        </span>
      )}
    </div>
  );
}

Update the import paths to match your project setup.

Usage

The <UploadDropzoneProgress /> should be used with the useUploadFiles hook.

'use client';

import { useUploadFiles } from 'better-upload/client';
import { UploadDropzoneProgress } from '@/components/ui/upload-dropzone-progress';

export function Uploader() {
  const { control } = useUploadFiles({
    route: 'demo',
  });

  return <UploadDropzoneProgress control={control} accept="image/*" />;
}

When clicked, the dropzone will open a file picker dialog. When selected or dropped, the files will be uploaded to the desired route.

Description

You can customize the description shown in the dropzone. You can pass a string, or an object with the following properties:

  • maxFiles: The maximum number of files that can be uploaded.
  • maxFileSize: The maximum size of the files that can be uploaded, use a formatted string (e.g. 10MB).
  • fileTypes: The file types that can be uploaded.
<UploadDropzone
  control={control}
  accept="image/*"
  description={{
    maxFiles: 4,
    maxFileSize: '2MB',
    fileTypes: 'JPEG, PNG, GIF',
  }}
/>

Note that this is only cosmetic and does not enforce any restrictions client-side.

Props

PropTypeDefault
control
object
-
accept?
string | undefined
-
description?
string | object | undefined
-
metadata?
Record<string, unknown> | undefined
-
uploadOverride?
function | undefined
-