File Dropzone
Drag-and-drop or click upload zone on react-dropzone, with a file list, type and size validation, and readable rejection messages.
Example
Installation
npx shadcn@latest add https://hirael.com/r/file-dropzone.jsonUsage
import {
FileDropzone,
FileDropzoneZone,
FileDropzoneList,
FileDropzoneErrors,
} from "@/components/file-dropzone"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<FileDropzone />
+ native element props| Prop | Type | Default |
|---|---|---|
value | File[] | none |
defaultValue | File[] | none |
onValueChange | ((files: File[]) => void) | none |
acceptMIME types mapped to extensions, e.g. `{ 'image/*': [], 'application/pdf': ['.pdf'] }`. | Accept | none |
maxSizeLargest file in bytes. | number | none |
multiple | boolean | false |
disabled | boolean | none |
<FileDropzoneZone />
+ native element props| Prop | Type | Default |
|---|---|---|
headline | string | 'Drop files here, or click to browse' |
subline | React.ReactNode | none |
children | React.ReactNode | none |
<FileDropzoneList />
+ native element props| Prop | Type | Default |
|---|---|---|
removeLabelAccessible name of a file's remove button. | ((file: File) => string) | defaultRemoveLabel |
<FileDropzoneErrors />
+ native element props| Prop | Type | Default |
|---|---|---|
messagesOverride the message per reason, e.g. for another language. | Partial<Record<"type" | "size" | "count", (file: File) => string>> | none |
Component source
'use client';
import * as React from 'react';
import { useDropzone, type Accept, type DropzoneState, type FileRejection } from 'react-dropzone';
import { File as FileIcon, FileText, UploadCloud, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { formatBytes } from '@/registry/hirael/lib/format-bytes';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';
export interface FileDropzoneError {
file: File;
/** Which rule turned the file away. */
reason: 'size' | 'type' | 'count';
message: string;
}
const toError = ({ file, errors: [error] }: FileRejection, maxSize?: number): FileDropzoneError => {
if (error?.code === 'file-too-large') {
return { file, reason: 'size', message: `"${file.name}" exceeds ${formatBytes(maxSize ?? 0)}.` };
}
if (error?.code === 'too-many-files') {
return { file, reason: 'count', message: `"${file.name}" not added. Only one file is allowed.` };
}
return { file, reason: 'type', message: `"${file.name}" type not allowed.` };
};
interface Ctx {
files: File[];
accept?: Accept;
maxSize?: number;
disabled?: boolean;
errors: FileDropzoneError[];
removeAt: (index: number) => void;
dropzone: DropzoneState;
}
const FileDropzoneContext = React.createContext<Ctx | null>(null);
const useFileDropzone = () => {
const ctx = React.useContext(FileDropzoneContext);
if (!ctx) {
throw new Error('FileDropzone compound parts must be used inside <FileDropzone>');
}
return ctx;
};
export interface FileDropzoneProps extends Omit<React.ComponentProps<'div'>, 'defaultValue'> {
value?: File[];
defaultValue?: File[];
onValueChange?: (files: File[]) => void;
/** MIME types mapped to extensions, e.g. `{ 'image/*': [], 'application/pdf': ['.pdf'] }`. */
accept?: Accept;
/** Largest file in bytes. */
maxSize?: number;
multiple?: boolean;
disabled?: boolean;
}
const FileDropzone = ({
value: valueProp,
defaultValue,
onValueChange,
accept,
maxSize,
multiple = false,
disabled,
className,
children,
...props
}: FileDropzoneProps) => {
const [files, setFiles] = useControllableState<File[]>({
prop: valueProp,
defaultProp: defaultValue ?? [],
onChange: onValueChange,
});
const [errors, setErrors] = React.useState<FileDropzoneError[]>([]);
const dropzone = useDropzone({
accept,
maxSize,
multiple,
disabled,
onDrop: (accepted, rejections) => {
if (accepted.length > 0) setFiles(multiple ? [...files, ...accepted] : accepted);
setErrors(rejections.map((rejection) => toError(rejection, maxSize)));
},
});
const removeAt = React.useCallback(
(index: number) => {
const removed = files[index];
setFiles(files.filter((_, i) => i !== index));
// Errors about this file, or about the one-file limit, no longer apply once it's gone.
setErrors((prev) => prev.filter((err) => err.file !== removed && err.reason !== 'count'));
},
[files, setFiles],
);
const ctx = React.useMemo<Ctx>(
() => ({ files, accept, maxSize, disabled, errors, removeAt, dropzone }),
[files, accept, maxSize, disabled, errors, removeAt, dropzone],
);
return (
<FileDropzoneContext.Provider value={ctx}>
<div data-slot="file-dropzone" className={className} {...props}>
{children}
</div>
</FileDropzoneContext.Provider>
);
};
interface FileDropzoneZoneProps extends Omit<
React.ComponentProps<'div'>,
'onDrop' | 'onDragEnter' | 'onDragLeave' | 'onDragOver' | 'children'
> {
headline?: string;
subline?: React.ReactNode;
children?: React.ReactNode;
}
const FileDropzoneZone = ({
className,
headline = 'Drop files here, or click to browse',
subline,
children,
...props
}: FileDropzoneZoneProps) => {
const ctx = useFileDropzone();
const { getRootProps, getInputProps, isDragActive } = ctx.dropzone;
const types = ctx.accept && Object.entries(ctx.accept).flatMap(([mime, exts]) => (exts.length ? exts : [mime]));
const resolvedSubline =
subline !== undefined
? subline
: [types?.join(', '), ctx.maxSize !== undefined && `up to ${formatBytes(ctx.maxSize)}`]
.filter(Boolean)
.join(', ') || null;
return (
<div
{...getRootProps({
role: 'button',
'aria-disabled': ctx.disabled || undefined,
className: cn(
'flex min-w-0 flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border bg-background px-6 py-10 text-center transition-colors outline-none',
'hover:border-foreground/30 hover:bg-accent/50',
'focus-visible:ring-2 focus-visible:ring-ring',
isDragActive && 'border-foreground/30 bg-accent/50',
ctx.disabled && 'cursor-not-allowed opacity-60',
className,
),
...props,
})}
data-slot="file-dropzone-zone"
data-dragging={isDragActive || undefined}
>
<input {...getInputProps()} />
{children ?? (
<>
<UploadCloud className="size-6 text-muted-foreground" />
<p className="text-sm text-foreground">{headline}</p>
{resolvedSubline && <p className="text-xs text-muted-foreground uppercase">{resolvedSubline}</p>}
</>
)}
</div>
);
};
const iconForFile = (file: File) => {
if (file.type.startsWith('text/') || /\.(md|txt|csv|json)$/i.test(file.name)) {
return FileText;
}
return FileIcon;
};
interface FileDropzoneListProps extends React.ComponentProps<'ul'> {
/** Accessible name of a file's remove button. */
removeLabel?: (file: File) => string;
}
const defaultRemoveLabel = (file: File) => `Remove ${file.name}`;
const FileDropzoneList = ({ removeLabel = defaultRemoveLabel, className, ...props }: FileDropzoneListProps) => {
const ctx = useFileDropzone();
if (ctx.files.length === 0) return null;
return (
<ul data-slot="file-dropzone-list" className={cn('mt-3 flex min-w-0 flex-col gap-1.5', className)} {...props}>
{ctx.files.map((file, index) => {
const Icon = iconForFile(file);
return (
<li
key={`${file.name}-${file.lastModified}-${index}`}
data-slot="file-dropzone-item"
className="flex min-w-0 items-center gap-2 rounded-sm border border-border bg-card px-2.5 py-1.5 text-sm"
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-xs text-muted-foreground uppercase">{formatBytes(file.size)}</span>
{!ctx.disabled && (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={removeLabel(file)}
onClick={() => ctx.removeAt(index)}
className="shrink-0"
>
<X className="size-3" />
</Button>
)}
</li>
);
})}
</ul>
);
};
interface FileDropzoneErrorsProps extends React.ComponentProps<'ul'> {
/** Override the message per reason, e.g. for another language. */
messages?: Partial<Record<FileDropzoneError['reason'], (file: File) => string>>;
}
const FileDropzoneErrors = ({ messages, className, ...props }: FileDropzoneErrorsProps) => {
const ctx = useFileDropzone();
if (ctx.errors.length === 0) return null;
return (
<ul
role="alert"
data-slot="file-dropzone-errors"
className={cn('mt-2 flex min-w-0 flex-col gap-1', className)}
{...props}
>
{ctx.errors.map((err, i) => (
<li
key={`${err.file.name}-${i}`}
data-slot="file-dropzone-error"
className="text-sm break-words text-destructive"
>
{messages?.[err.reason]?.(err.file) ?? err.message}
</li>
))}
</ul>
);
};
export { FileDropzone, FileDropzoneZone, FileDropzoneList, FileDropzoneErrors };
Dependencies
shadcn registry
buttonformat-bytesuse-controllable-state
npm
lucide-reactreact-dropzone