EdgeStore

Dropzone

The drop area and the useUploadDropzone hook that the other upload components are built on.

dropzone.tsx exports:

  • Dropzone: a drop area that adds files to the nearest UploaderProvider.
  • useUploadDropzone: react-dropzone's useDropzone, connected to the UploaderProvider. It enforces maxFiles across drops, adds what fits, and returns readable messages about skipped files.
  • DropzonePrompt, DropzoneOverlay and UploadErrors: the building blocks for the empty state, the drag overlay and the error notice.
  • dropzoneVariants and dropzoneState: the classes for a drop area and the data attributes that drive its drag styles.

If you are installing the other upload components via the CLI, this component will be installed automatically. You can skip the following steps.

Installation

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

npx shadcn@latest add https://edgestore.dev/r/dropzone.json

Setup for manual installation

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

Copy this component

components/upload/dropzone.tsx
'use client';

import { cn } from '@/lib/utils';
import { AlertCircleIcon, UploadIcon, XIcon } from 'lucide-react';
import * as React from 'react';
import { useDropzone, type DropzoneOptions } from 'react-dropzone';
import {
  describeLimits,
  rejectionMessages,
  uploadErrorMessage,
} from './upload-utils';
import { useUploader } from './uploader-provider';

/**
 * Options for the `useUploadDropzone` hook.
 */
export type UseUploadDropzoneOptions = Omit<
  DropzoneOptions,
  'onDrop' | 'getErrorMessage' | 'maxFiles'
> & {
  /**
   * Maximum number of files the uploader can hold in total.
   * Extra files in a drop are skipped with a message.
   */
  maxFiles?: number;

  /**
   * Swap the current file for the dropped one instead of adding it.
   * Implies `multiple: false`.
   */
  replace?: boolean;

  /**
   * Human-readable list of accepted types, used in messages. e.g. "PNG or JPG"
   */
  typesLabel?: string;

  /**
   * Called with the messages for files that were not added.
   */
  onRejected?: (messages: string[]) => void;
};

/**
 * `useDropzone` wired to the nearest `UploaderProvider`.
 * Adds what fits, skips the rest and keeps a list of messages about skipped files.
 *
 * @example
 * ```tsx
 * const { getRootProps, getInputProps, errors } = useUploadDropzone({ maxFiles: 5 });
 * ```
 */
export function useUploadDropzone({
  maxFiles,
  replace,
  typesLabel,
  onRejected,
  disabled,
  ...options
}: UseUploadDropzoneOptions = {}) {
  const { fileStates, addFiles, removeFile } = useUploader();
  const [errors, setErrors] = React.useState<string[]>([]);
  const isFull =
    !replace && maxFiles !== undefined && fileStates.length >= maxFiles;

  const multiple = options.multiple ?? !replace;

  const dropzone = useDropzone({
    ...options,
    multiple,
    disabled: disabled || isFull,
    getErrorMessage: uploadErrorMessage({
      maxSize: options.maxSize,
      minSize: options.minSize,
      maxFiles: multiple ? maxFiles : 1,
      typesLabel,
    }),
    onDrop: (accepted, rejected) => {
      const messages = rejectionMessages(rejected);
      if (replace) {
        if (accepted.length > 0) {
          fileStates.forEach((fileState) => removeFile(fileState.key));
          addFiles(accepted.slice(0, 1));
        }
      } else {
        // react-dropzone's `maxFiles` only counts a single drop, so the total is enforced here.
        const room =
          maxFiles === undefined
            ? accepted.length
            : Math.max(maxFiles - fileStates.length, 0);
        if (accepted.length > room) {
          messages.push(
            `You can add up to ${maxFiles} files. ${accepted.length - room} skipped.`,
          );
        }
        addFiles(accepted.slice(0, room));
      }
      setErrors(messages);
      if (messages.length > 0) onRejected?.(messages);
    },
  });

  const clearErrors = React.useCallback(() => setErrors([]), []);

  return { ...dropzone, errors, clearErrors, isFull };
}

/**
 * Data attributes that drive the drag styles of `dropzoneVariants`.
 */
export function dropzoneState({
  isDragActive,
  isDragReject,
  disabled,
}: {
  isDragActive?: boolean;
  isDragReject?: boolean;
  disabled?: boolean;
}) {
  return {
    'data-dragging': isDragActive || undefined,
    'data-rejected': isDragReject || undefined,
    'data-disabled': disabled || undefined,
  };
}

/**
 * Base classes for a drop area. Pair with `dropzoneState()`.
 */
export const dropzoneVariants =
  'group/dropzone relative flex cursor-pointer flex-col items-center justify-center rounded-xl border-[1.5px] border-dashed border-muted-foreground/30 bg-muted/40 text-center outline-none transition-[border-color,background-color,box-shadow] hover:border-primary/50 hover:bg-muted/70 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[dragging]:border-solid data-[dragging]:border-primary data-[dragging]:bg-primary/5 data-[rejected]:border-destructive data-[rejected]:bg-destructive/5 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-60 data-[disabled]:hover:border-muted-foreground/30 data-[disabled]:hover:bg-muted/40';

/**
 * Icon, title and hint shown inside an empty drop area.
 */
export function DropzonePrompt({
  icon = <UploadIcon />,
  title,
  hint,
  isDragActive,
  isDragReject,
  activeText = 'Drop to upload',
  rejectText = 'File type not supported',
  className,
}: {
  icon?: React.ReactNode;
  title: React.ReactNode;
  hint?: React.ReactNode;
  isDragActive?: boolean;
  isDragReject?: boolean;
  activeText?: string;
  rejectText?: string;
  className?: string;
}) {
  return (
    <div
      className={cn(
        'pointer-events-none flex flex-col items-center gap-1 px-5 py-7',
        className,
      )}
    >
      <span className="mb-2 grid size-11 place-items-center rounded-xl border bg-background text-muted-foreground shadow-xs transition group-data-[dragging]/dropzone:-translate-y-0.5 group-data-[dragging]/dropzone:border-primary/40 group-data-[dragging]/dropzone:text-primary group-data-[rejected]/dropzone:border-destructive/40 group-data-[rejected]/dropzone:text-destructive motion-reduce:transition-none [&_svg]:size-5">
        {icon}
      </span>
      <p className="text-sm font-medium group-data-[rejected]/dropzone:text-destructive">
        {isDragReject ? rejectText : isDragActive ? activeText : title}
      </p>
      {hint && <p className="text-xs text-muted-foreground">{hint}</p>}
    </div>
  );
}

/**
 * Covers a drop area that already shows files while something is dragged over it.
 */
export function DropzoneOverlay({
  isDragReject,
  children,
}: {
  isDragReject?: boolean;
  children: React.ReactNode;
}) {
  return (
    <div
      data-rejected={isDragReject || undefined}
      className="pointer-events-none absolute inset-0 z-10 grid animate-in place-items-center rounded-[inherit] border-2 border-primary bg-background/80 p-4 text-sm font-semibold text-primary backdrop-blur-[2px] fade-in-0 data-[rejected]:border-destructive data-[rejected]:text-destructive"
    >
      {children}
    </div>
  );
}

/**
 * Lists messages about files that were not added.
 */
export function UploadErrors({
  errors,
  onDismiss,
  className,
}: {
  errors: string[];
  onDismiss?: () => void;
  className?: string;
}) {
  if (errors.length === 0) return null;
  return (
    <div
      role="alert"
      className={cn(
        'flex animate-in items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 py-2 pr-2 pl-3 text-sm text-destructive fade-in-0',
        className,
      )}
    >
      <AlertCircleIcon className="mt-0.5 size-4 shrink-0" />
      <ul className="grid flex-1 gap-0.5 break-words">
        {errors.map((error) => (
          <li key={error}>{error}</li>
        ))}
      </ul>
      {onDismiss && (
        <button
          type="button"
          onClick={onDismiss}
          aria-label="Dismiss"
          className="grid size-5 shrink-0 place-items-center rounded-md outline-none hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-destructive/40"
        >
          <XIcon className="size-3.5" />
        </button>
      )}
    </div>
  );
}

/**
 * Props for the Dropzone component.
 */
export type DropzoneProps = Omit<React.ComponentProps<'div'>, 'title'> & {
  /**
   * Options passed to `useUploadDropzone` (and react-dropzone).
   */
  dropzoneOptions?: UseUploadDropzoneOptions;

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

  /**
   * Icon shown above the title.
   */
  icon?: React.ReactNode;

  /**
   * Title shown when idle.
   */
  title?: React.ReactNode;

  /**
   * Hint shown below the title. Defaults to a summary of the limits.
   */
  hint?: React.ReactNode;

  /**
   * Title shown while files are dragged over the dropzone.
   */
  activeText?: string;
};

/**
 * A drop area that adds files to the nearest `UploaderProvider`.
 *
 * @example
 * ```tsx
 * <Dropzone
 *   dropzoneOptions={{
 *     maxFiles: 5,
 *     maxSize: 1024 * 1024 * 10, // 10MB
 *   }}
 * />
 * ```
 */
export function Dropzone({
  dropzoneOptions,
  disabled,
  icon,
  title,
  hint,
  activeText = 'Drop files to upload',
  className,
  ...props
}: DropzoneProps) {
  const {
    getRootProps,
    getInputProps,
    isDragActive,
    isDragReject,
    errors,
    clearErrors,
    isFull,
  } = useUploadDropzone({ ...dropzoneOptions, disabled });

  return (
    <div className="flex w-full flex-col gap-3">
      <div
        {...getRootProps({
          role: 'button',
          'aria-label': 'Upload files',
          ...props,
          className: cn(dropzoneVariants, 'min-h-40', className),
        })}
        {...dropzoneState({
          isDragActive,
          isDragReject,
          disabled: disabled || isFull,
        })}
      >
        <input {...getInputProps()} />
        <DropzonePrompt
          icon={icon}
          isDragActive={isDragActive}
          isDragReject={isDragReject}
          activeText={activeText}
          title={
            isFull
              ? 'File limit reached'
              : (title ?? (
                  <>
                    <span className="font-semibold text-primary">
                      Click to upload
                    </span>{' '}
                    or drag and drop
                  </>
                ))
          }
          hint={hint ?? describeLimits(dropzoneOptions ?? {})}
        />
      </div>
      <UploadErrors errors={errors} onDismiss={clearErrors} />
    </div>
  );
}
components/upload/upload-utils.ts
'use client';

import * as React from 'react';
import {
  type Accept,
  type FileError,
  type FileRejection,
} from 'react-dropzone';

export const IMAGE_ACCEPT: Accept = {
  'image/png': ['.png'],
  'image/jpeg': ['.jpg', '.jpeg'],
  'image/webp': ['.webp'],
  'image/gif': ['.gif'],
};

export const DOCUMENT_ACCEPT: Accept = {
  'application/pdf': ['.pdf'],
  'application/msword': ['.doc'],
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [
    '.docx',
  ],
};

export const SPREADSHEET_ACCEPT: Accept = {
  'text/csv': ['.csv'],
  'application/vnd.ms-excel': ['.xls'],
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
    '.xlsx',
  ],
};

/**
 * Formats a file size in bytes to a human-readable string.
 *
 * @example
 * ```ts
 * formatFileSize(1024); // "1 KB"
 * formatFileSize(1024 * 1024 * 2.5); // "2.5 MB"
 * ```
 */
export function formatFileSize(bytes?: number) {
  if (!bytes) return '0 B';
  if (bytes < 1024) return `${Math.round(bytes)} B`;
  const units = ['KB', 'MB', 'GB', 'TB'];
  let value = bytes / 1024;
  let i = 0;
  while (value >= 1024 && i < units.length - 1) {
    value /= 1024;
    i++;
  }
  return `${value < 10 ? Number(value.toFixed(1)) : Math.round(value)} ${units[i]}`;
}

export function fileExtension(name: string) {
  const dot = name.lastIndexOf('.');
  return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
}

export type FileKind =
  | 'image'
  | 'video'
  | 'audio'
  | 'pdf'
  | 'doc'
  | 'sheet'
  | 'slides'
  | 'archive'
  | 'code'
  | 'other';

const KIND_BY_EXTENSION: Record<string, FileKind> = {
  pdf: 'pdf',
  doc: 'doc',
  docx: 'doc',
  txt: 'doc',
  md: 'doc',
  rtf: 'doc',
  xls: 'sheet',
  xlsx: 'sheet',
  csv: 'sheet',
  ppt: 'slides',
  pptx: 'slides',
  key: 'slides',
  zip: 'archive',
  rar: 'archive',
  '7z': 'archive',
  tar: 'archive',
  gz: 'archive',
  js: 'code',
  ts: 'code',
  tsx: 'code',
  jsx: 'code',
  json: 'code',
  html: 'code',
  css: 'code',
  py: 'code',
};

export function fileKind(file: File): FileKind {
  if (file.type.startsWith('image/')) return 'image';
  if (file.type.startsWith('video/')) return 'video';
  if (file.type.startsWith('audio/')) return 'audio';
  return KIND_BY_EXTENSION[fileExtension(file.name)] ?? 'other';
}

export type UploadLimits = {
  maxSize?: number;
  minSize?: number;
  maxFiles?: number;
  /** Human-readable list of accepted types, e.g. "PNG or JPG". */
  typesLabel?: string;
};

/**
 * Friendlier rejection messages than react-dropzone's defaults
 * ("File is larger than 1048576 bytes"). Pass it to the `getErrorMessage` option.
 */
export function uploadErrorMessage(limits: UploadLimits) {
  return (error: FileError, file: File): string => {
    switch (error.code) {
      case 'file-too-large':
        return `${file.name} is ${formatFileSize(file.size)}. The limit is ${formatFileSize(limits.maxSize)}.`;
      case 'file-too-small':
        return `${file.name} is smaller than ${formatFileSize(limits.minSize)}.`;
      case 'file-invalid-type':
        return limits.typesLabel
          ? `${file.name} isn't supported. Use ${limits.typesLabel}.`
          : `${file.name} isn't a supported file type.`;
      case 'too-many-files':
        if (limits.maxFiles === 1) return 'Choose a single file.';
        return limits.maxFiles
          ? `You can add up to ${limits.maxFiles} files.`
          : 'Too many files.';
      default:
        return error.message;
    }
  };
}

/** Flattens rejections into unique, human-readable messages. */
export function rejectionMessages(rejections: readonly FileRejection[]) {
  return [
    ...new Set(rejections.flatMap((r) => r.errors.map((e) => e.message))),
  ];
}

/** Short summary of the limits, e.g. "PNG or JPG · up to 2 MB · 5 max". */
export function describeLimits({
  typesLabel,
  maxSize,
  maxFiles,
}: UploadLimits) {
  return [
    typesLabel,
    maxSize &&
      `up to ${formatFileSize(maxSize)}${maxFiles === 1 ? '' : ' each'}`,
    maxFiles && maxFiles > 1 && `${maxFiles} max`,
  ]
    .filter(Boolean)
    .join(' · ');
}

/**
 * Async validator that rejects images smaller than `min` pixels on either side.
 * Pass it to the `validator` option.
 */
export function minImageSize(min: number) {
  return async (file: File): Promise<FileError | null> => {
    // Let `accept` report the type error.
    if (!file.type.startsWith('image/')) return null;
    const url = URL.createObjectURL(file);
    try {
      const img = new Image();
      img.src = url;
      await img.decode();
      if (img.naturalWidth < min || img.naturalHeight < min) {
        return {
          code: 'image-too-small',
          message: `${file.name} is ${img.naturalWidth}×${img.naturalHeight}px. Use at least ${min}×${min}px.`,
        };
      }
      return null;
    } catch {
      return {
        code: 'image-unreadable',
        message: `${file.name} couldn't be read as an image.`,
      };
    } finally {
      URL.revokeObjectURL(url);
    }
  };
}

/**
 * Returns an object URL for previewing `file`, created once per file and
 * revoked when the file changes or the component unmounts.
 */
export function useObjectUrl(file?: File | null) {
  const [url, setUrl] = React.useState<string>();

  React.useEffect(() => {
    if (!file) return;
    const objectUrl = URL.createObjectURL(file);
    setUrl(objectUrl);
    return () => {
      URL.revokeObjectURL(objectUrl);
      setUrl(undefined);
    };
  }, [file]);

  return url;
}

Usage

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

'use client';

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

export function DropzoneUsage() {
  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>
      <Dropzone
        dropzoneOptions={{
          maxFiles: 5,
          maxSize: 1024 * 1024 * 2, // 2MB
          accept: {
            'image/*': ['.jpeg', '.jpg', '.png'],
          },
          typesLabel: 'JPG or PNG',
        }}
      />
      {/* You can create a component that uses the provider context */}
      {/* (from the `useUploader` hook) to show a custom file list here */}
    </UploaderProvider>
  );
}

Custom dropzones

Use useUploadDropzone to build your own drop area. Spread dropzoneState() on the root element so the data-dragging, data-rejected and data-disabled attributes drive the styles in dropzoneVariants:

import {
  dropzoneState,
  dropzoneVariants,
  UploadErrors,
  useUploadDropzone,
} from '@/components/upload/dropzone';

function MyDropzone() {
  const { getRootProps, getInputProps, isDragActive, isDragReject, errors } =
    useUploadDropzone({ maxFiles: 3, maxSize: 1024 * 1024 });

  return (
    <>
      <div
        {...getRootProps({ className: dropzoneVariants })}
        {...dropzoneState({ isDragActive, isDragReject })}
      >
        <input {...getInputProps()} />
        <p>Drop files here</p>
      </div>
      <UploadErrors errors={errors} />
    </>
  );
}

upload-utils.ts has the helpers the components share: formatFileSize, accept presets (IMAGE_ACCEPT, DOCUMENT_ACCEPT, SPREADSHEET_ACCEPT), the minImageSize validator and the useObjectUrl preview hook.

On this page