EdgeStore

Multi-file

A component for uploading multiple files with a progress list, retry and a running total.

Click to upload or drag and drop

up to 1 MB each · 10 max

Each file shows its type, size and progress. Failed uploads show the error with a retry button, and running uploads can be canceled.

Installation

Use the shadcn CLI to add the component to your project.

npx shadcn@latest add https://edgestore.dev/r/file-uploader.json

Setup for manual installation

First you will need to follow the manual install setup guide.

Copy this component

components/upload/multi-file.tsx
'use client';

import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { CheckIcon, RotateCwIcon, XIcon } from 'lucide-react';
import * as React from 'react';
import { type Accept } from 'react-dropzone';
import { Dropzone } from './dropzone';
import { ProgressBar } from './progress-bar';
import {
  fileExtension,
  fileKind,
  formatFileSize,
  type FileKind,
} from './upload-utils';
import { useUploader, type FileState } from './uploader-provider';

const KIND_CLASSNAMES: Record<FileKind, string> = {
  image: 'text-purple-600 dark:text-purple-400',
  video: 'text-pink-600 dark:text-pink-400',
  audio: 'text-cyan-600 dark:text-cyan-400',
  pdf: 'text-red-600 dark:text-red-400',
  doc: 'text-blue-600 dark:text-blue-400',
  sheet: 'text-green-600 dark:text-green-400',
  slides: 'text-orange-600 dark:text-orange-400',
  archive: 'text-yellow-600 dark:text-yellow-400',
  code: 'text-teal-600 dark:text-teal-400',
  other: 'text-muted-foreground',
};

/**
 * A colored tile with the file's extension.
 */
export function FileBadge({
  file,
  className,
}: {
  file: File;
  className?: string;
}) {
  return (
    <span
      aria-hidden="true"
      className={cn(
        'grid h-10 w-9 shrink-0 place-items-center rounded-md border border-current/25 bg-current/10 font-mono text-[10px] font-bold uppercase',
        KIND_CLASSNAMES[fileKind(file)],
        className,
      )}
    >
      {fileExtension(file.name).slice(0, 4) || 'file'}
    </span>
  );
}

function IconButton({
  label,
  className,
  ...props
}: React.ComponentProps<'button'> & { label: string }) {
  return (
    <button
      type="button"
      aria-label={label}
      title={label}
      className={cn(
        'grid size-8 place-items-center rounded-md text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4',
        className,
      )}
      {...props}
    />
  );
}

/**
 * One file with its status, progress and actions (retry, cancel, remove).
 */
export function FileListItem({
  fileState,
  disabled,
  className,
  ...props
}: React.ComponentProps<'li'> & {
  fileState: FileState;
  /** Hides the retry, cancel and remove actions. */
  disabled?: boolean;
}) {
  const { removeFile, cancelUpload, uploadFiles } = useUploader();
  const { file, key, status, progress, error } = fileState;

  return (
    <li
      data-status={status}
      className={cn(
        'relative flex animate-in items-center gap-3 overflow-hidden rounded-xl border bg-background py-2.5 pr-2 pl-2.5 fade-in-0 slide-in-from-top-1 data-[status=ERROR]:border-destructive/40',
        className,
      )}
      {...props}
    >
      <FileBadge file={file} />
      <div className="grid min-w-0 flex-1 gap-1">
        <div className="flex min-w-0 items-center gap-1.5">
          <span className="truncate text-sm font-medium" title={file.name}>
            {file.name}
          </span>
          {status === 'COMPLETE' && (
            <span className="grid size-4 shrink-0 place-items-center rounded-full bg-emerald-600 text-white dark:bg-emerald-500">
              <CheckIcon className="size-2.5" strokeWidth={4} />
              <span className="sr-only">Uploaded</span>
            </span>
          )}
        </div>
        <p
          className={cn(
            'text-xs text-muted-foreground tabular-nums',
            status === 'ERROR' && 'text-destructive',
          )}
        >
          {status === 'ERROR'
            ? (error ?? 'Upload failed')
            : status === 'UPLOADING'
              ? `${formatFileSize((file.size * progress) / 100)} of ${formatFileSize(file.size)} · ${Math.round(progress)}%`
              : formatFileSize(file.size)}
        </p>
      </div>
      {/* Pinned to the bottom edge so the row keeps its height when the upload ends. */}
      {status === 'UPLOADING' && (
        <ProgressBar
          progress={progress}
          aria-label={`Uploading ${file.name}`}
          className="absolute inset-x-0 bottom-0 h-0.5 rounded-none bg-transparent"
          indicatorClassName="rounded-none"
        />
      )}
      {!disabled && (
        <div className="flex items-center">
          {status === 'ERROR' && (
            <IconButton
              label={`Retry ${file.name}`}
              onClick={() => void uploadFiles([key])}
            >
              <RotateCwIcon />
            </IconButton>
          )}
          {status === 'UPLOADING' ? (
            <IconButton
              label={`Cancel ${file.name}`}
              onClick={() => cancelUpload(key)}
            >
              <XIcon />
            </IconButton>
          ) : (
            <IconButton
              label={`Remove ${file.name}`}
              onClick={() => removeFile(key)}
            >
              <XIcon />
            </IconButton>
          )}
        </div>
      )}
    </li>
  );
}

/**
 * Lists the files of the nearest `UploaderProvider`.
 *
 * @example
 * ```tsx
 * <FileList className="my-4" />
 * ```
 */
export function FileList({
  disabled,
  className,
  ...props
}: React.ComponentProps<'ul'> & {
  /** Hides the retry, cancel and remove actions. */
  disabled?: boolean;
}) {
  const { fileStates } = useUploader();
  if (fileStates.length === 0) return null;

  return (
    <ul
      aria-label="Files"
      className={cn('grid w-full gap-2', className)}
      {...props}
    >
      {fileStates.map((fileState) => (
        <FileListItem
          key={fileState.key}
          fileState={fileState}
          disabled={disabled}
        />
      ))}
    </ul>
  );
}

/**
 * Props for the FileUploader component.
 */
export type FileUploaderProps = React.ComponentProps<'div'> & {
  /**
   * Maximum number of files allowed in total.
   */
  maxFiles?: number;

  /**
   * Maximum file size in bytes.
   */
  maxSize?: number;

  /**
   * Accepted file types.
   *
   * @example
   * ```tsx
   * accept={{ 'application/pdf': ['.pdf'] }}
   * ```
   */
  accept?: Accept;

  /**
   * Human-readable list of accepted types, shown in the hint and messages.
   *
   * @example "PDF or TXT"
   */
  typesLabel?: string;

  /**
   * Whether the uploader is disabled.
   */
  disabled?: boolean;

  /**
   * Additional className for the dropzone.
   */
  dropzoneClassName?: string;

  /**
   * Additional className for the file list.
   */
  fileListClassName?: string;
};

/**
 * A dropzone with a list of files, their progress and a summary.
 *
 * @example
 * ```tsx
 * <FileUploader
 *   maxFiles={5}
 *   maxSize={1024 * 1024 * 10} // 10MB
 *   accept={{ 'application/pdf': ['.pdf'] }}
 *   typesLabel="PDF"
 * />
 * ```
 */
export function FileUploader({
  maxFiles,
  maxSize,
  accept,
  typesLabel,
  disabled,
  className,
  dropzoneClassName,
  fileListClassName,
  ...props
}: FileUploaderProps) {
  const { fileStates, resetFiles } = useUploader();
  const completed = fileStates.filter((fs) => fs.status === 'COMPLETE').length;
  const totalSize = fileStates.reduce((sum, fs) => sum + fs.file.size, 0);

  return (
    <div className={cn('flex w-full flex-col gap-3', className)} {...props}>
      <Dropzone
        dropzoneOptions={{ maxFiles, maxSize, accept, typesLabel }}
        disabled={disabled}
        className={dropzoneClassName}
      />
      <FileList className={fileListClassName} disabled={disabled} />
      {fileStates.length > 0 && (
        <div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
          <span className="tabular-nums">
            {completed} of {fileStates.length} uploaded ·{' '}
            {formatFileSize(totalSize)}
          </span>
          <Button
            type="button"
            variant="ghost"
            size="sm"
            onClick={resetFiles}
            disabled={disabled}
          >
            Clear all
          </Button>
        </div>
      )}
    </div>
  );
}

Usage

Install or copy the component from Installation before using this example.

'use client';

import { FileUploader } from '@/components/upload/multi-file';
import {
  UploaderProvider,
  type UploadFn,
} from '@/components/upload/uploader-provider';
import { useEdgeStore } from '@/lib/edgestore';
import * as React from 'react';

export function MultiFileDropzoneUsage() {
  const { edgestore } = useEdgeStore();

  const uploadFn: UploadFn = React.useCallback(
    async ({ file, onProgressChange, signal }) => {
      const res = await edgestore.publicFiles.upload({
        file,
        signal,
        onProgressChange,
      });
      // you can run some server action or api here
      // to add the necessary data to your database
      console.log(res);
      return res;
    },
    [edgestore],
  );

  return (
    <UploaderProvider uploadFn={uploadFn} autoUpload>
      <FileUploader
        maxFiles={5}
        maxSize={1024 * 1024 * 1} // 1 MB
        accept={{
          'application/pdf': ['.pdf'],
          'text/plain': ['.txt'],
        }}
        typesLabel="PDF or TXT"
      />
    </UploaderProvider>
  );
}

Props

PropTypeDefaultDescription
maxFilesnumberMaximum number of files in total.
maxSizenumberMaximum file size in bytes.
acceptAcceptall typesAccepted file types, in react-dropzone's format.
typesLabelstringHuman-readable list of accepted types, shown in the hint and in error messages.
disabledbooleanfalseDisables the component.
dropzoneClassNamestringAdditional className for the dropzone.
fileListClassNamestringAdditional className for the file list.

Building your own layout

multi-file.tsx also exports the pieces it is made of, so you can arrange them differently:

  • FileList lists the files of the nearest UploaderProvider.
  • FileListItem renders one file with its status and actions.
  • FileBadge renders the colored file-type tile.

On this page