# EdgeStore Docs: Dropzone
URL: https://edgestore.dev/docs/components/dropzone
Source: https://raw.githubusercontent.com/edgestorejs/edgestore/refs/heads/main/docs/content/docs/components/dropzone.mdx
import { LimitedCode } from '@/components/ui/limited-code';
import {
OpenTabs,
OpenTabsContent,
OpenTabsList,
OpenTabsTrigger,
} from '@/components/ui/open-tabs';
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
`dropzone.tsx` exports:
* `Dropzone`: a drop area that adds files to the nearest `UploaderProvider`.
* `useUploadDropzone`: [react-dropzone](https://react-dropzone.js.org/)'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
CLI
Manual
Use the shadcn CLI to add the component to your project.
npm
pnpm
yarn
bun
```bash
npx shadcn@latest add https://edgestore.dev/r/dropzone.json
```
```bash
pnpm dlx shadcn@latest add https://edgestore.dev/r/dropzone.json
```
```bash
yarn dlx shadcn@latest add https://edgestore.dev/r/dropzone.json
```
```bash
bun x shadcn@latest add https://edgestore.dev/r/dropzone.json
```
### Setup for manual installation
First you will need to follow the [manual install setup](./manual-install) guide.
### Install required components
* [uploader-provider](./uploader-provider)
### Copy this component
````tsx title="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([]);
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 = ,
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 (
);
}
/**
* 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 (
{children}
);
}
/**
* 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 (
{errors.map((error) => (
{error}
))}
{onDismiss && (
)}
);
}
/**
* Props for the Dropzone component.
*/
export type DropzoneProps = Omit, '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
*
* ```
*/
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 (
Click to upload
{' '}
or drag and drop
>
))
}
hint={hint ?? describeLimits(dropzoneOptions ?? {})}
/>
);
}
````
````tsx title="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 = {
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 => {
// 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();
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](#installation) before using this example.
```tsx
'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 (
{/* You can create a component that uses the provider context */}
{/* (from the `useUploader` hook) to show a custom file list here */}
);
}
```
### 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`:
```tsx
import {
dropzoneState,
dropzoneVariants,
UploadErrors,
useUploadDropzone,
} from '@/components/upload/dropzone';
function MyDropzone() {
const { getRootProps, getInputProps, isDragActive, isDragReject, errors } =
useUploadDropzone({ maxFiles: 3, maxSize: 1024 * 1024 });
return (
<>
Drop files here
>
);
}
```
`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.