Dropzone
A headless file-intake primitive with drag/drop, file input wiring, validation, controlled state, and accessibility prop getters.
registry/new-york-v4/ui/dropzone.tsx
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { useKeyedMountEffect } from "@/hooks/use-keyed-mount-effect";
import {
formatDropzoneAccept,
parseDropzoneAccept,
validateDropzoneFiles,
type DropzoneAcceptRule,
type DropzoneFileRejection,
type DropzoneIntake,
} from "@/components/ui/dropzone-core";
export {
formatDropzoneAccept,
matchesDropzoneAccept,
parseDropzoneAccept,
validateDropzoneFile,
validateDropzoneFiles,
type DropzoneAcceptRule,
type DropzoneFileRejection,
type DropzoneIntake,
} from "@/components/ui/dropzone-core";
export type DropzoneFileItem = {
id: string;
file: File;
};
type MaybePromise<T> = T | Promise<T>;
export type DropzoneFilesValidatorContext = {
acceptedFiles: File[];
currentCount: number;
currentFiles: DropzoneFileItem[];
fileRejections: DropzoneFileRejection[];
};
export type DropzoneFilesValidatorResult =
| DropzoneFileRejection[]
| DropzoneIntake
| null
| undefined
| void;
export type DropzoneIntakeSource = "drop" | "input" | (string & {});
export type DropzoneIntakeDetails = {
source: DropzoneIntakeSource;
};
export type DropzoneOpenFileDialogOptions = {
source?: DropzoneIntakeSource;
};
type DropzoneDataAttributes = {
[key: `data-${string}`]: string | undefined;
};
type DropzoneRootGetterProps<T extends HTMLElement> = React.HTMLAttributes<T> &
Partial<DropzoneDataAttributes>;
type DropzoneInputGetterProps = React.ComponentPropsWithRef<"input"> &
Partial<DropzoneDataAttributes>;
type DropzoneTriggerGetterProps<T extends HTMLElement> =
React.HTMLAttributes<T> &
Partial<DropzoneDataAttributes> & {
/** The trigger is a real `<button>`; suppress the ARIA-button polyfill. */
native?: boolean;
source?: DropzoneIntakeSource;
};
export type UseDropzoneProps = {
accept?: string | DropzoneAcceptRule[];
inputAccept?: string | DropzoneAcceptRule[];
currentFileCount?: number;
disabled?: boolean;
dragScope?: "root" | "document";
files?: DropzoneFileItem[];
defaultFiles?: DropzoneFileItem[];
maxFiles?: number;
maxSize?: number;
multiple?: boolean;
onFilesChange?: (files: DropzoneFileItem[]) => void;
onIntake?: (intake: DropzoneIntake, details: DropzoneIntakeDetails) => void;
storeFiles?: boolean;
validateFiles?: (
files: File[],
context: DropzoneFilesValidatorContext,
) => MaybePromise<DropzoneFilesValidatorResult>;
};
export type UseDropzoneReturn = {
files: DropzoneFileItem[];
lastIntake: DropzoneIntake;
lastIntakeDetails: DropzoneIntakeDetails | null;
isDragging: boolean;
isDisabled: boolean;
isValidating: boolean;
clearFiles: () => void;
openFileDialog: (options?: DropzoneOpenFileDialogOptions) => void;
removeFile: (fileId: string) => void;
reset: () => void;
resetIntake: () => void;
getRootProps: <T extends HTMLElement>(
props?: DropzoneRootGetterProps<T>,
) => DropzoneRootGetterProps<T>;
getInputProps: (props?: DropzoneInputGetterProps) => DropzoneInputGetterProps;
getTriggerProps: <T extends HTMLElement>(
props?: DropzoneTriggerGetterProps<T>,
) => DropzoneTriggerGetterProps<T>;
};
type DropzoneContextValue = UseDropzoneReturn;
const DropzoneContext = React.createContext<DropzoneContextValue | null>(null);
export type DropzoneProviderProps = UseDropzoneProps & {
children: React.ReactNode;
};
export function DropzoneProvider({
children,
...dropzoneProps
}: DropzoneProviderProps) {
const dropzone = useDropzone(dropzoneProps);
return (
<DropzoneContext.Provider value={dropzone}>
{children}
</DropzoneContext.Provider>
);
}
export function useDropzoneContext(
consumerName = "useDropzoneContext",
): DropzoneContextValue {
const dropzone = React.useContext(DropzoneContext);
if (!dropzone) {
throw new Error(`${consumerName} must be used within DropzoneProvider.`);
}
return dropzone;
}
export type DropzoneStateProps = {
children: (dropzone: DropzoneContextValue) => React.ReactNode;
};
export function DropzoneState({ children }: DropzoneStateProps) {
const dropzone = useDropzoneContext("DropzoneState");
return <>{children(dropzone)}</>;
}
export type DropzoneRootProps = React.HTMLAttributes<HTMLElement> &
Partial<DropzoneDataAttributes> & {
asChild?: boolean;
};
export const DropzoneRoot = React.forwardRef<HTMLElement, DropzoneRootProps>(
function DropzoneRoot({ asChild = false, ...props }, ref) {
const dropzone = useDropzoneContext("DropzoneRoot");
const Comp = asChild ? Slot : "div";
return (
<Comp
{...dropzone.getRootProps(props)}
ref={ref as React.Ref<HTMLDivElement>}
/>
);
},
);
export type DropzoneInputProps = React.ComponentPropsWithoutRef<"input"> &
Partial<DropzoneDataAttributes>;
export const DropzoneInput = React.forwardRef<
HTMLInputElement,
DropzoneInputProps
>(function DropzoneInput(props, ref) {
const dropzone = useDropzoneContext("DropzoneInput");
return <input {...dropzone.getInputProps({ ...props, ref })} />;
});
export type DropzoneTriggerProps = React.ComponentPropsWithoutRef<"button"> &
Partial<DropzoneDataAttributes> & {
asChild?: boolean;
native?: boolean;
source?: DropzoneIntakeSource;
};
export const DropzoneTrigger = React.forwardRef<
HTMLElement,
DropzoneTriggerProps
>(function DropzoneTrigger({ asChild = false, native, ...props }, ref) {
const dropzone = useDropzoneContext("DropzoneTrigger");
const Comp = asChild ? Slot : "button";
const triggerProps = dropzone.getTriggerProps({
...props,
native: native ?? !asChild,
});
return <Comp {...triggerProps} ref={ref as React.Ref<HTMLButtonElement>} />;
});
const EMPTY_INTAKE: DropzoneIntake = {
acceptedFiles: [],
fileRejections: [],
};
const EMPTY_FILE_ITEMS: DropzoneFileItem[] = [];
export function useDropzone({
accept,
inputAccept: nativeInputAccept,
currentFileCount,
defaultFiles = [],
disabled = false,
dragScope = "root",
files,
maxFiles,
maxSize,
multiple = true,
onFilesChange,
onIntake,
storeFiles = true,
validateFiles,
}: UseDropzoneProps = {}): UseDropzoneReturn {
const inputRef = React.useRef<HTMLInputElement | null>(null);
const activeDialogSourceRef = React.useRef<DropzoneIntakeSource | undefined>(
undefined,
);
const dragDepthRef = React.useRef(0);
const intakeRequestRef = React.useRef(0);
const isDisabledRef = React.useRef(disabled);
const shouldStoreFiles = storeFiles;
const isControlled = shouldStoreFiles && files !== undefined;
const acceptRules = React.useMemo<DropzoneAcceptRule[]>(
() => (Array.isArray(accept) ? accept : parseDropzoneAccept(accept)),
[accept],
);
const inputAccept = React.useMemo(
() => formatDropzoneAccept(nativeInputAccept ?? accept),
[accept, nativeInputAccept],
);
const [uncontrolledItems, setUncontrolledItems] =
React.useState<DropzoneFileItem[]>(defaultFiles);
const [lastIntake, setLastIntake] =
React.useState<DropzoneIntake>(EMPTY_INTAKE);
const [lastIntakeDetails, setLastIntakeDetails] =
React.useState<DropzoneIntakeDetails | null>(null);
const [rawIsDragging, setIsDragging] = React.useState(false);
const [isValidating, setIsValidating] = React.useState(false);
const currentItems = shouldStoreFiles
? (files ?? uncontrolledItems)
: EMPTY_FILE_ITEMS;
const isDragging = disabled ? false : rawIsDragging;
// itemsRef.current is the latest committed items. The effect mirrors the
// source of truth into it after every render; internal commits update it
// eagerly so consecutive same-tick intakes — and the pre-validation count in
// commitFiles — read the value they just produced. A controlled parent owns
// the truth: the eager write is optimistic and the effect reconciles it on
// the parent's next render.
const itemsRef = React.useRef(currentItems);
itemsRef.current = currentItems;
isDisabledRef.current = disabled;
const invalidatePendingIntake = React.useCallback(
(clearValidationState = true) => {
intakeRequestRef.current += 1;
activeDialogSourceRef.current = undefined;
if (clearValidationState) setIsValidating(false);
},
[],
);
const commitFileTransition = React.useCallback(
(transition: (items: DropzoneFileItem[]) => DropzoneFileItem[]) => {
if (!shouldStoreFiles) return;
const nextItems = transition(itemsRef.current);
itemsRef.current = nextItems;
if (!isControlled) setUncontrolledItems(nextItems);
onFilesChange?.(nextItems);
},
[isControlled, onFilesChange, shouldStoreFiles],
);
const resetDragState = React.useCallback(() => {
dragDepthRef.current = 0;
setIsDragging(false);
}, []);
const resetIntake = React.useCallback(() => {
setLastIntake(EMPTY_INTAKE);
setLastIntakeDetails(null);
}, []);
const clearFiles = React.useCallback(() => {
if (disabled || !shouldStoreFiles) return;
commitFileTransition(() => []);
}, [commitFileTransition, disabled, shouldStoreFiles]);
const reset = React.useCallback(() => {
if (disabled) return;
resetDragState();
resetIntake();
if (shouldStoreFiles) commitFileTransition(() => []);
}, [
commitFileTransition,
disabled,
resetDragState,
resetIntake,
shouldStoreFiles,
]);
const removeFile = React.useCallback(
(fileId: string) => {
if (disabled || !shouldStoreFiles) return;
commitFileTransition((previousItems) =>
previousItems.filter((item) => item.id !== fileId),
);
},
[commitFileTransition, disabled, shouldStoreFiles],
);
const commitFiles = React.useCallback(
async (nextFiles: FileList | File[], details: DropzoneIntakeDetails) => {
if (disabled) return;
const requestId = intakeRequestRef.current + 1;
intakeRequestRef.current = requestId;
const incomingFiles = Array.from(nextFiles);
const baseItems = multiple ? itemsRef.current : [];
const effectiveCurrentCount = currentFileCount ?? baseItems.length;
const effectiveMaxFiles = multiple
? maxFiles
: Math.min(maxFiles ?? 1, 1);
const intake = validateDropzoneFiles(incomingFiles, {
accept: acceptRules,
maxSize,
});
let validatedIntake = intake;
if (validateFiles) {
setIsValidating(true);
try {
validatedIntake = await resolveDropzoneValidation({
currentCount: effectiveCurrentCount,
currentFiles: baseItems,
intake,
validateFiles,
});
} catch (error) {
validatedIntake = rejectAcceptedFilesForValidationError(
intake,
error,
);
} finally {
if (intakeRequestRef.current === requestId) {
setIsValidating(false);
}
}
}
if (intakeRequestRef.current !== requestId || isDisabledRef.current) {
return;
}
const finalIntake = applyDropzoneMaxFiles(validatedIntake, {
currentCount: effectiveCurrentCount,
maxFiles: effectiveMaxFiles,
});
setLastIntake(finalIntake);
setLastIntakeDetails(details);
onIntake?.(finalIntake, details);
if (!shouldStoreFiles || finalIntake.acceptedFiles.length === 0) {
return;
}
const acceptedItems = finalIntake.acceptedFiles.map((file) => ({
id: createDropzoneFileId(file),
file,
}));
commitFileTransition((previousItems) =>
createNextDropzoneItems({
acceptedItems,
multiple,
previousItems,
}),
);
},
[
acceptRules,
commitFileTransition,
currentFileCount,
disabled,
maxFiles,
maxSize,
multiple,
onIntake,
shouldStoreFiles,
validateFiles,
],
);
const commitFilesRef = React.useRef(commitFiles);
commitFilesRef.current = commitFiles;
const openFileDialog = React.useCallback(
(options: DropzoneOpenFileDialogOptions = {}) => {
if (disabled || !inputRef.current) return;
activeDialogSourceRef.current = options.source;
inputRef.current.click();
},
[disabled],
);
useKeyedMountEffect("dropzone-lifecycle", () => {
return () => {
invalidatePendingIntake(false);
};
});
useKeyedMountEffect(disabled ? "disabled" : null, () => {
if (disabled) {
invalidatePendingIntake();
resetDragState();
}
});
useKeyedMountEffect(
dragScope === "document" && !disabled ? "document-drag-listeners" : null,
() => {
// Document-level drag listeners are imperative browser wiring for overlays outside the root.
const handleDocumentDragEnter = (event: DragEvent) => {
if (!hasDraggedFiles(event.dataTransfer)) return;
event.preventDefault();
dragDepthRef.current += 1;
setIsDragging(true);
};
const handleDocumentDragLeave = (event: DragEvent) => {
if (!hasDraggedFiles(event.dataTransfer)) return;
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragging(false);
};
const handleDocumentDragOver = (event: DragEvent) => {
if (!hasDraggedFiles(event.dataTransfer)) return;
event.preventDefault();
event.dataTransfer!.dropEffect = "copy";
};
const handleDocumentDrop = (event: DragEvent) => {
if (!hasDraggedFiles(event.dataTransfer)) {
resetDragState();
return;
}
event.preventDefault();
resetDragState();
if (event.dataTransfer?.files.length) {
void commitFilesRef.current(event.dataTransfer.files, {
source: "drop",
});
}
};
const handleDocumentDragEnd = () => {
resetDragState();
};
document.addEventListener("dragenter", handleDocumentDragEnter, true);
document.addEventListener("dragleave", handleDocumentDragLeave, true);
document.addEventListener("dragover", handleDocumentDragOver, true);
document.addEventListener("drop", handleDocumentDrop, true);
document.addEventListener("dragend", handleDocumentDragEnd, true);
return () => {
document.removeEventListener(
"dragenter",
handleDocumentDragEnter,
true,
);
document.removeEventListener(
"dragleave",
handleDocumentDragLeave,
true,
);
document.removeEventListener("dragover", handleDocumentDragOver, true);
document.removeEventListener("drop", handleDocumentDrop, true);
document.removeEventListener("dragend", handleDocumentDragEnd, true);
};
},
);
const getRootProps = React.useCallback(
<T extends HTMLElement>(
props: DropzoneRootGetterProps<T> = {},
): DropzoneRootGetterProps<T> => ({
...props,
"aria-disabled": disabled || props["aria-disabled"] || undefined,
"data-dragging": isDragging ? "" : undefined,
"data-slot": props["data-slot"] ?? "dropzone",
onDragEnter: composeEventHandlers(props.onDragEnter, (event) => {
if (disabled || !hasDraggedFiles(event.dataTransfer)) return;
event.preventDefault();
if (dragScope === "root") {
dragDepthRef.current += 1;
setIsDragging(true);
}
}),
onDragLeave: composeEventHandlers(props.onDragLeave, (event) => {
if (disabled || !hasDraggedFiles(event.dataTransfer)) return;
event.preventDefault();
if (dragScope === "root") {
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragging(false);
}
}),
onDragOver: composeEventHandlers(props.onDragOver, (event) => {
if (disabled || !hasDraggedFiles(event.dataTransfer)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}),
onDrop: composeEventHandlers(props.onDrop, (event) => {
if (disabled || !hasDraggedFiles(event.dataTransfer)) return;
event.preventDefault();
resetDragState();
if (event.dataTransfer.files.length > 0) {
void commitFiles(event.dataTransfer.files, { source: "drop" });
}
}),
}),
[commitFiles, disabled, dragScope, isDragging, resetDragState],
);
const getInputProps = React.useCallback(
(props: DropzoneInputGetterProps = {}): DropzoneInputGetterProps => ({
...props,
accept: inputAccept,
disabled,
multiple,
ref: composeRefs(inputRef, props.ref),
type: "file",
"data-slot": props["data-slot"] ?? "dropzone-input",
onChange: composeEventHandlers(props.onChange, (event) => {
if (disabled) return;
if (event.currentTarget.files) {
const source = activeDialogSourceRef.current ?? "input";
activeDialogSourceRef.current = undefined;
void commitFiles(event.currentTarget.files, { source });
event.currentTarget.value = "";
}
}),
}),
[commitFiles, disabled, inputAccept, multiple],
);
const getTriggerProps = React.useCallback(
<T extends HTMLElement>({
native = false,
source,
...props
}: DropzoneTriggerGetterProps<T> = {}): DropzoneTriggerGetterProps<T> => ({
...props,
"data-slot": props["data-slot"] ?? "dropzone-trigger",
onClick: composeEventHandlers(props.onClick, () => {
openFileDialog({ source });
}),
...(native
? // Native button: the platform owns role, focus, and keyboard
// activation; only the disabled attribute and button type are ours.
{ disabled, type: "button" as const }
: // Anything else: polyfill button semantics onto the element.
{
role: props.role ?? "button",
tabIndex: disabled ? -1 : (props.tabIndex ?? 0),
"aria-disabled": disabled || props["aria-disabled"] || undefined,
onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openFileDialog({ source });
}
}),
}),
}),
[disabled, openFileDialog],
);
return React.useMemo(
() => ({
files: currentItems,
lastIntake,
lastIntakeDetails,
isDragging,
isDisabled: disabled,
isValidating,
clearFiles,
openFileDialog,
removeFile,
reset,
resetIntake,
getRootProps,
getInputProps,
getTriggerProps,
}),
[
clearFiles,
currentItems,
disabled,
getInputProps,
getRootProps,
getTriggerProps,
isDragging,
isValidating,
lastIntake,
lastIntakeDetails,
openFileDialog,
removeFile,
reset,
resetIntake,
],
);
}
async function resolveDropzoneValidation({
currentCount,
currentFiles,
intake,
validateFiles,
}: {
currentCount: number;
currentFiles: DropzoneFileItem[];
intake: DropzoneIntake;
validateFiles: NonNullable<UseDropzoneProps["validateFiles"]>;
}): Promise<DropzoneIntake> {
if (intake.acceptedFiles.length === 0) return intake;
const result = await validateFiles(intake.acceptedFiles, {
acceptedFiles: intake.acceptedFiles,
currentCount,
currentFiles,
fileRejections: intake.fileRejections,
});
if (!result) return intake;
if (Array.isArray(result)) {
return appendDropzoneRejections(intake, result);
}
return {
acceptedFiles: result.acceptedFiles,
fileRejections: [...intake.fileRejections, ...result.fileRejections],
};
}
function applyDropzoneMaxFiles(
intake: DropzoneIntake,
{
currentCount,
maxFiles,
}: {
currentCount: number;
maxFiles: number | undefined;
},
): DropzoneIntake {
if (maxFiles === undefined) return intake;
const availableSlots = Math.max(0, maxFiles - currentCount);
if (intake.acceptedFiles.length <= availableSlots) return intake;
const acceptedFiles = intake.acceptedFiles.slice(0, availableSlots);
const fileRejections = [
...intake.fileRejections,
...intake.acceptedFiles.slice(availableSlots).map((file) => ({
file,
reason: "too-many-files" as const,
maxFiles,
})),
];
return { acceptedFiles, fileRejections };
}
function appendDropzoneRejections(
intake: DropzoneIntake,
rejections: DropzoneFileRejection[],
): DropzoneIntake {
if (rejections.length === 0) return intake;
const rejectedFiles = new Set(rejections.map((rejection) => rejection.file));
return {
acceptedFiles: intake.acceptedFiles.filter(
(file) => !rejectedFiles.has(file),
),
fileRejections: [...intake.fileRejections, ...rejections],
};
}
function rejectAcceptedFilesForValidationError(
intake: DropzoneIntake,
error: unknown,
): DropzoneIntake {
return {
acceptedFiles: [],
fileRejections: [
...intake.fileRejections,
...intake.acceptedFiles.map((file) => ({
file,
reason: "custom" as const,
code: "validation-error",
details:
error instanceof Error
? { message: error.message, name: error.name }
: error,
})),
],
};
}
function createNextDropzoneItems({
acceptedItems,
multiple,
previousItems,
}: {
acceptedItems: DropzoneFileItem[];
multiple: boolean;
previousItems: DropzoneFileItem[];
}) {
return multiple ? [...previousItems, ...acceptedItems] : acceptedItems;
}
function createDropzoneFileId(file: File): string {
const uniqueId =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
return `${file.name}-${file.size}-${file.lastModified}-${uniqueId}`;
}
function hasDraggedFiles(
dataTransfer:
| Pick<DataTransfer, "items" | "types" | "files">
| null
| undefined,
): boolean {
if (!dataTransfer) return false;
if (dataTransfer.items?.length) {
return Array.from(dataTransfer.items).some((item) => item.kind === "file");
}
return (
Array.from(dataTransfer.types ?? []).includes("Files") ||
(dataTransfer.files?.length ?? 0) > 0
);
}
function composeEventHandlers<Event extends { defaultPrevented: boolean }>(
externalHandler: ((event: Event) => void) | undefined,
internalHandler: (event: Event) => void,
) {
return (event: Event) => {
externalHandler?.(event);
if (!event.defaultPrevented) internalHandler(event);
};
}
function composeRefs<T>(
internalRef: React.MutableRefObject<T | null>,
externalRef: React.Ref<T> | undefined,
) {
return (node: T | null) => {
internalRef.current = node;
if (!externalRef) return;
if (typeof externalRef === "function") {
externalRef(node);
return;
}
externalRef.current = node;
};
}
- Where it goes
- stats
- Interaction
- animated-counterdrag
DependenciesBundle weight · light
- ui-primitives
- @radix-ui/react-slot@^1.1.1